refreshReads(); $stored = $store->read(); $unchanged = isset($stored['fingerprint']) && $stored['fingerprint'] === $fingerprint; $fresh = ($now - self::intIn($stored, 'recorded_at', 0)) < self::REFRESH_AFTER_SECONDS; if ($unchanged && $fresh) { return; } self::write(self::census($entries, $fingerprint, $now, $stored)); } finally { $lock->releaseIfValueIs(array( 'optionName' => self::REFRESH_LOCK_OPTION_NAME, 'value' => $claimValue, )); } } catch (Throwable $e) { abj404_logPhpFallback('canonical-hook-census', 'canonical hook census failed (code ' . $e->getCode() . '): ' . $e->getMessage()); } } /** * The stored reading, or an empty array when nothing has been recorded. * Never throws; an unreadable or malformed record reports as absent rather * than propagating into the support request that asked for it. * * @return array */ public static function read(): array { return self::store()->read(); } /** * The whole reading, ready to store. * * @param array $entries * the `template_redirect` roster, in dispatch order. * @param array $previous the record being replaced, so the * first-observed timestamp survives a re-reading. * @return array */ private static function census(array $entries, string $fingerprint, int $now, array $previous): array { $corePriority = ABJ_404_Solution_HookCallbackRoster::priorityOf($entries, self::CORE_CANONICAL_CALLBACK); $pluginPriority = ABJ_404_Solution_HookCallbackRoster::priorityOf($entries, self::PLUGIN_LISTENER_CALLBACK); // The SECOND way canonicalization stops. A plugin can leave the hook // attached and return false from the `redirect_canonical` filter, which // a hook-attachment reading alone would report as a healthy site. $filterEntries = ABJ_404_Solution_HookCallbackRoster::forHook(self::CORE_CANONICAL_CALLBACK); if ($filterEntries === null) { throw new UnexpectedValueException( 'The WordPress hook registry became unreadable while inspecting ' . self::CORE_CANONICAL_CALLBACK . '.' ); } $suppression = array(); if ($corePriority === null) { $suppression[] = self::SUPPRESSION_HOOK_REMOVED; } if ($filterEntries !== array()) { $suppression[] = self::SUPPRESSION_FILTER_HOOKED; } if ($corePriority !== null && $pluginPriority !== null && $pluginPriority < $corePriority) { $suppression[] = self::SUPPRESSION_PLUGIN_FIRST; } $record = array( 'version' => self::RECORD_VERSION, 'recorded_at' => $now, 'first_recorded_at' => self::intIn($previous, 'first_recorded_at', $now), 'fingerprint' => $fingerprint, 'plugin_version' => defined('ABJ404_VERSION') ? ABJ404_VERSION : '', 'hook' => self::HOOK_NAME, 'core_canonical' => $corePriority === null ? 'detached' : 'attached', 'core_canonical_priority' => $corePriority, 'plugin_listener_priority' => $pluginPriority, 'callback_count' => count($entries), 'suppression' => $suppression, 'canonical_filter' => ABJ_404_Solution_HookCallbackRoster::describeEntriesWithOrigins( $filterEntries, self::MAX_CALLBACKS ), ); // The full roster is recorded ONLY when core's callback is gone. That is // the one branch where a reader has a culprit to find; on an intact hook // the same list names nobody and would spend the payload's bytes saying // so on every healthy install. if ($corePriority === null) { $record['callbacks'] = ABJ_404_Solution_HookCallbackRoster::describeEntriesWithOrigins( $entries, self::MAX_CALLBACKS ); } return $record; } /** * Store the reading. Non-autoloaded on purpose: it is read when a support * payload is assembled, not on every page view, and the front-end check that * decides whether to write it is already paying for one read. * * @param array $record * @return void */ private static function write(array $record): void { self::store()->write($record); } /** * Acquire the stale-reading refresh mutex, recovering a claim left behind * by a request that terminated before its finally block ran. */ private static function claimRefreshLock( ABJ_404_Solution_ExclusiveOptionRow $lock, string $claimValue, int $now ): bool { $claim = array('optionName' => self::REFRESH_LOCK_OPTION_NAME, 'value' => $claimValue); if ($lock->claim($claim)) { return true; } $holder = $lock->valueOf(self::REFRESH_LOCK_OPTION_NAME); $separator = strpos($holder, ':'); $expiresAt = (int)($separator === false ? $holder : substr($holder, 0, $separator)); if ($holder === '' || $expiresAt > $now) { return false; } $lock->releaseIfValueIs(array( 'optionName' => self::REFRESH_LOCK_OPTION_NAME, 'value' => $holder, )); return $lock->claim($claim); } /** Persistence adapter for the census record. */ private static function store(): ABJ_404_Solution_CanonicalHookCensusStore { return new ABJ_404_Solution_CanonicalHookCensusStore(); } /** * One integer field out of a decoded record, or $fallback when the field is * absent or is not something an integer can be read from. The record came * out of an options row that any other code, or a hand edit, could have * left in any shape, so it is validated rather than trusted. * * @param array $record */ private static function intIn(array $record, string $field, int $fallback): int { $value = $record[$field] ?? null; return is_scalar($value) ? (int)$value : $fallback; } /** Epoch seconds, or 0 when no clock is reachable (a boot-order edge). */ private static function now(): int { if (function_exists('abj_clock')) { return (int)abj_clock()->now(); } return function_exists('abj404_now') ? abj404_now() : 0; } }