PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / diagnostics / SameSiteRequestCensus.php

SameSiteRequestCensus.php in 404 Solution trunk, at includes/diagnostics/SameSiteRequestCensus.php

330 lines 13.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Counts how many PHP requests THIS SITE has in flight right now.
9 *
10 * ABJ_404_Solution_HostPressureSampler answers "is the machine busy". On a
11 * LiteSpeed/CloudLinux host that is not the same question as "did this site
12 * run out of its own worker slots": an LVE entry-process cap is per account,
13 * so a site can be throttled to a standstill while `sys_getloadavg()` looks
14 * ordinary, and a site can survive a genuinely loaded box because its own
15 * traffic is light. Only a per-site count separates them, and separating them
16 * is what turns "bucket C, host pressure" into an actionable finding -- the
17 * WordPress Heartbeat API polls every open admin screen unconditionally, a
18 * wp-cron loopback spawns a second PHP request out of the first, and a second
19 * admin tab issues its own table request, so the traffic competing for the
20 * slot is very often the site's own.
21 *
22 * This class owns the POLICY and this request's own place in it: which requests
23 * are in scope, whether this one joined, which lifecycle segment it is inside,
24 * and how long an entry counts as a live request. The rows themselves belong to
25 * ABJ_404_Solution_SameSiteRequestRegistry, which is also why a leftover entry
26 * is recoverable at all: one row per request, written by that request alone,
27 * so a request killed before it could deregister leaves an old row rather than
28 * a corrupted counter. Taking a reading of all those rows, and shaping it into
29 * a finding, belongs to ABJ_404_Solution_SameSiteCensusReading.
30 *
31 * Scope is admin-ajax, wp-cron and admin-screen requests. Ordinary front-end
32 * page views are deliberately excluded: they are the hot 404 path, two extra
33 * queries there would be paid by every visitor of every install, and the four
34 * named contention sources above are all inside the scope that is measured.
35 * Every reading with a finding reports that scope, so an under-count is never
36 * read as a quiet site.
37 */
38 final class ABJ_404_Solution_SameSiteRequestCensus {
39
40 /**
41 * How long an entry counts as a live request. Longer than any request that
42 * is going to finish (the stall under investigation is a 25-second client
43 * timeout against a request the host eventually kills) and short enough
44 * that a killed request's leftover row stops being counted within one
45 * admin session.
46 */
47 const ENTRY_TTL_MS = 300000;
48
49 /**
50 * What a reading covers, named on the reading itself so an under-count is
51 * never read as a quiet site. Ordinary front-end page views are outside it
52 * by design (the hot 404 path pays nothing), as are WP-CLI processes,
53 * which hold no web worker slot.
54 */
55 const SCOPE = 'admin-ajax+cron+admin';
56
57 /**
58 * The segments of a request's own lifecycle, in the order they are entered.
59 *
60 * A stranded row's phase is the whole point of recording one. Report 193
61 * showed four pagination workers still alive 121-198 seconds after their
62 * handlers had returned `status: complete` in 1.3-4.7s, and the census
63 * could say only THAT they were stranded -- naming where cost a hunt
64 * through a rotating journal whose decisive records had already been
65 * elided. These names are chosen so that the phase alone answers it:
66 * each one is a segment with a different fix.
67 *
68 * PHASE_SHUTDOWN deliberately covers everything after the connection is
69 * released, because a worker stranded there is holding a process slot
70 * while owing the browser nothing -- a different failure from one
71 * stranded before it, which is still owed a response.
72 */
73 const PHASE_BOOT = 'boot';
74 const PHASE_HANDLER = 'handler';
75 const PHASE_RESPONSE_ENCODE = 'response_encode';
76 const PHASE_OB_DRAIN = 'ob_drain';
77 const PHASE_DETACH = 'detach';
78 const PHASE_SHUTDOWN = 'shutdown';
79
80 /**
81 * Every phase name, so a reader can tell an unrecognised value (a row
82 * written by a newer build, or a corrupted one) from a known segment.
83 */
84 const PHASES = array(
85 self::PHASE_BOOT,
86 self::PHASE_HANDLER,
87 self::PHASE_RESPONSE_ENCODE,
88 self::PHASE_OB_DRAIN,
89 self::PHASE_DETACH,
90 self::PHASE_SHUTDOWN,
91 );
92
93 /** @var string Option name this request registered under, or '' when it did not join. */
94 private static $ownEntry = '';
95
96 /**
97 * @var array{started_at_ms: int, channel: string, action: string, pid: int|null}|null
98 * What this request registered with. Retained so a phase update can rewrite
99 * the row from these values instead of reading it back first -- a
100 * read-modify-write is the one thing that would cost the registry the
101 * single-writer property its whole design rests on.
102 */
103 private static $ownIdentity = null;
104
105 /** @var string The last phase successfully recorded, so a repeat is not re-written. */
106 private static $ownPhase = '';
107
108 /**
109 * Register this request in the census and arrange for it to leave at
110 * shutdown. Safe to call more than once; only the first call registers.
111 * Never throws.
112 *
113 * @return string the option name this request registered under, or '' when
114 * it did not join (out of scope, no clock, no DAO, or the write failed).
115 */
116 public static function join(): string {
117 try {
118 if (self::$ownEntry !== '') {
119 return self::$ownEntry;
120 }
121 $channel = self::channelForThisRequest();
122 $startedAt = self::nowMs();
123 if ($channel === '' || $startedAt === null) {
124 // An entry with no start time could never be aged out, so it
125 // would become a permanent phantom request. Not registering is
126 // the safe failure.
127 return '';
128 }
129 $action = self::actionForThisRequest();
130 $pid = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processId();
131 $optionName = ABJ_404_Solution_SameSiteRequestRegistry::add(
132 $startedAt,
133 $channel,
134 $action,
135 $pid,
136 self::PHASE_BOOT,
137 ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processToken()
138 );
139 if ($optionName === '') {
140 return '';
141 }
142 self::$ownEntry = $optionName;
143 self::$ownIdentity = array(
144 'started_at_ms' => $startedAt,
145 'channel' => $channel,
146 'action' => $action,
147 'pid' => $pid,
148 );
149 self::$ownPhase = self::PHASE_BOOT;
150 register_shutdown_function(array(__CLASS__, 'leave'));
151 return $optionName;
152 } catch (Throwable $e) {
153 self::reportFailure('same-site census join failed: ' . $e->getMessage());
154 return '';
155 }
156 }
157
158 /**
159 * Record that this request is ENTERING the named segment of its lifecycle.
160 *
161 * Call it immediately before the segment runs, never after it returns: the
162 * rows worth reading belong to requests that never came back, so a phase
163 * written on the way out is the one phase a stranded row can never carry.
164 *
165 * A healthy request deletes its row at shutdown and leaves nothing behind,
166 * so this costs one UPDATE per transition and stores nothing long-term.
167 * An abandoned row keeps the last phase its request survived long enough
168 * to write, which is the segment it was inside when it stopped.
169 *
170 * Never throws: a request must not fail because the census could not
171 * describe it.
172 *
173 * @param string $phase one of self::PHASES.
174 * @return bool whether the phase was recorded.
175 */
176 public static function markPhase(string $phase): bool {
177 try {
178 if (self::$ownEntry === '' || self::$ownIdentity === null
179 || !in_array($phase, self::PHASES, true)) {
180 return false;
181 }
182 if ($phase === self::$ownPhase) {
183 // Re-entering the same segment says nothing new and the write
184 // is paid on the path being measured.
185 return true;
186 }
187 $identity = self::$ownIdentity;
188 $recorded = ABJ_404_Solution_SameSiteRequestRegistry::advance(
189 self::$ownEntry,
190 $identity['started_at_ms'],
191 $identity['channel'],
192 $identity['action'],
193 $identity['pid'],
194 $phase
195 );
196 if ($recorded) {
197 self::$ownPhase = $phase;
198 }
199 return $recorded;
200 } catch (Throwable $e) {
201 self::reportFailure('same-site census phase update failed: ' . $e->getMessage());
202 return false;
203 }
204 }
205
206 /**
207 * Remove this request's entry. Idempotent, and safe to call when the
208 * request never joined. Never throws.
209 */
210 public static function leave(): void {
211 try {
212 if (self::$ownEntry === '') {
213 return;
214 }
215 $optionName = self::$ownEntry;
216 self::$ownEntry = '';
217 self::$ownIdentity = null;
218 self::$ownPhase = '';
219 ABJ_404_Solution_SameSiteRequestRegistry::remove(array($optionName));
220 } catch (Throwable $e) {
221 self::reportFailure('same-site census leave failed: ' . $e->getMessage());
222 }
223 }
224
225 /**
226 * Return this class to the state a freshly started PHP process is in:
227 * no census identity, no memoized reading.
228 *
229 * One web request is one process, so a normal request never needs this.
230 * A process that serves several requests in sequence does -- a persistent
231 * SAPI, or any harness that drives more than one request through one
232 * interpreter -- because the second request would otherwise inherit the
233 * first request's census identity and report another request's row as its
234 * own. This is the seam for returning that state, not a back door into it.
235 */
236 public static function resetRequestState(): void {
237 self::$ownEntry = '';
238 self::$ownIdentity = null;
239 self::$ownPhase = '';
240 }
241
242 /**
243 * The option name this request registered under, or '' when it did not
244 * join.
245 *
246 * Public because a reading has to tell this request's own row apart from a
247 * competitor's, and that is the ONLY thing it needs from this class's
248 * private state. Exposing the name rather than letting the reading reach
249 * into the identity is what keeps the dependency one-directional.
250 */
251 public static function ownEntryName(): string {
252 return self::$ownEntry;
253 }
254
255 /**
256 * Which census channel this request belongs to, or '' when it is out of
257 * scope. WP-CLI is excluded on purpose: a CLI process does not consume a
258 * web worker slot, so counting it would overstate the contention.
259 */
260 public static function channelForThisRequest(): string {
261 if (defined('WP_CLI') && WP_CLI) {
262 return '';
263 }
264 if (function_exists('wp_doing_ajax') && wp_doing_ajax()) {
265 return 'ajax';
266 }
267 // wp_doing_cron() rather than the DOING_CRON constant: it is
268 // filterable the way WordPress itself lets other code correct the
269 // signal, and it can be substituted in a test instead of leaking a
270 // process-wide constant the moment one test defines it. Same reasoning
271 // as AjaxDiagnosticRequestPolicy::bootWaypointRequestId()'s wp_doing_ajax() call.
272 if (function_exists('wp_doing_cron') && wp_doing_cron()) {
273 return 'cron';
274 }
275 if (function_exists('is_admin') && is_admin()) {
276 return 'admin';
277 }
278 return '';
279 }
280
281 /** The WordPress action this request names, bounded and character-restricted. */
282 private static function actionForThisRequest(): string {
283 $raw = isset($_REQUEST['action']) && is_scalar($_REQUEST['action'])
284 ? (string)$_REQUEST['action'] : '';
285 return preg_match('/^[A-Za-z0-9_-]{1,64}$/', $raw) === 1 ? $raw : '';
286 }
287
288 /**
289 * Seconds as a float from the injected clock, or null when there is no
290 * clock yet.
291 *
292 * The census can be reached during the boot window (a boot-lifecycle
293 * checkpoint fires before the service locator file is even required), and
294 * reading the raw system clock there would both defeat the deterministic
295 * test seam and silently mix two time sources inside one reading. Null
296 * instead: a census with no clock cannot age its entries, and an
297 * unavailable reading is the honest answer. The same window has no DAO
298 * either, so nothing is lost that was otherwise obtainable.
299 *
300 * Public because deciding what time means for a census -- one injected
301 * clock, null rather than a raw fallback during boot -- is this class's
302 * policy, and ABJ_404_Solution_SameSiteCensusReading has to age entries
303 * against the same one. A second time source inside one reading is exactly
304 * what this null is here to prevent.
305 */
306 public static function nowFloat(): ?float {
307 if (!function_exists('abj_clock')) {
308 return null;
309 }
310 try {
311 return abj_clock()->nowFloat();
312 } catch (Throwable $e) {
313 self::reportFailure('same-site census clock unavailable: ' . $e->getMessage());
314 return null;
315 }
316 }
317
318 /** Milliseconds from the census clock, or null when there is none yet. */
319 public static function nowMs(): ?int {
320 $now = self::nowFloat();
321 return $now === null ? null : (int)round($now * 1000);
322 }
323
324 private static function reportFailure(string $message): void {
325 if (function_exists('abj404_logPhpFallback')) {
326 abj404_logPhpFallback('same-site-census', $message);
327 }
328 }
329 }
330