$_REQUEST['requestId'] ?? '', 'retryCount' => $_REQUEST['retryCount'] ?? '', 'retryParentId' => $_REQUEST['retryParentId'] ?? '', 'part' => $_REQUEST['part'] ?? '', 'subpage' => $_REQUEST['subpage'] ?? '', 'clientSentAt' => $_REQUEST['clientSentAt'] ?? '', )); // Deliberately no request id: the census is not debug-gated, and a // durable record per table request would put a file write on the // unarmed path. The empty id makes this bracket persist nothing; // it exists because the diagnostics-wide guard // (DecisiveRecordManifestTest) requires every hook mutation here // to run inside traceBoundary(). (new ABJ_404_Solution_HookInstrumentationLifecycleTracer('', 'same_site_request_census'))->traceBoundary( ABJ_404_Solution_HookInstrumentationLifecycleTracer::PHASE_REGISTRATION, 'shutdown', static function (): void { add_action('shutdown', array('ABJ_404_Solution_RequestPhaseTimeline', 'stampWpShutdown'), PHP_INT_MIN); }); } $optionName = ABJ_404_Solution_SameSiteRequestRegistry::add( array( 'started_at_ms' => $startedAt, 'channel' => $channel, 'action' => $action, 'pid' => $pid, 'phase' => self::PHASE_BOOT, 'timeline' => ABJ_404_Solution_RequestPhaseTimeline::encode(), ), ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processToken() ); if ($optionName === '') { if ($action === self::TABLE_ENDPOINT_ACTION) { ABJ_404_Solution_RequestFailureFrame::storageFailed(); } return ''; } self::$ownEntry = $optionName; self::$ownIdentity = array( 'started_at_ms' => $startedAt, 'channel' => $channel, 'action' => $action, 'pid' => $pid, ); self::$ownPhase = self::PHASE_BOOT; register_shutdown_function(array(__CLASS__, 'leave')); return $optionName; } catch (Throwable $e) { if (class_exists('ABJ_404_Solution_RequestFailureFrame', false)) { ABJ_404_Solution_RequestFailureFrame::storageFailed(); } self::reportFailure('same-site census join failed: ' . $e->getMessage()); return ''; } } /** * Record that this request is ENTERING the named segment of its lifecycle. * * Call it immediately before the segment runs, never after it returns: the * rows worth reading belong to requests that never came back, so a phase * written on the way out is the one phase a stranded row can never carry. * * A healthy request deletes its row at shutdown and leaves nothing behind, * so this costs one UPDATE per transition and stores nothing long-term. * An abandoned row keeps the last phase its request survived long enough * to write, which is the segment it was inside when it stopped. * * Never throws: a request must not fail because the census could not * describe it. * * @param string $phase one of self::PHASES. * @return bool whether the phase was recorded. */ public static function markPhase(string $phase): bool { try { if (self::$ownEntry === '' || self::$ownIdentity === null || !in_array($phase, self::PHASES, true)) { return false; } if ($phase === self::$ownPhase) { // Re-entering the same segment says nothing new and the write // is paid on the path being measured. return true; } ABJ_404_Solution_RequestPhaseTimeline::stamp($phase); if (self::$ownIdentity['action'] === self::TABLE_ENDPOINT_ACTION) { ABJ_404_Solution_RequestFailureFrame::stage($phase); } $recorded = self::writeTimeline($phase); if ($recorded) { self::$ownPhase = $phase; } return $recorded; } catch (Throwable $e) { self::reportFailure('same-site census phase update failed: ' . $e->getMessage()); return false; } } /** * Record that this request proved nonce and admin access. Call it once, * right after the endpoint's authorization passes and never before: it is * what lets leave() retire the row and promote the timeline into the * durable ledger. Without it a finished request deletes its row like any * other, because the identifiers the timeline is keyed on came from an * unauthenticated client and could otherwise be forged to fill the ledger. * * @return void */ public static function markAuthorized(): void { $context = $GLOBALS['abj404_ajax_context'] ?? array(); ABJ_404_Solution_RequestFailureFrame::authorize(is_array($context) ? $context : array()); if (self::$ownEntry !== '') { self::$ownAuthorized = true; self::checkpoint(); } } /** Persist a bounded authorized stage/query checkpoint through this request's existing row. */ public static function checkpoint(): bool { if (!self::$ownAuthorized || self::$ownIdentity === null || self::$ownIdentity['action'] !== self::TABLE_ENDPOINT_ACTION) { return false; } return self::writeTimeline(self::$ownPhase); } public static function isPersisting(): bool { return self::$persisting; } /** Single guarded persistence boundary shared by phases and bounded failure-frame checkpoints. */ private static function writeTimeline(string $phase): bool { if (self::$persisting || self::$ownEntry === '' || self::$ownIdentity === null) { return false; } self::$persisting = true; try { $recorded = ABJ_404_Solution_SameSiteRequestRegistry::advance(self::$ownEntry, array_merge(self::$ownIdentity, array('phase' => $phase, 'timeline' => ABJ_404_Solution_RequestPhaseTimeline::encode()))); if (!$recorded) { ABJ_404_Solution_RequestFailureFrame::storageFailed(); } return $recorded; } catch (Throwable $e) { ABJ_404_Solution_RequestFailureFrame::storageFailed(); self::reportFailure('same-site checkpoint failed: ' . $e->getMessage()); return false; } finally { self::$persisting = false; } } /** * Remove this request's entry. Idempotent, and safe to call when the * request never joined. Never throws. * * A finished instrumented request that proved admin access (see * markAuthorized()) is retired rather than deleted: its row * is renamed into the retired namespace with its final timeline, kept * for RETIRED_TTL_MS, and promoted into the durable ledger when it is * noteworthy. Every other request deletes its row exactly as before. */ public static function leave(): void { try { if (self::$ownEntry === '' || self::$ownIdentity === null) { return; } $optionName = self::$ownEntry; $identity = self::$ownIdentity; $phase = self::$ownPhase; $authorized = self::$ownAuthorized; ABJ_404_Solution_RequestPhaseTimeline::stamp('leave'); ABJ_404_Solution_RequestPhaseTimeline::noteCpuSinceBoot(); $described = ABJ_404_Solution_RequestPhaseTimeline::toArray(); $rid = isset($described['rid']) && is_string($described['rid']) ? $described['rid'] : ''; if ($authorized && $identity['action'] === self::TABLE_ENDPOINT_ACTION && $rid !== '' && $rid !== ABJ_404_Solution_AjaxRequestLedger::UNKNOWN_ID) { self::preserveInterruptedAccount($identity, $phase, $described); ABJ_404_Solution_SameSiteRequestRegistry::retire($optionName, array( 'started_at_ms' => $identity['started_at_ms'], 'channel' => $identity['channel'], 'action' => $identity['action'], 'pid' => $identity['pid'], 'phase' => $phase, 'timeline' => ABJ_404_Solution_RequestPhaseTimeline::encode(), ), $rid); $now = self::nowMs(); if ($now !== null) { ABJ_404_Solution_SameSiteRequestRegistry::pruneRetired( $now - self::RETIRED_TTL_MS); } $now = self::nowMs(); ABJ_404_Solution_RequestTimelinePromoter::promoteOwnIfNoteworthy( $now === null ? null : $now - $identity['started_at_ms']); } else { ABJ_404_Solution_SameSiteRequestRegistry::remove(array($optionName)); } self::$ownEntry = ''; self::$ownIdentity = null; self::$ownPhase = ''; self::$ownAuthorized = false; } catch (Throwable $e) { self::reportFailure('same-site census leave failed: ' . $e->getMessage()); } } /** * Keep an authorized table request that shutdown reached before a response. * LSAPI aborts can run PHP cleanup; retirement must not erase their account. * * @param array{started_at_ms: int, channel: string, action: string, pid: int|null} $identity * @param array $timeline */ private static function preserveInterruptedAccount(array $identity, string $phase, array $timeline): void { $stamps = isset($timeline['t']) && is_array($timeline['t']) ? $timeline['t'] : array(); if (!isset($stamps['shutdown']) || isset($stamps['echoed'])) { return; } $now = self::nowMs(); ABJ_404_Solution_StrandedRequestLedger::record(array(array( 'started_at_ms' => $identity['started_at_ms'], 'channel' => $identity['channel'], 'action' => $identity['action'], 'pid' => $identity['pid'], 'phase' => $phase, 'age_ms' => $now === null ? 0 : max(0, $now - $identity['started_at_ms']), 'timeline' => ABJ_404_Solution_RequestPhaseTimeline::encode(), ))); } /** * Return this class to the state a freshly started PHP process is in: * no census identity, no memoized reading. * * One web request is one process, so a normal request never needs this. * A process that serves several requests in sequence does -- a persistent * SAPI, or any harness that drives more than one request through one * interpreter -- because the second request would otherwise inherit the * first request's census identity and report another request's row as its * own. This is the seam for returning that state, not a back door into it. */ public static function resetRequestState(): void { self::$ownEntry = ''; self::$ownIdentity = null; self::$ownPhase = ''; self::$ownAuthorized = false; self::$persisting = false; } /** * The option name this request registered under, or '' when it did not * join. * * Public because a reading has to tell this request's own row apart from a * competitor's, and that is the ONLY thing it needs from this class's * private state. Exposing the name rather than letting the reading reach * into the identity is what keeps the dependency one-directional. */ public static function ownEntryName(): string { return self::$ownEntry; } /** * Which census channel this request belongs to, or '' when it is out of * scope. WP-CLI is excluded on purpose: a CLI process does not consume a * web worker slot, so counting it would overstate the contention. */ public static function channelForThisRequest(): string { if (defined('WP_CLI') && WP_CLI) { return ''; } if (function_exists('wp_doing_ajax') && wp_doing_ajax()) { return 'ajax'; } // wp_doing_cron() rather than the DOING_CRON constant: it is // filterable the way WordPress itself lets other code correct the // signal, and it can be substituted in a test instead of leaking a // process-wide constant the moment one test defines it. Same reasoning // as AjaxDiagnosticRequestPolicy::bootWaypointRequestId()'s wp_doing_ajax() call. if (function_exists('wp_doing_cron') && wp_doing_cron()) { return 'cron'; } if (function_exists('is_admin') && is_admin()) { return 'admin'; } return ''; } /** The WordPress action this request names, bounded and character-restricted. */ private static function actionForThisRequest(): string { $raw = isset($_REQUEST['action']) && is_scalar($_REQUEST['action']) ? (string)$_REQUEST['action'] : ''; return preg_match('/^[A-Za-z0-9_-]{1,64}$/', $raw) === 1 ? $raw : ''; } /** * Seconds as a float from the injected clock, or null when there is no * clock yet. * * The census can be reached during the boot window (a boot-lifecycle * checkpoint fires before the service locator file is even required), and * reading the raw system clock there would both defeat the deterministic * test seam and silently mix two time sources inside one reading. Null * instead: a census with no clock cannot age its entries, and an * unavailable reading is the honest answer. The same window has no DAO * either, so nothing is lost that was otherwise obtainable. * * Public because deciding what time means for a census -- one injected * clock, null rather than a raw fallback during boot -- is this class's * policy, and ABJ_404_Solution_SameSiteCensusReading has to age entries * against the same one. A second time source inside one reading is exactly * what this null is here to prevent. */ public static function nowFloat(): ?float { if (!function_exists('abj_clock')) { return null; } try { return abj_clock()->nowFloat(); } catch (Throwable $e) { self::reportFailure('same-site census clock unavailable: ' . $e->getMessage()); return null; } } /** Milliseconds from the census clock, or null when there is none yet. */ public static function nowMs(): ?int { $now = self::nowFloat(); return $now === null ? null : (int)round($now * 1000); } private static function reportFailure(string $message): void { if (function_exists('abj404_logPhpFallback')) { abj404_logPhpFallback('same-site-census', $message); } } }