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

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

93 lines 3.3 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 structural shape of a response payload, measured BEFORE anything tries
9 * to encode it: maximum nesting depth, element count, and total string bytes.
10 *
11 * Recorded on the `json_encode_start` checkpoint so that a stall, a fatal or a
12 * memory exhaustion INSIDE json_encode() is attributable to a payload shape
13 * rather than vanishing into an absence. Without it, the trace of a request
14 * that died mid-encode ends at a boundary that says nothing about what it was
15 * handed.
16 *
17 * Bounded on purpose, in two directions. A diagnostic that walks an
18 * unboundedly deep or unboundedly wide structure becomes the next unmeasured
19 * hang, which is exactly the failure class this instrumentation exists to
20 * catch; so the walk stops at a depth and an element count and reports
21 * `truncated` rather than pretending it saw everything. `truncated` is a
22 * finding in its own right: it says the payload was pathological enough to
23 * outrun the measurement.
24 *
25 * Pure: no I/O, no globals, no WordPress. It lives here rather than inside
26 * ABJ_404_Solution_AjaxResponseEmitter because "how big and how deep is this
27 * value" has nothing to do with headers, echo, connection detach or exit, and
28 * as a private pair inside the emitter it could not be driven with a
29 * pathological payload without staging an entire response emission.
30 *
31 * @since 4.3.5
32 */
33 final class ABJ_404_Solution_PayloadShapeFingerprint {
34
35 /**
36 * Maximum recursion depth the walk will descend to.
37 * See the class docblock: the bound is what keeps this diagnostic from
38 * becoming the hang it was written to explain.
39 */
40 const MAX_DEPTH = 32;
41
42 /** Maximum number of array/object elements the walk will visit. */
43 const MAX_ELEMENTS = 5000;
44
45 /**
46 * Measure one value.
47 *
48 * @param mixed $payload
49 * @return array{depth: int, element_count: int, string_byte_total: int, truncated: bool}
50 */
51 public static function measure($payload): array {
52 $stats = array('depth' => 0, 'element_count' => 0, 'string_byte_total' => 0, 'truncated' => false);
53 self::walk($payload, 0, $stats);
54 return $stats;
55 }
56
57 /**
58 * @param mixed $value
59 * @param array{depth: int, element_count: int, string_byte_total: int, truncated: bool} $stats
60 */
61 private static function walk($value, int $currentDepth, array &$stats): void {
62 if ($stats['truncated']) {
63 return;
64 }
65 $stats['depth'] = max($stats['depth'], $currentDepth);
66 if ($currentDepth >= self::MAX_DEPTH) {
67 $stats['truncated'] = true;
68 return;
69 }
70 if (is_string($value)) {
71 $stats['string_byte_total'] += strlen($value);
72 return;
73 }
74 $children = null;
75 if (is_array($value)) {
76 $children = $value;
77 } else if (is_object($value)) {
78 $children = get_object_vars($value);
79 }
80 if ($children === null) {
81 return;
82 }
83 foreach ($children as $child) {
84 $stats['element_count']++;
85 if ($stats['element_count'] >= self::MAX_ELEMENTS) {
86 $stats['truncated'] = true;
87 return;
88 }
89 self::walk($child, $currentDepth + 1, $stats);
90 }
91 }
92 }
93