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 / ClientTransportReport.php

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

423 lines 19.5 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 * The browser's half of the request ledger (Bruno timeout cause matrix,
9 * coverage req. 6).
10 *
11 * The server flight recorder can prove what PHP did with a request. It cannot
12 * prove that the request ever left the browser, how long it sat in the browser
13 * connection queue, whether response headers arrived and the body then stalled,
14 * or whether the completion callback lost a race to jQuery's timeout timer.
15 * Only the browser can answer those, and only if what it observed gets back
16 * here. This class is where it lands.
17 *
18 * Two arrival routes, both authenticated exactly like a normal table request:
19 *
20 * 1. Riding the next table request's params (the primary route: the client
21 * attaches its previous attempt's record to the following request, so the
22 * evidence arrives even if the admin never sends a support request).
23 * 2. A clientReportOnly beacon fired after the last attempt of a failed
24 * request, which does no table work and returns immediately.
25 *
26 * Reports are journaled through ABJ_404_Solution_AjaxCheckpointLogger rather
27 * than the trace class, for the same reason the server checkpoints are: a
28 * defect in the component under investigation must not be able to erase the
29 * evidence about it. The payload is treated as untrusted text throughout: it
30 * is length-bounded, parsed defensively, and never echoed back to any client.
31 */
32 final class ABJ_404_Solution_ClientTransportReport {
33
34 /**
35 * Hard bound on a single client report. The client trims its own record to
36 * 4000 characters before sending; this is the server refusing to journal
37 * more than that regardless of what actually arrives.
38 */
39 const MAX_REPORT_BYTES = 4096;
40
41 /**
42 * Hard bound on the raw drained buffer BEFORE it is parsed. Only an input
43 * guard against an absurd POST; the shipping bound is the caller's budget.
44 */
45 const MAX_DRAINED_BUFFER_INPUT_BYTES = 131072;
46
47 /**
48 * The only attempt outcome that means "did not fail". An allowlist, not a
49 * deny-list: 'pending' is an attempt that never finished (the hung request
50 * itself) and an unrecognised or absent outcome is an unknown, which is
51 * worth more than a known success when something has to be dropped.
52 */
53 const HEALTHY_OUTCOMES = array('success');
54
55 /**
56 * Attempt ids carried into the support-collection manifest. The browser's
57 * own ring buffer holds 16 records, so this is that ceiling plus headroom
58 * for a buffer that arrives from an older or a modified client.
59 */
60 const MAX_ATTEMPT_IDS_REPORTED = 32;
61
62 /**
63 * Read, bound, and journal whatever the browser said about a previous
64 * attempt, plus the build identity of the JavaScript that said it. Never
65 * throws: a malformed or absent report must not affect the request that
66 * carried it.
67 */
68 public static function journal(string $requestId): void {
69 try {
70 $reader = self::requestReader();
71 $build = (string)$reader->getPostOrGetSanitize('clientBuild', '');
72 $buildModules = (string)$reader->getPostOrGetSanitize('clientBuildModules', '');
73 $inflight = (string)$reader->getPostOrGetSanitize('clientInflight', '');
74 $tabs = (string)$reader->getPostOrGetSanitize('clientTabs', '');
75 $foreignInflight = (string)$reader->getPostOrGetSanitize('clientForeignInflight', '');
76 $storageHealth = (string)$reader->getPostOrGetSanitize('clientStorageHealth', '');
77 if ($build !== '' || $inflight !== '' || $tabs !== '' || $foreignInflight !== '' ||
78 $storageHealth !== '') {
79 // What the client said about ITSELF at send time: which
80 // JavaScript is executing, how many other plugin requests that
81 // tab already had open, how many admin tabs of the page are
82 // open at all, and how much non-plugin AJAX the tab had
83 // outstanding. The last two are the browser's half of the
84 // same-site contention the server counts in
85 // ABJ_404_Solution_SameSiteRequestCensus; neither used to be
86 // recorded anywhere, so a cross-tab cause could not even be
87 // suspected from the evidence that survived. The previous
88 // attempt's story is a separate record below.
89 ABJ_404_Solution_AjaxCheckpointLogger::record(
90 $requestId,
91 'client_send_state',
92 array_merge(
93 ABJ_404_Solution_ClientBuildFingerprint::compare($build, $buildModules),
94 array(
95 'inflight' => ctype_digit($inflight) ? (int)$inflight : null,
96 'inflight_ids' => substr(
97 (string)$reader->getPostOrGetSanitize('clientInflightIds', ''), 0, 256),
98 // -1 is the client's own "could not observe this",
99 // and it is preserved rather than folded into null:
100 // an unobservable channel and an absent parameter
101 // are different findings about the client.
102 'open_tabs' => self::signedCountOrNull($tabs),
103 'foreign_inflight' => self::signedCountOrNull($foreignInflight),
104 'storage_health' => self::parseStorageHealth($storageHealth),
105 )
106 )
107 );
108 }
109 $report = self::readReport($reader);
110 if ($report === null) {
111 return;
112 }
113 // Nested under one key, never spread across the record: the
114 // envelope's own fields (request_id, event, ts, pid) are the join
115 // keys the whole journal is read by, and a client that sent a
116 // field with one of those names would otherwise overwrite them and
117 // forge the identity of its own evidence.
118 if (ABJ_404_Solution_ConcurrentControlReceipt::isBrowserReceipt($report)) {
119 ABJ_404_Solution_ConcurrentControlReceipt::journal(array(
120 'carrierRequestId' => $requestId,
121 'sessionId' => (string)ABJ_404_Solution_AjaxRequestLedger::readFields($reader)['session_id'],
122 'report' => $report,
123 ));
124 return;
125 }
126 ABJ_404_Solution_AjaxCheckpointLogger::record(
127 $requestId, 'client_prior_attempt', array('report' => $report));
128 } catch (Throwable $e) {
129 ABJ_404_Solution_AjaxCheckpointLogger::record($requestId, 'client_report_error', array(
130 'message' => substr($e->getMessage(), 0, 200),
131 ));
132 }
133 }
134
135
136 /**
137 * A client-sent count that is allowed to be -1 ("this browser could not
138 * observe it"), or null when the parameter was absent or not a count at
139 * all. Kept separate from ctype_digit() because -1 is a real reading here
140 * and silently discarding it would turn a declared blind spot into a
141 * missing field.
142 */
143 private static function signedCountOrNull(string $raw): ?int {
144 return preg_match('/^-?\d{1,9}$/', $raw) === 1 ? (int)$raw : null;
145 }
146
147 /**
148 * The browser storage adapter's bounded health result. Rebuild the shape
149 * field by field because this is untrusted request data; malformed input
150 * remains a positive "unparseable" finding rather than blocking the table.
151 *
152 * @return array<string, mixed>
153 */
154 private static function parseStorageHealth(string $raw): array {
155 if ($raw === '') {
156 return array('status' => 'absent', 'raw_length' => 0);
157 }
158 $decoded = json_decode(substr($raw, 0, 512), true);
159 if (!is_array($decoded)) {
160 return array('status' => 'unparseable', 'raw_length' => strlen($raw));
161 }
162 $status = isset($decoded['status']) && is_scalar($decoded['status'])
163 ? (string)$decoded['status'] : 'unknown';
164 $quota = isset($decoded['quota']) && is_scalar($decoded['quota'])
165 ? (string)$decoded['quota'] : 'unknown';
166 $fallback = isset($decoded['fallback']) && is_scalar($decoded['fallback'])
167 ? (string)$decoded['fallback'] : 'memory';
168 return array(
169 'status' => in_array($status, array('available', 'unavailable'), true) ? $status : 'unknown',
170 'accessible' => is_bool($decoded['accessible'] ?? null) ? $decoded['accessible'] : null,
171 'writable' => is_bool($decoded['writable'] ?? null) ? $decoded['writable'] : null,
172 'quota' => in_array($quota, array('ok', 'exceeded', 'unknown'), true) ? $quota : 'unknown',
173 'last_write_ok' => is_bool($decoded['last_write_ok'] ?? null)
174 ? $decoded['last_write_ok'] : null,
175 'fallback' => in_array($fallback, array('none', 'memory'), true) ? $fallback : 'memory',
176 );
177 }
178
179 /**
180 * The request reader, straight from the container.
181 *
182 * Resolved here rather than through
183 * ABJ_404_Solution_AjaxAdminEndpointSupport::getRequestReader(), which
184 * returns this same service and belongs to the endpoint layer. Reading
185 * request parameters is not an endpoint-only need, and routing through
186 * that class made a recorder depend on the presentation surface it exists
187 * to observe.
188 *
189 * @return ABJ_404_Solution_RequestInputNormalizer
190 */
191 private static function requestReader() {
192 /** @var ABJ_404_Solution_RequestInputNormalizer $requestReader */
193 $requestReader = abj_service('request_input_normalizer');
194 return $requestReader;
195 }
196
197 /**
198 * The decoded client report, or null when none was sent. Returns a
199 * diagnostic stand-in (never null) when a report was sent but could not be
200 * decoded: "the client sent something unparseable" is itself a finding
201 * about the transport and must not be silently dropped.
202 *
203 * @param ABJ_404_Solution_RequestInputNormalizer $reader Docblock-typed only:
204 * tests substitute request-reader doubles that are not literally that class.
205 * @return array<string, mixed>|null
206 */
207 private static function readReport($reader): ?array {
208 $raw = $reader->getPostOrGetSanitize('clientReport', '');
209 if (!is_scalar($raw) || (string)$raw === '') {
210 return null;
211 }
212 $raw = (string)$raw;
213 $truncated = strlen($raw) > self::MAX_REPORT_BYTES;
214 $decoded = json_decode(substr($raw, 0, self::MAX_REPORT_BYTES), true);
215 if (!is_array($decoded)) {
216 return array(
217 'decoded' => false,
218 'json_error' => json_last_error_msg(),
219 'raw_length' => strlen($raw),
220 'raw_head' => substr($raw, 0, 200),
221 );
222 }
223 // Rebuilt key by key rather than passed through: the decoded value is
224 // whatever the browser sent, so its keys are only assumed to be
225 // strings until they are made so here.
226 $report = array();
227 foreach ($decoded as $key => $value) {
228 $report[(string)$key] = $value;
229 }
230 $report['decoded'] = true;
231 $report['truncated_on_arrival'] = $truncated;
232 return $report;
233 }
234
235 /**
236 * The attempt outcomes the browser says its drained buffer describes.
237 *
238 * The support payload is the one place both halves of the request ledger
239 * meet, so "the browser is reporting attempt X and the collected journals
240 * never mention X" is a decisive fact about the COLLECTION rather than
241 * about the request -- and it is only available if the ids the browser
242 * named are read before the buffer is bounded down to fit the payload.
243 * Parsing lives here, next to boundDrainedBuffer(), because this class
244 * already owns every rule about what that buffer is; the manifest that
245 * consumes this owns none of them.
246 *
247 * The three statuses are kept distinct on purpose: "the browser sent
248 * nothing" and "the browser sent something we could not read" are
249 * different findings, and collapsing the second into an empty id list is
250 * the same silent-empty defect this whole manifest exists to end.
251 *
252 * @param string $raw The raw POSTed buffer, already unslashed.
253 * A failure is sticky across duplicate records. Browser storage is a
254 * ring buffer and a retry can leave more than one account of an attempt;
255 * a later success must not erase an earlier timeout, and a later timeout
256 * must still override an earlier success. Only the explicit `success`
257 * outcome is healthy, matching the ranking rules used after journaling.
258 *
259 * @return array{status: string, ids: array<int, string>, records: int, outcomes: array<string, bool>}
260 * status: `absent`, `unparseable`, or `parsed`.
261 */
262 public static function attemptOutcomesInDrainedBuffer(string $raw): array {
263 if ($raw === '') {
264 return array(
265 'status' => 'absent', 'ids' => array(), 'records' => 0, 'outcomes' => array(),
266 );
267 }
268 $boundedRaw = substr($raw, 0, self::MAX_DRAINED_BUFFER_INPUT_BYTES);
269 $decoded = json_decode($boundedRaw, true);
270 if (!is_array($decoded) || !self::isJsonArrayDocument($boundedRaw)) {
271 return array(
272 'status' => 'unparseable', 'ids' => array(), 'records' => 0, 'outcomes' => array(),
273 );
274 }
275 $ids = array();
276 $outcomes = array();
277 foreach ($decoded as $record) {
278 if (!is_array($record) || !isset($record['id']) || !is_scalar($record['id'])) {
279 continue;
280 }
281 $id = (string)$record['id'];
282 // The wire contract's own request-id shape. An id that cannot be a
283 // server request id cannot be reconciled against one, and letting
284 // arbitrary browser text into the manifest would put an unbounded
285 // string in a bounded record.
286 if (preg_match('/^[a-zA-Z0-9]{1,64}$/', $id) !== 1) {
287 continue;
288 }
289 $ids[$id] = true;
290 $outcome = isset($record['outcome']) && is_scalar($record['outcome'])
291 ? (string)$record['outcome'] : '';
292 $healthy = in_array($outcome, self::HEALTHY_OUTCOMES, true);
293 if (!array_key_exists($id, $outcomes) || !$healthy) {
294 $outcomes[$id] = $healthy;
295 }
296 }
297 return array(
298 'status' => 'parsed',
299 'ids' => array_slice(array_keys($ids), 0, self::MAX_ATTEMPT_IDS_REPORTED),
300 'records' => count($decoded),
301 'outcomes' => $outcomes,
302 );
303 }
304
305 /**
306 * Fit the browser's drained attempt buffer inside a byte budget WITHOUT
307 * destroying it.
308 *
309 * The buffer is a JSON array of per-attempt records, and it can exceed
310 * what the support payload will carry: the browser store holds up to 16
311 * records / 48 KB. Cutting the serialized array at a byte offset -- which
312 * is what both ends used to do -- leaves invalid JSON, so an overflowing
313 * buffer arrived as "unparseable" and EVERY attempt was lost rather than
314 * the least interesting one. That is the same defect the journal excerpt
315 * had, on the one channel that can describe attempts the server never saw
316 * at all.
317 *
318 * So whole records are dropped, not bytes, and the ones kept are chosen:
319 * attempts that did not succeed first (oldest first, because the first
320 * failure is the one without retry effects), then the rest newest first.
321 *
322 * @param string $raw The raw POSTed buffer.
323 * @param int $budgetBytes Ceiling for the returned JSON.
324 * @return array{json: string, parsed: bool, kept: int, dropped: int, raw_length: int, error: string}
325 */
326 public static function boundDrainedBuffer(string $raw, int $budgetBytes): array {
327 $rawLength = strlen($raw);
328 $unparseable = array(
329 'json' => '', 'parsed' => false, 'kept' => 0, 'dropped' => 0,
330 'raw_length' => $rawLength, 'error' => '',
331 );
332 if ($raw === '') {
333 return $unparseable;
334 }
335 $boundedRaw = substr($raw, 0, self::MAX_DRAINED_BUFFER_INPUT_BYTES);
336 $decoded = json_decode($boundedRaw, true);
337 if (!is_array($decoded) || !self::isJsonArrayDocument($boundedRaw)) {
338 $unparseable['error'] = is_array($decoded)
339 ? 'expected a JSON array of attempt records'
340 : json_last_error_msg();
341 return $unparseable;
342 }
343 $records = array();
344 foreach ($decoded as $record) {
345 $records[] = $record;
346 }
347 if ($rawLength <= self::MAX_DRAINED_BUFFER_INPUT_BYTES && $rawLength <= $budgetBytes) {
348 return array(
349 'json' => $raw, 'parsed' => true, 'kept' => count($records), 'dropped' => 0,
350 'raw_length' => $rawLength, 'error' => '',
351 );
352 }
353
354 $kept = self::keepWithinBudget($records, $budgetBytes);
355 $json = json_encode($kept, JSON_UNESCAPED_SLASHES);
356 if (!is_string($json) || strlen($json) > $budgetBytes) {
357 $unparseable['error'] = 'buffer could not be reduced to the support budget';
358 return $unparseable;
359 }
360 return array(
361 'json' => $json, 'parsed' => true, 'kept' => count($kept),
362 'dropped' => count($records) - count($kept), 'raw_length' => $rawLength, 'error' => '',
363 );
364 }
365
366 /**
367 * Whether decoded JSON came from the buffer's required top-level array.
368 *
369 * Associative decoding turns both JSON objects and arrays into PHP arrays,
370 * so the decoded type alone cannot enforce the wire contract. Inspecting
371 * the first non-whitespace byte keeps a valid object from being reported as
372 * a successfully parsed empty attempt list.
373 */
374 private static function isJsonArrayDocument(string $raw): bool {
375 return substr(ltrim($raw), 0, 1) === '[';
376 }
377
378 /**
379 * The records that fit, in their original order, failures first.
380 *
381 * @param array<int, mixed> $records
382 * @return array<int, mixed>
383 */
384 private static function keepWithinBudget(array $records, int $budgetBytes): array {
385 $failed = array();
386 $healthy = array();
387 foreach ($records as $position => $record) {
388 $outcome = is_array($record) && isset($record['outcome']) && is_scalar($record['outcome'])
389 ? (string)$record['outcome'] : '';
390 if (in_array($outcome, self::HEALTHY_OUTCOMES, true)) {
391 $healthy[] = $position;
392 } else {
393 $failed[] = $position;
394 }
395 }
396 $order = array_merge($failed, array_reverse($healthy));
397
398 // Two brackets and the commas between the records; charged up front so
399 // the encoded result cannot creep past the budget on the last record.
400 $used = 2;
401 $keepPositions = array();
402 foreach ($order as $position) {
403 $encoded = json_encode($records[$position], JSON_UNESCAPED_SLASHES);
404 if (!is_string($encoded)) {
405 continue;
406 }
407 $cost = strlen($encoded) + ($keepPositions === array() ? 0 : 1);
408 if ($used + $cost > $budgetBytes) {
409 continue;
410 }
411 $used += $cost;
412 $keepPositions[] = $position;
413 }
414 sort($keepPositions);
415
416 $kept = array();
417 foreach ($keepPositions as $position) {
418 $kept[] = $records[$position];
419 }
420 return $kept;
421 }
422 }
423