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 / ajax / JsonResponseHead.php

JsonResponseHead.php in 404 Solution trunk, at includes/ajax/JsonResponseHead.php

171 lines 7.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 * The head of a JSON AJAX response: its headers, its status code, and which of
9 * those two this request has actually emitted.
10 *
11 * Split out of ABJ_404_Solution_AjaxResponseEmitter (which owns the body encode
12 * + echo boundary and the connection-detach/exit tail) because the head is
13 * emitted on its own, from elsewhere, before any body exists:
14 * ABJ_404_Solution_AjaxCanaryStepRunner commits it before the canary ladder's
15 * `stream` step flushes, and ABJ_404_Solution_AjaxAdminEndpointSupport re-arms
16 * it at the start of every request. Those are calls on the head, not on the
17 * emitter, and they were reaching through it.
18 *
19 * The head is TWO emissions, not one. WordPress dispatches the foreign
20 * `status_header` filter before its own header() call, so the status half runs
21 * arbitrary third-party code and can fail on its own while the headers half has
22 * already succeeded. Both are separately checkpoint-bracketed for that reason
23 * (gap-hunt iteration 2, Codex gap #5): before that, a blocking header filter
24 * left `trace_finish_end` followed by nothing, indistinguishable from a worker
25 * kill.
26 */
27 final class ABJ_404_Solution_JsonResponseHead {
28
29 /** The two independently-completing halves of the response head. */
30 const STEP_HEADERS = 'headers';
31 const STEP_STATUS = 'status_header';
32
33 /**
34 * Which halves this request has actually emitted.
35 *
36 * headers_sent() alone cannot answer that: while any output buffer holds
37 * the body, the head is set but not yet on the wire, so a second emission
38 * would silently duplicate Content-type and X-ABJ404-Request-ID and journal
39 * a second headers_start/_end pair.
40 *
41 * A SET rather than one flag because the two halves fail independently. A
42 * single flag was raised before either had run, so a `status_header`
43 * callback that threw left the request marked as having emitted a head it
44 * had not: the handler's catch then sent its 500 envelope, this class
45 * skipped the status it believed was already sent, and the browser received
46 * an error body under HTTP 200 -- which jQuery reports as success.
47 *
48 * @var array<string, bool>
49 */
50 private static $completedSteps = array();
51
52 /**
53 * Re-arm the per-request bookkeeping. Called from the endpoint's own arming
54 * point so this stays request state rather than a test-only back door: one
55 * PHP request serves one AJAX response, but one PHPUnit worker serves many.
56 *
57 * @return void
58 */
59 public static function resetForRequest(): void {
60 self::$completedSteps = array();
61 }
62
63 /** Whether both halves of the response head have been emitted. */
64 public static function isComplete(): bool {
65 return !empty(self::$completedSteps[self::STEP_HEADERS])
66 && !empty(self::$completedSteps[self::STEP_STATUS]);
67 }
68
69 /**
70 * Emit the head NOW, before any body byte can commit it.
71 *
72 * A handler that echoes and flushes mid-response (the canary ladder's
73 * `stream` step) commits the response head at that flush. By the time the
74 * emitter runs, headers_sent() is true and its own emission is skipped --
75 * so without this call the streamed response ships with PHP's default
76 * text/html and, worse, without the X-ABJ404-Request-ID header the ledger
77 * relies on to identify a request whose body never arrives, which is
78 * exactly the case the canary exists to diagnose.
79 *
80 * Committing the head here also pins the status code, so an error raised
81 * after this point can no longer change it. That is not a regression: on
82 * the only branch that calls this, the flush was already committing the
83 * head a few statements later regardless. The difference is whether the
84 * committed head is the right one.
85 *
86 * @param int $httpStatus
87 * @return void
88 */
89 public static function emitEarly($httpStatus = 200): void {
90 if (self::isComplete() || headers_sent()) {
91 return;
92 }
93 self::emit(ABJ_404_Solution_AjaxRequestIdScopes::fromGlobalContext(), $httpStatus);
94 }
95
96 /**
97 * Emit whichever halves have not been emitted yet.
98 *
99 * Each half is marked complete only AFTER its emission returns, and skipped
100 * only if it already did, so a throw leaves the half that did not run still
101 * pending and the handler's error response can still set its own status.
102 *
103 * A response with no checkpoint scope is outside the Bruno table-AJAX
104 * endpoint; skip the instrumentation but keep behavior identical.
105 *
106 * @param int $httpStatus
107 * @return void
108 */
109 public static function emit(
110 ABJ_404_Solution_AjaxRequestIdScopes $scopes, $httpStatus): void {
111 $ledgerRequestId = $scopes->ledger();
112 $ctx = isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])
113 ? $GLOBALS['abj404_ajax_context'] : array();
114 $emitHeaders = static function () use ($ctx, $ledgerRequestId) {
115 if ($ctx !== array()) {
116 if (array_key_exists('action', $ctx) && is_string($ctx['action'])) {
117 header('X-ABJ404-Ajax: ' . preg_replace('/[\r\n]+/', '', $ctx['action']));
118 }
119 if (array_key_exists('subpage', $ctx) && is_string($ctx['subpage']) && $ctx['subpage'] !== '') {
120 header('X-ABJ404-Subpage: ' . preg_replace('/[\r\n]+/', '', $ctx['subpage']));
121 }
122 // Immutable request ledger (matrix coverage req. 1): echo the
123 // request ID back as a response header so it is recoverable
124 // from the client/proxy side even when the JSON body itself
125 // never arrives. Normalized to the ledger format, so no
126 // header-splitting scrub is needed and no raw client value
127 // is ever reflected.
128 if ($ledgerRequestId !== '') {
129 header('X-ABJ404-Request-ID: ' . $ledgerRequestId);
130 }
131 }
132 header('Content-type: application/json; charset=UTF-8');
133 };
134 if (empty(self::$completedSteps[self::STEP_HEADERS])) {
135 if (!$scopes->hasCheckpoints()) {
136 $emitHeaders();
137 } else {
138 ABJ_404_Solution_AjaxCheckpointLogger::around(
139 $scopes->checkpoint(), self::STEP_HEADERS, $emitHeaders);
140 }
141 self::$completedSteps[self::STEP_HEADERS] = true;
142 }
143
144 $emitStatus = static function () use ($httpStatus) {
145 if (function_exists('status_header')) {
146 // WordPress dispatches the foreign `status_header` filter and
147 // global `all` hook before its core header() call. Attribute
148 // those callbacks inside the existing outer status boundary:
149 // completed callbacks followed by a missing status_header_end
150 // then isolate the remaining stall to WordPress/core emission.
151 ABJ_404_Solution_ResponseControlFilterTracer::traceDispatch(
152 'status_header',
153 static function () use ($httpStatus) {
154 status_header($httpStatus);
155 }
156 );
157 } else if (function_exists('http_response_code')) {
158 http_response_code($httpStatus);
159 }
160 };
161 if (!$scopes->hasCheckpoints()) {
162 $emitStatus();
163 } else {
164 ABJ_404_Solution_AjaxCheckpointLogger::around(
165 $scopes->checkpoint(), self::STEP_STATUS, $emitStatus,
166 array('http_status' => $httpStatus));
167 }
168 self::$completedSteps[self::STEP_STATUS] = true;
169 }
170 }
171