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