| 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 |
|