PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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 / ErrorHandler.php

ErrorHandler.php in 404 Solution 4.2.0, at includes/ErrorHandler.php

591 lines 23.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /* Functions in this class should only be for plugging into WordPress listeners (filters, actions, etc). */
9
10 class ABJ_404_Solution_ErrorHandler {
11
12 /**
13 * Prevent duplicate shutdown fallback output when multiple handlers run.
14 *
15 * @var bool
16 */
17 private static $adminFatalPageRendered = false;
18
19 /** Keep a reference to the original error handler so we can use it later.
20 * @var callable|null
21 */
22 static $originalErrorHandler = null;
23
24 /**
25 * Reserved memory released during fatal shutdown handling so OOM errors can still render fallback output.
26 *
27 * @var string|null
28 */
29 private static $reservedMemory = null;
30
31 /** Setup.
32 * @return void
33 */
34 static function init(): void {
35 // store the original error handler.
36 self::$originalErrorHandler = set_error_handler(function(int $errno, string $errstr, string $errfile = '', int $errline = 0): bool { return false; });
37 restore_error_handler();
38
39 // set to the user defined error handler
40 set_error_handler("ABJ_404_Solution_ErrorHandler::NormalErrorHandler");
41 if (self::$reservedMemory === null) {
42 // Keep a small memory reserve so shutdown handling can render a fallback page on memory exhaustion.
43 self::$reservedMemory = str_repeat('R', 262144);
44 }
45 register_shutdown_function('ABJ_404_Solution_ErrorHandler::FatalErrorHandler');
46 }
47
48 /** Try to capture PHP errors.
49 * @param int $errno
50 * @param string $errstr
51 * @param string $errfile
52 * @param int $errline
53 * @return boolean
54 */
55 static function NormalErrorHandler($errno, $errstr, $errfile, $errline) {
56 // Respect PHP's `@` error-suppression operator. Up to PHP 7.x the @
57 // operator zeroed error_reporting() for the duration of the call;
58 // PHP 8.0+ instead leaves error_reporting() non-zero but masks out
59 // the specific level being silenced. A custom handler that ignores
60 // this state still escalates intentionally-suppressed warnings to
61 // error-level logging, defeating the suppression.
62 // Concrete case: @opcache_invalidate() at PluginLogic.php:1036 on
63 // hosts with opcache.restrict_api set produces an E_WARNING. The
64 // legacy `error_reporting() === 0` check matched on PHP 7.x but
65 // not on PHP 8.0+ (where the @ leaves the mask non-zero), so 24
66 // such ERROR entries reached the email reporter in the May 10
67 // debug zip, including 2 from current 4.1.17 sites on PHP 8.3.
68 // The bitwise form covers both eras: on PHP 7.x error_reporting()
69 // is 0 and `& $errno` is 0; on PHP 8.0+ the specific level bit is
70 // cleared and `& $errno` is also 0. Returning false here lets
71 // PHP's default handler honour the suppression.
72 if ((error_reporting() & $errno) === 0) {
73 return false;
74 }
75
76 $abj404logging = abj_service('logging');
77 $f = abj_service('functions');
78 $onlyAWarning = false;
79
80 try {
81 // if the error file does not contain the name of our plugin then we ignore it.
82 $slashPos = $f->strpos(ABJ404_NAME, '/');
83 $pluginFolder = $f->substr(ABJ404_NAME, 0, ($slashPos !== false ? $slashPos : null));
84 if ($f->strpos($errfile, $pluginFolder) === false) {
85 // let the normal error handler handle it.
86
87 // this would display the error for other plugins but show @author user
88 // stacktrace from this plugin.
89 // // try calling the original error handler.
90 // if (is_callable(self::$originalErrorHandler)) {
91 // return call_user_func_array(self::$originalErrorHandler,
92 // array($errno, $errstr, $errfile, $errline));
93 // }
94 return false;
95
96 } else {
97 // for our own plugin errors make sure we see them.
98 if ($GLOBALS['abj404_display_errors']) {
99 error_reporting(E_ALL);
100 ini_set('display_errors', '1');
101 }
102 }
103
104 if ($errno == 2 &&
105 $f->strpos($errstr,
106 "Cannot modify header information - headers already sent by") !== false) {
107
108 $onlyAWarning = true;
109 }
110
111 $extraInfo = "(none)";
112 $ctxDebugInfo = abj_service('request_context')->debug_info;
113 if ($ctxDebugInfo !== '') {
114 $extraInfo = stripcslashes(wp_kses_post((string)json_encode($ctxDebugInfo)));
115 }
116 $errmsg = "ABJ404-SOLUTION Normal error handler error: errno: " .
117 wp_kses_post((string)json_encode($errno)) . ", errstr: " . wp_kses_post((string)json_encode($errstr)) .
118 ", \nerrfile: " . stripcslashes(wp_kses_post((string)json_encode($errfile))) .
119 ", \nerrline: " . wp_kses_post((string)json_encode($errline)) .
120 ', \nAdditional info: ' . $extraInfo . ", mbstring: " .
121 (extension_loaded('mbstring') ? 'true' : 'false');
122
123 if ($abj404logging != null) {
124 if ($errno === E_NOTICE) {
125 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)');
126 if (in_array($serverName, $GLOBALS['abj404_whitelist'])) {
127 $e = new Exception;
128 $abj404logging->debugMessage($errmsg . ', Trace:' . $e->getTraceAsString());
129 }
130 } elseif ($onlyAWarning) {
131 $abj404logging->debugMessage($errmsg);
132 } else {
133 $abj404logging->errorMessage($errmsg);
134 }
135 } else {
136 echo $errmsg;
137 }
138 } catch (Throwable $ex) {
139 // Last-resort breadcrumb: the inner logging path itself failed,
140 // so we can't go through $abj404logging. Match the pattern used
141 // by WordPress_Connector::reportAdminRuntimeError() and
142 // Ajax_SuggestionCompute::handleShutdown(). Widening from
143 // Exception to Throwable is intentional — Error types are
144 // exactly the case the outer handler exists for.
145 @error_log('404 Solution: error handler itself failed: ' . $ex->getMessage());
146 }
147
148 // show all warnings and errors.
149 if ($GLOBALS['abj404_display_errors']) {
150 error_reporting(E_ALL);
151 ini_set('display_errors', '1');
152 }
153 // let the original error handler handle it.
154 return false;
155 }
156
157 /** @return bool */
158 static function FatalErrorHandler(): bool {
159 $lasterror = error_get_last();
160 return self::processFatalError($lasterror);
161 }
162
163 /**
164 * @param mixed $value
165 * @return string
166 */
167 private static function safeJsonEncode($value): string {
168 $encoded = json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR);
169 if ($encoded === false) {
170 return '(json_encode failed) ' . print_r($value, true);
171 }
172 return $encoded;
173 }
174
175 /**
176 * @param mixed $sql
177 * @return string
178 */
179 private static function redactSqlShape($sql): string {
180 if (!is_string($sql) || $sql === '') {
181 return '';
182 }
183
184 $out = $sql;
185 $out = preg_replace("~'(?:\\\\'|''|[^'])*'~", "?", $out) ?? $out;
186 $out = preg_replace('~"(?:\\\\"|""|[^"])*"~', "?", $out) ?? $out;
187 $out = preg_replace('~\\b0x[0-9A-Fa-f]+\\b~', '?', $out) ?? $out;
188 $out = preg_replace('~\\b\\d+(?:\\.\\d+)?\\b~', '?', $out) ?? $out;
189 $out = preg_replace('~\\(\\s*\\?\\s*(?:,\\s*\\?\\s*)+\\)~', '(?)', $out) ?? $out;
190 $out = preg_replace('~\\bIN\\s*\\(\\?\\)\\b~i', 'IN (?)', $out) ?? $out;
191 $out = preg_replace('~\\s+~', ' ', trim($out)) ?? $out;
192 if (strlen($out) > 4000) {
193 $out = substr($out, 0, 4000) . '...';
194 }
195 return $out;
196 }
197
198 /**
199 * @param string $line
200 * @return bool
201 */
202 private static function safeWriteLine(string $line): bool {
203 $logger = abj_service('logging');
204 if (is_object($logger) && method_exists($logger, 'writeLineToDebugFile')) {
205 $logger->writeLineToDebugFile($line);
206 return true;
207 }
208 if (is_object($logger) && method_exists($logger, 'sanitizeLogLine')) {
209 $line = $logger->sanitizeLogLine($line);
210 }
211 @file_put_contents(ABJ404_PATH . 'abj404_debug_fallback.txt', $line . "\n", FILE_APPEND);
212 return false;
213 }
214
215 /**
216 * @param int $type
217 * @return bool
218 */
219 private static function isFatalType(int $type): bool {
220 $fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR);
221 return in_array($type, $fatalTypes, true);
222 }
223
224 /**
225 * Best-effort scalar read from request arrays without depending on WP helpers.
226 *
227 * @param string $key
228 * @return string
229 */
230 private static function getRequestValue(string $key): string {
231 $raw = null;
232 if (array_key_exists($key, $_GET)) {
233 $raw = $_GET[$key];
234 } elseif (array_key_exists($key, $_POST)) {
235 $raw = $_POST[$key];
236 } elseif (array_key_exists($key, $_REQUEST)) {
237 $raw = $_REQUEST[$key];
238 }
239
240 if (!is_scalar($raw)) {
241 return '';
242 }
243
244 return trim((string)$raw);
245 }
246
247 /**
248 * Detect whether the current request is the plugin admin page.
249 *
250 * @return bool
251 */
252 private static function isPluginAdminPageRequest(): bool {
253 if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
254 return false;
255 }
256
257 $page = self::getRequestValue('page');
258 if ($page === '') {
259 return false;
260 }
261
262 $pluginPage = defined('ABJ404_PP') ? (string)ABJ404_PP : 'abj404_solution';
263 return $page === $pluginPage;
264 }
265
266 /**
267 * Persist the last admin fatal so we can show a notice on the next request.
268 *
269 * @param array<string,mixed> $lasterror
270 * @return void
271 */
272 private static function stashAdminFatal(array $lasterror): void {
273 $payload = array(
274 'message' => array_key_exists('message', $lasterror) ? (is_string($lasterror['message']) ? $lasterror['message'] : '') : '',
275 'file' => array_key_exists('file', $lasterror) ? (is_string($lasterror['file']) ? $lasterror['file'] : '') : '',
276 'line' => array_key_exists('line', $lasterror) ? (is_int($lasterror['line']) ? $lasterror['line'] : 0) : 0,
277 'type' => array_key_exists('type', $lasterror) ? (is_int($lasterror['type']) ? $lasterror['type'] : 0) : 0,
278 'time' => time(),
279 'page' => self::getRequestValue('page'),
280 'subpage' => self::getRequestValue('subpage'),
281 );
282
283 $ttl = defined('HOUR_IN_SECONDS') ? HOUR_IN_SECONDS : 3600;
284 if (function_exists('set_transient')) {
285 set_transient('abj404_admin_fatal', $payload, $ttl);
286 return;
287 }
288
289 if (function_exists('update_option')) {
290 update_option('abj404_admin_fatal_fallback', $payload);
291 }
292 }
293
294 /**
295 * Render a small HTML fallback so fatal admin errors do not become a blank page.
296 *
297 * @param array<string,mixed> $lasterror
298 * @return void
299 */
300 private static function renderAdminFatalFallback(array $lasterror): void {
301 if (self::$adminFatalPageRendered) {
302 return;
303 }
304 self::$adminFatalPageRendered = true;
305
306 $canShowDetails = false;
307 try {
308 if (function_exists('current_user_can') && current_user_can('manage_options')) {
309 $canShowDetails = true;
310 } elseif (function_exists('is_super_admin') && is_super_admin()) {
311 $canShowDetails = true;
312 }
313 } catch (Throwable $e) { // allow-silent-catch: admin-status detection in fatal handler; WP capability API may itself be broken, default to hiding details
314 $canShowDetails = false;
315 }
316
317 $shouldManageOb = function_exists('apply_filters')
318 ? apply_filters('abj404_should_manage_output_buffer', true, array('source' => 'renderAdminFatalFallback'))
319 : true;
320 if ($shouldManageOb) {
321 while (ob_get_level() > 0) {
322 @ob_end_clean();
323 }
324 }
325
326 if (!headers_sent()) {
327 if (function_exists('status_header')) {
328 status_header(500);
329 } elseif (function_exists('http_response_code')) {
330 http_response_code(500);
331 }
332 header('Content-Type: text/html; charset=UTF-8');
333 }
334
335 $settingsUrl = '?page=' . (defined('ABJ404_PP') ? ABJ404_PP : 'abj404_solution') . '&subpage=abj404_options';
336 if (function_exists('admin_url')) {
337 $settingsUrl = admin_url('options-general.php' . $settingsUrl);
338 }
339
340 $message = array_key_exists('message', $lasterror) ? (is_string($lasterror['message']) ? $lasterror['message'] : 'Fatal error') : 'Fatal error';
341 $file = array_key_exists('file', $lasterror) ? (is_string($lasterror['file']) ? $lasterror['file'] : '(unknown file)') : '(unknown file)';
342 $line = array_key_exists('line', $lasterror) ? (is_int($lasterror['line']) ? $lasterror['line'] : 0) : 0;
343
344 echo '<!doctype html><html><head><meta charset="utf-8"><title>404 Solution Error</title></head><body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Arial,sans-serif;padding:24px;">';
345 echo '<h1 style="margin:0 0 12px 0;">404 Solution</h1>';
346 echo '<p><strong>A fatal error occurred while rendering this admin page.</strong></p>';
347 echo '<p>Open the Options tab to continue: <a href="' . htmlspecialchars($settingsUrl, ENT_QUOTES, 'UTF-8') . '">Options</a></p>';
348
349 if ($canShowDetails) {
350 echo '<details open><summary>Error details</summary>';
351 echo '<pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;">' .
352 htmlspecialchars($message . "\n" . $file . ':' . (string)$line, ENT_QUOTES, 'UTF-8') .
353 '</pre>';
354 echo '</details>';
355 }
356
357 echo '</body></html>';
358 }
359
360 /**
361 * @param array<string, mixed> $payload
362 * @param int $httpStatus
363 * @return bool
364 */
365 private static function emitJsonAndExit(array $payload, int $httpStatus): bool {
366 if (!headers_sent()) {
367 // Marker headers help support quickly identify that this response came from our AJAX endpoint.
368 // These are safe to expose (no sensitive values).
369 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
370 $ctx = $GLOBALS['abj404_ajax_context'];
371 if (array_key_exists('action', $ctx) && is_string($ctx['action'])) {
372 header('X-ABJ404-Ajax: ' . preg_replace('/[\r\n]+/', '', $ctx['action']));
373 }
374 if (array_key_exists('subpage', $ctx) && is_string($ctx['subpage']) && $ctx['subpage'] !== '') {
375 header('X-ABJ404-Subpage: ' . preg_replace('/[\r\n]+/', '', $ctx['subpage']));
376 }
377 }
378 header('Content-type: application/json; charset=UTF-8');
379 if (function_exists('status_header')) {
380 status_header($httpStatus);
381 } else if (function_exists('http_response_code')) {
382 http_response_code($httpStatus);
383 }
384 }
385 echo json_encode($payload);
386 $shouldExit = function_exists('apply_filters')
387 ? apply_filters('abj404_should_exit', true, array('source' => 'errorHandler_emitJson'))
388 : true;
389 if (!$shouldExit) {
390 return true;
391 }
392 exit;
393 }
394
395 /**
396 * Process a fatal error (shutdown handler).
397 * Public for unit tests (allows injecting a fake last error).
398 */
399 /**
400 * @param array<string, mixed>|null $lasterror
401 * @return bool
402 */
403 public static function processFatalError($lasterror): bool {
404 $f = abj_service('functions');
405
406 if ($lasterror == null || !is_array($lasterror) || !array_key_exists('type', $lasterror) ||
407 !array_key_exists('file', $lasterror)) {
408 return false;
409 }
410 $errorType = $lasterror['type'];
411 if (!self::isFatalType(is_int($errorType) ? $errorType : (is_scalar($errorType) ? (int)$errorType : 0))) {
412 return false;
413 }
414
415 // Defensive: error_get_last() during an OOM fatal can return a 'message'
416 // field that contains the full crash context (gigabytes on a runaway
417 // memory exhaustion). json_encoding that downstream then OOMs the
418 // shutdown handler itself. Cap the message length so the handler
419 // never fails recursively due to its own logging path.
420 if (isset($lasterror['message']) && is_string($lasterror['message'])
421 && strlen($lasterror['message']) > 8192) {
422 $lasterror['message'] = substr($lasterror['message'], 0, 8192)
423 . '... (truncated; original length ' . strlen($lasterror['message']) . ' bytes)';
424 }
425
426 $ctx = isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])
427 ? $GLOBALS['abj404_ajax_context'] : null;
428
429 $isPluginAdminPage = self::isPluginAdminPageRequest();
430 if ($isPluginAdminPage) {
431 // Free reserved memory first so fallback rendering can succeed after OOM fatals.
432 self::$reservedMemory = null;
433 self::stashAdminFatal($lasterror);
434 }
435
436 $isAjaxContext = is_array($ctx) &&
437 !empty($ctx['ajax_expected_json']) &&
438 empty($ctx['response_sent']) &&
439 array_key_exists('action', $ctx) &&
440 $ctx['action'] === 'ajaxUpdatePaginationLinks';
441
442 // -------------------------
443 // AJAX context: always log (even if fatal is from another plugin/theme/core),
444 // and emit JSON for admins so WordPress's generic "critical error" page doesn't hide details.
445 if ($isAjaxContext) {
446 // Only handle fatals for this endpoint when the context was created by our handler.
447 // This avoids logging unrelated admin-ajax fatals while still capturing "foreign" plugin/theme fatals
448 // that break our AJAX response.
449 $contextSourceOk = array_key_exists('abj404_context_source', $ctx) &&
450 $ctx['abj404_context_source'] === 'ViewUpdater::getPaginationLinks';
451 if (!$contextSourceOk) {
452 return false;
453 }
454
455 $bufferedOutput = '';
456 $shouldManageOb = function_exists('apply_filters')
457 ? apply_filters('abj404_should_manage_output_buffer', true, array('source' => 'errorHandler_processFatalError'))
458 : true;
459 if ($shouldManageOb) {
460 if (ob_get_level() > 0) {
461 $bufferedOutput = (string)ob_get_contents();
462 }
463 $minLevel = array_key_exists('ob_level_before', $ctx) ? intval($ctx['ob_level_before']) : 0;
464 while (ob_get_level() > $minLevel) {
465 @ob_end_clean();
466 }
467 }
468
469 $details = array(
470 'fatal_error' => $lasterror,
471 'context' => $ctx,
472 );
473 if (isset($GLOBALS['wpdb']) && is_object($GLOBALS['wpdb'])) {
474 $lastQuery = $GLOBALS['wpdb']->last_query ?? '';
475 $details['wpdb'] = array(
476 'last_error' => $GLOBALS['wpdb']->last_error ?? '',
477 'last_query_redacted' => self::redactSqlShape($lastQuery),
478 'last_query_length' => is_string($lastQuery) ? strlen($lastQuery) : 0,
479 );
480 }
481 if ($bufferedOutput !== '') {
482 $details['buffered_output'] = substr($bufferedOutput, 0, 8000);
483 }
484
485 $line = date('c') . ' (ERROR): AJAX fatal error in ajaxUpdatePaginationLinks. Details: ' . self::safeJsonEncode($details);
486 self::safeWriteLine($line);
487
488 $isPluginAdmin = array_key_exists('is_plugin_admin', $ctx) ? (bool)$ctx['is_plugin_admin'] : null;
489 // Only try to compute admin status if it wasn't already determined earlier in the request.
490 if ($isPluginAdmin === null) {
491 try {
492 $logic = abj_service('plugin_logic');
493 if (is_object($logic) && method_exists($logic, 'userIsPluginAdmin')) {
494 $isPluginAdmin = $logic->userIsPluginAdmin();
495 }
496 } catch (Throwable $e) { // allow-silent-catch: admin-status detection; PluginLogic may be the broken component, fall through to WP capability check
497 $isPluginAdmin = null;
498 }
499 }
500 if ($isPluginAdmin === null) {
501 // Best-effort fallback: show details to real WordPress admins if PluginLogic is broken.
502 if (function_exists('wp_get_current_user')) {
503 $user = ABJ_404_Solution_UserRef::fromWpUser(wp_get_current_user());
504 if ($user !== null) {
505 $isPluginAdmin = $user->isAdministrator();
506 }
507 }
508 if ($isPluginAdmin !== true && function_exists('is_super_admin') && is_super_admin()) {
509 $isPluginAdmin = true;
510 }
511 }
512 if ($isPluginAdmin === null) {
513 $isPluginAdmin = false;
514 }
515
516 $payload = array(
517 'success' => false,
518 'data' => array(
519 'message' => 'Server error while updating the table.',
520 ),
521 );
522 if ($isPluginAdmin) {
523 $payload['data']['details'] = $details;
524 }
525
526 $GLOBALS['abj404_ajax_context']['response_sent'] = true;
527 return self::emitJsonAndExit($payload, 500);
528 }
529
530 // -------------------------
531 // Default behavior: only log plugin-scope fatals (avoid noise from other plugins/themes),
532 // except plugin admin page requests where we deliberately capture foreign fatals too.
533 try {
534 $errno = $lasterror['type'];
535 $errfile = is_string($lasterror['file']) ? $lasterror['file'] : '';
536 $slashPos2 = $f->strpos(ABJ404_NAME, '/');
537 $pluginFolder = $f->substr(ABJ404_NAME, 0, ($slashPos2 !== false ? $slashPos2 : null));
538
539 $isPluginScopeFatal = ($f->strpos($errfile, $pluginFolder) !== false);
540
541 // If the error file does not contain our plugin name, ignore it unless
542 // we are rendering the plugin admin page where blank-page prevention is critical.
543 if (!$isPluginScopeFatal && !$isPluginAdminPage) {
544 return false;
545 }
546
547 $extraInfo = "(none)";
548 $ctxDebugInfo = abj_service('request_context')->debug_info;
549 if ($ctxDebugInfo !== '') {
550 $extraInfo = stripcslashes(wp_kses_post((string)json_encode($ctxDebugInfo)));
551 }
552 $contextPrefix = $isPluginScopeFatal
553 ? 'ABJ404-SOLUTION Fatal error handler: '
554 : 'ABJ404-SOLUTION Fatal error handler (plugin admin page, foreign scope): ';
555
556 $errmsg = $contextPrefix .
557 stripcslashes(wp_kses_post((string)json_encode($lasterror))) .
558 ", \nAdditional info: " . $extraInfo . ", mbstring: " .
559 (extension_loaded('mbstring') ? 'true' : 'false');
560
561 $abj404logging = abj_service('logging');
562 if ($abj404logging != null) {
563 switch ($errno) {
564 case E_NOTICE:
565 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)');
566 if (in_array($serverName, $GLOBALS['abj404_whitelist'])) {
567 $abj404logging->debugMessage($errmsg);
568 }
569 break;
570
571 default:
572 $abj404logging->errorMessage($errmsg);
573 break;
574 }
575 } else {
576 echo $errmsg;
577 }
578 } catch (Throwable $ex) {
579 // Last-resort breadcrumb: inner logging itself failed during the
580 // fatal-error path. See NormalErrorHandler() above for rationale.
581 @error_log('404 Solution: error handler itself failed: ' . $ex->getMessage());
582 }
583
584 if ($isPluginAdminPage) {
585 self::renderAdminFatalFallback($lasterror);
586 }
587
588 return false;
589 }
590 }
591