| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Hard wall-clock deadline for instrumented AJAX work after a response detaches. |
| 9 |
* |
| 10 |
* LiteSpeed and FastCGI finish-request functions release the client connection, |
| 11 |
* but PHP continues running WordPress and raw PHP shutdown callbacks in the same |
| 12 |
* worker. On hosts with pcntl, SIGALRM is the only in-process mechanism that can |
| 13 |
* interrupt foreign code which neither cooperates with a deadline nor returns. |
| 14 |
*/ |
| 15 |
final class ABJ_404_Solution_PostResponseWorkerBudget { |
| 16 |
|
| 17 |
public const BUDGET_SECONDS = 2; |
| 18 |
|
| 19 |
/** @var bool */ |
| 20 |
private static $armed = false; |
| 21 |
|
| 22 |
/** @var string */ |
| 23 |
private static $requestId = ''; |
| 24 |
|
| 25 |
/** |
| 26 |
* Report whether this process exposes every primitive needed to arm the |
| 27 |
* OS-backed post-response deadline. |
| 28 |
*/ |
| 29 |
public static function isSupported(): bool { |
| 30 |
return defined('SIGALRM') |
| 31 |
&& ABJ_404_Solution_PcntlSignalAdapter::supportsSignalBudget(); |
| 32 |
} |
| 33 |
|
| 34 |
/** |
| 35 |
* Arm the deadline once a response has successfully detached. |
| 36 |
* |
| 37 |
* @return bool True when an OS-backed wall-clock deadline was installed. |
| 38 |
*/ |
| 39 |
public static function arm(string $requestId): bool { |
| 40 |
if (self::$armed || $requestId === '') { |
| 41 |
return self::$armed; |
| 42 |
} |
| 43 |
if (!self::isSupported()) { |
| 44 |
self::record($requestId, 'post_response_worker_budget_unavailable', array( |
| 45 |
'budget_seconds' => self::BUDGET_SECONDS, |
| 46 |
'reason' => 'pcntl_alarm_unavailable', |
| 47 |
)); |
| 48 |
return false; |
| 49 |
} |
| 50 |
|
| 51 |
try { |
| 52 |
ABJ_404_Solution_PcntlSignalAdapter::enableAsyncSignals(); |
| 53 |
if (!ABJ_404_Solution_PcntlSignalAdapter::installSignalHandler( |
| 54 |
SIGALRM, |
| 55 |
array(__CLASS__, 'expire') |
| 56 |
)) { |
| 57 |
self::record($requestId, 'post_response_worker_budget_unavailable', array( |
| 58 |
'budget_seconds' => self::BUDGET_SECONDS, |
| 59 |
'reason' => 'pcntl_signal_registration_failed', |
| 60 |
)); |
| 61 |
return false; |
| 62 |
} |
| 63 |
|
| 64 |
// Do not extend a deadline another component already installed. |
| 65 |
$priorAlarmSeconds = ABJ_404_Solution_PcntlSignalAdapter::alarm(0); |
| 66 |
$budgetSeconds = $priorAlarmSeconds > 0 |
| 67 |
? min(self::BUDGET_SECONDS, $priorAlarmSeconds) |
| 68 |
: self::BUDGET_SECONDS; |
| 69 |
self::$requestId = $requestId; |
| 70 |
self::$armed = true; |
| 71 |
// Registered at the detach boundary, after WordPress and all |
| 72 |
// request-time raw shutdown callbacks. A normal request reaches |
| 73 |
// this last and cancels the process-global alarm before LSAPI |
| 74 |
// reuses the worker; a stuck earlier callback never reaches it. |
| 75 |
register_shutdown_function(array(__CLASS__, 'complete')); |
| 76 |
ABJ_404_Solution_PcntlSignalAdapter::alarm($budgetSeconds); |
| 77 |
self::record($requestId, 'post_response_worker_budget_armed', array( |
| 78 |
'budget_seconds' => $budgetSeconds, |
| 79 |
'configured_budget_seconds' => self::BUDGET_SECONDS, |
| 80 |
'prior_alarm_seconds' => $priorAlarmSeconds, |
| 81 |
'mechanism' => 'pcntl_sigalrm', |
| 82 |
)); |
| 83 |
return true; |
| 84 |
} catch (\Throwable $e) { |
| 85 |
self::$armed = false; |
| 86 |
self::$requestId = ''; |
| 87 |
self::reportFailure('Could not arm post-response worker budget', $e); |
| 88 |
self::record($requestId, 'post_response_worker_budget_unavailable', array( |
| 89 |
'budget_seconds' => self::BUDGET_SECONDS, |
| 90 |
'reason' => 'pcntl_exception', |
| 91 |
'exception_class' => get_class($e), |
| 92 |
'exception_code' => $e->getCode(), |
| 93 |
'exception_message' => $e->getMessage(), |
| 94 |
)); |
| 95 |
return false; |
| 96 |
} |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* SIGALRM handler. Exiting here stops every remaining WordPress, plugin, |
| 101 |
* extension, destructor, and raw PHP shutdown callback in this worker. |
| 102 |
*/ |
| 103 |
public static function expire(int $signal): void { |
| 104 |
$message = '[404 Solution] Post-response worker budget exhausted; terminating detached request' |
| 105 |
. ' request_id=' . self::$requestId |
| 106 |
. ' budget_seconds=' . self::BUDGET_SECONDS |
| 107 |
. ' signal=' . $signal; |
| 108 |
// Same terminal-handler sink as every other last-resort path: this |
| 109 |
// runs inside a signal handler that is about to end the process, so |
| 110 |
// plugin logging may already be torn down. |
| 111 |
abj404_logPhpFallback('fatal-handler-fallback', $message); |
| 112 |
exit(0); |
| 113 |
} |
| 114 |
|
| 115 |
/** Cancel the process-global alarm after every earlier callback returned. */ |
| 116 |
public static function complete(): void { |
| 117 |
if (!self::$armed) { |
| 118 |
return; |
| 119 |
} |
| 120 |
$remainingSeconds = ABJ_404_Solution_PcntlSignalAdapter::alarm(0); |
| 121 |
$requestId = self::$requestId; |
| 122 |
self::$armed = false; |
| 123 |
self::$requestId = ''; |
| 124 |
self::record($requestId, 'post_response_worker_budget_completed', array( |
| 125 |
'remaining_seconds' => $remainingSeconds, |
| 126 |
'mechanism' => 'pcntl_sigalrm', |
| 127 |
)); |
| 128 |
} |
| 129 |
|
| 130 |
/** @param array<string,mixed> $fields */ |
| 131 |
private static function record(string $requestId, string $event, array $fields): void { |
| 132 |
if (class_exists('ABJ_404_Solution_AjaxCheckpointLogger', false)) { |
| 133 |
// The completion sentinel runs at the very end of PHP shutdown; |
| 134 |
// use the lightweight writer so it does not invoke full-envelope |
| 135 |
// samplers whose dependencies may already have torn down. |
| 136 |
ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent($requestId, $event, $fields); |
| 137 |
} |
| 138 |
} |
| 139 |
|
| 140 |
private static function reportFailure(string $context, \Throwable $error): void { |
| 141 |
abj404_logPhpFallback('fatal-handler-fallback', |
| 142 |
'[404 Solution] ' . $context . ': ' . get_class($error) |
| 143 |
. ' code=' . $error->getCode() . ' message=' . $error->getMessage()); |
| 144 |
} |
| 145 |
} |
| 146 |
|