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 / services / PostResponseWorkerBudget.php

PostResponseWorkerBudget.php in 404 Solution trunk, at includes/services/PostResponseWorkerBudget.php

146 lines 5.9 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 * 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