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

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

278 lines 11.2 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 require_once __DIR__ . '/../diagnostics/CrashBeaconStore.php';
8
9 /**
10 * Orchestrates shutdown-time fatal-error handling.
11 */
12 class ABJ_404_Solution_FatalErrorProcessor {
13
14 /** @var ABJ_404_Solution_ErrorTypeClassifier */
15 private $classifier;
16
17 /** @var ABJ_404_Solution_ErrorDiagnosticsReporter */
18 private $diagnostics;
19
20 /** @var ABJ_404_Solution_AdminFatalErrorResponder */
21 private $adminResponder;
22
23 /** @var ABJ_404_Solution_AjaxFatalErrorResponder */
24 private $ajaxResponder;
25
26 /**
27 * @param ABJ_404_Solution_ErrorTypeClassifier|null $classifier
28 * @param ABJ_404_Solution_ErrorDiagnosticsReporter|null $diagnostics
29 * @param ABJ_404_Solution_AdminFatalErrorResponder|null $adminResponder
30 * @param ABJ_404_Solution_AjaxFatalErrorResponder|null $ajaxResponder
31 */
32 public function __construct($classifier = null, $diagnostics = null, $adminResponder = null, $ajaxResponder = null) {
33 $this->classifier = $classifier !== null ? $classifier : new ABJ_404_Solution_ErrorTypeClassifier();
34 $this->diagnostics = $diagnostics !== null ? $diagnostics : new ABJ_404_Solution_ErrorDiagnosticsReporter();
35 $this->adminResponder = $adminResponder !== null ? $adminResponder : new ABJ_404_Solution_AdminFatalErrorResponder();
36 $this->ajaxResponder = $ajaxResponder !== null ? $ajaxResponder : new ABJ_404_Solution_AjaxFatalErrorResponder($this->diagnostics);
37 }
38
39 /**
40 * @param array<string,mixed>|null $lasterror
41 * @return bool
42 */
43 public function process($lasterror): bool {
44 if (!$this->isProcessableFatal($lasterror) || !is_array($lasterror)) {
45 return false;
46 }
47
48 $lasterror = $this->truncateLargeMessage($lasterror);
49 $ctx = $this->currentAjaxContext();
50
51 // Crash beacon: for a plugin-scope fatal (including OOM), release the
52 // memory reserve on ALL requests (previously admin-only, which is why a
53 // front-end OOM had no headroom) and write a tiny breadcrumb file FIRST,
54 // before any heavier handling below, so the post-mortem survives even if
55 // a later step in this handler fatals again. Self-contained and best
56 // effort; never throws.
57 if ($this->isPluginScopeFatal($lasterror)) {
58 ABJ_404_Solution_ErrorHandler::releaseReservedMemory();
59 $this->captureCrashBeacon($lasterror);
60 }
61
62 $isPluginAdminPage = $this->adminResponder->isPluginAdminPageRequest();
63 if ($isPluginAdminPage) {
64 ABJ_404_Solution_ErrorHandler::releaseReservedMemory();
65 $this->adminResponder->stashAdminFatal($lasterror);
66 }
67
68 if ($this->ajaxResponder->isAjaxContext($ctx)) {
69 return $this->ajaxResponder->process($lasterror, is_array($ctx) ? $ctx : array());
70 }
71
72 $this->logDefaultFatal($lasterror, $isPluginAdminPage);
73
74 if ($isPluginAdminPage) {
75 $this->adminResponder->renderAdminFatalFallback($lasterror);
76 }
77
78 return false;
79 }
80
81 /**
82 * @param mixed $lasterror
83 * @return bool
84 */
85 private function isProcessableFatal($lasterror): bool {
86 if ($lasterror == null || !is_array($lasterror) || !array_key_exists('type', $lasterror) ||
87 !array_key_exists('file', $lasterror)) {
88 return false;
89 }
90
91 $errorType = $lasterror['type'];
92 return $this->classifier->isFatalType(is_int($errorType) ? $errorType : (is_scalar($errorType) ? (int)$errorType : 0));
93 }
94
95 /**
96 * @return array<string,mixed>|null
97 */
98 private function currentAjaxContext() {
99 if (!isset($GLOBALS['abj404_ajax_context']) || !is_array($GLOBALS['abj404_ajax_context'])) {
100 return null;
101 }
102
103 $context = array();
104 foreach ($GLOBALS['abj404_ajax_context'] as $key => $value) {
105 if (is_string($key)) {
106 $context[$key] = $value;
107 }
108 }
109 return $context;
110 }
111
112 /**
113 * @param array<string,mixed> $lasterror
114 * @return void
115 */
116 private function logDefaultFatal(array $lasterror, bool $isPluginAdminPage): void {
117 try {
118 $errno = $lasterror['type'];
119 $isPluginScopeFatal = $this->isPluginScopeFatal($lasterror);
120
121 if (!$isPluginScopeFatal && !$isPluginAdminPage) {
122 return;
123 }
124
125 $extraInfo = "(none)";
126 $ctxDebugInfo = abj_service('request_context')->debug_info;
127 if ($ctxDebugInfo !== '') {
128 $extraInfo = stripcslashes(wp_kses_post((string)json_encode($ctxDebugInfo)));
129 }
130 $contextPrefix = $isPluginScopeFatal
131 ? 'ABJ404-SOLUTION Fatal error handler: '
132 : 'ABJ404-SOLUTION Fatal error handler (plugin admin page, foreign scope): ';
133
134 $errmsg = $contextPrefix .
135 stripcslashes(wp_kses_post((string)json_encode($lasterror))) .
136 ", \nAdditional info: " . $extraInfo . ", mbstring: " .
137 (extension_loaded('mbstring') ? 'true' : 'false');
138
139 $abj404logging = abj_service('logging');
140 if ($abj404logging != null) {
141 switch ($errno) {
142 case E_NOTICE:
143 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)');
144 $whitelist = isset($GLOBALS['abj404_whitelist']) && is_array($GLOBALS['abj404_whitelist'])
145 ? $GLOBALS['abj404_whitelist'] : array();
146 if (in_array($serverName, $whitelist, true)) {
147 $abj404logging->debugMessage($errmsg);
148 }
149 break;
150
151 default:
152 $abj404logging->errorMessage($errmsg);
153 break;
154 }
155 } else {
156 echo $errmsg;
157 }
158 } catch (Throwable $ex) {
159 abj404_logPhpFallback(
160 'fatal-handler-fallback',
161 'error handler itself failed (code ' . $ex->getCode() . '): ' . $ex->getMessage()
162 );
163 }
164 }
165
166 /**
167 * @param array<string,mixed> $lasterror
168 * @return array<string,mixed>
169 */
170 private function truncateLargeMessage(array $lasterror): array {
171 if (isset($lasterror['message']) && is_string($lasterror['message'])
172 && strlen($lasterror['message']) > 8192) {
173 $lasterror['message'] = substr($lasterror['message'], 0, 8192)
174 . '... (truncated; original length ' . strlen($lasterror['message']) . ' bytes)';
175 }
176 return $lasterror;
177 }
178
179 /** @return string */
180 private function pluginFolder(): string {
181 $slashPos = strpos(ABJ404_NAME, '/');
182 $pluginFolder = substr(ABJ404_NAME, 0, ($slashPos !== false ? $slashPos : strlen(ABJ404_NAME)));
183 return is_string($pluginFolder) ? $pluginFolder : (string)ABJ404_NAME;
184 }
185
186 /**
187 * Whether the fatal occurred in one of this plugin's files. Pure string
188 * containment on the existing plugin-folder marker, so it is safe to call
189 * during an OOM shutdown.
190 *
191 * @param array<string,mixed> $lasterror
192 * @return bool
193 */
194 private function isPluginScopeFatal(array $lasterror): bool {
195 $errfile = isset($lasterror['file']) && is_string($lasterror['file']) ? $lasterror['file'] : '';
196 if ($errfile === '') {
197 return false;
198 }
199 return strpos($errfile, $this->pluginFolder()) !== false;
200 }
201
202 /**
203 * Write a crash beacon for a plugin-scope fatal using ONLY the path
204 * precomputed at healthy boot (ABJ_404_Solution_ErrorHandler::precomputeCrashBeaconPath)
205 * plus primitives. No wp_upload_dir()/options/container/clock calls here:
206 * this runs in the fatal handler where memory may be exhausted, so it must
207 * not re-enter the failure class it is reporting. Best effort; never throws.
208 *
209 * @param array<string,mixed> $lasterror
210 * @return void
211 */
212 private function captureCrashBeacon(array $lasterror): void {
213 try {
214 // Guarantee headroom for the post-OOM beacon write. Raising
215 // memory_limit inside the shutdown handler is honored by PHP even
216 // after a memory-exhaustion fatal, so the tiny json_encode/fwrite
217 // below cannot itself fail for want of memory (the exact case the
218 // beacon exists to capture). ONLY ever raise (current limit + a
219 // small fixed margin); never lower, and leave an unlimited (-1) or
220 // unparseable limit untouched -- so this can never shrink a healthy
221 // request's budget. Bounded (not unlimited) so a constrained or
222 // shared host is never pushed into an OS-level OOM kill. Complements
223 // the released memory reserve.
224 $currentLimitBytes = $this->currentMemoryLimitBytes();
225 if ($currentLimitBytes > 0) {
226 ABJ_404_Solution_PhpRuntimeCapabilityAdapter::setIni(array(
227 'directive' => 'memory_limit',
228 'value' => (string) ($currentLimitBytes + (8 * 1024 * 1024)),
229 ));
230 }
231
232 $path = isset($GLOBALS['abj404_crash_beacon_path']) && is_string($GLOBALS['abj404_crash_beacon_path'])
233 ? $GLOBALS['abj404_crash_beacon_path'] : '';
234 if ($path === '') {
235 return;
236 }
237 $version = defined('ABJ404_VERSION') ? (string)ABJ404_VERSION : '';
238 $pluginRoot = defined('ABJ404_PATH') ? (string)ABJ404_PATH : '';
239 // Clock service for the informational capture timestamp. Safe during
240 // shutdown: the clock has no settings/logging/uploads dependencies
241 // (the re-entry classes this capture avoids) and is near-certainly
242 // already resolved this request; the surrounding try/catch makes the
243 // whole capture best-effort if it is somehow unavailable.
244 $now = abj_clock()->now();
245 $beacon = ABJ_404_Solution_CrashBeacon::fromLastError($lasterror, $version, $pluginRoot, $now);
246 $store = new ABJ_404_Solution_CrashBeaconStore($path);
247 $store->recordIfAbsent($beacon);
248 } catch (\Throwable $e) {
249 abj404_logPhpFallback('crash-beacon-capture', 'capture failed: ' . $e->getMessage());
250 }
251 }
252
253 /**
254 * Current PHP memory_limit in bytes: -1 for unlimited, 0 when unset or
255 * unparseable, otherwise the positive byte count. Minimal and
256 * dependency-free (no WordPress helpers) so it is safe to call from inside
257 * the fatal handler after a memory-exhaustion fatal.
258 *
259 * @return int
260 */
261 private function currentMemoryLimitBytes(): int {
262 $raw = trim((string) @ini_get('memory_limit'));
263 if ($raw === '') {
264 return 0;
265 }
266 if ($raw === '-1') {
267 return -1;
268 }
269 $value = (int) $raw;
270 switch (strtoupper(substr($raw, -1))) {
271 case 'G': $value *= 1024 * 1024 * 1024; break;
272 case 'M': $value *= 1024 * 1024; break;
273 case 'K': $value *= 1024; break;
274 }
275 return $value > 0 ? $value : 0;
276 }
277 }
278