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 / core / ErrorHandler.php

ErrorHandler.php in 404 Solution trunk, at includes/core/ErrorHandler.php

226 lines 10.1 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 require_once __DIR__ . '/../policies/ErrorTypeClassifier.php';
9 require_once __DIR__ . '/../services/ErrorDiagnosticsReporter.php';
10 require_once __DIR__ . '/../services/AdminFatalErrorResponder.php';
11 require_once __DIR__ . '/../services/AjaxFatalErrorResponder.php';
12 require_once __DIR__ . '/../services/FatalErrorProcessor.php';
13 require_once __DIR__ . '/../diagnostics/CrashBeaconStore.php';
14
15 /* Functions in this class should only be for plugging into WordPress listeners (filters, actions, etc). */
16
17 class ABJ_404_Solution_ErrorHandler {
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 self::precomputeCrashBeaconPath();
46 register_shutdown_function('ABJ_404_Solution_ErrorHandler::FatalErrorHandler');
47 }
48
49 /** Try to capture PHP errors.
50 * @param int $errno
51 * @param string $errstr
52 * @param string $errfile
53 * @param int $errline
54 * @return boolean
55 */
56 static function NormalErrorHandler($errno, $errstr, $errfile, $errline) {
57 // Respect PHP's `@` error-suppression operator. Up to PHP 7.x the @
58 // operator zeroed error_reporting() for the duration of the call;
59 // PHP 8.0+ instead leaves error_reporting() non-zero but masks out
60 // the specific level being silenced. A custom handler that ignores
61 // this state still escalates intentionally-suppressed warnings to
62 // error-level logging, defeating the suppression.
63 // Concrete case: @opcache_invalidate() at PluginLogic.php:1036 on
64 // hosts with opcache.restrict_api set produces an E_WARNING. The
65 // legacy `error_reporting() === 0` check matched on PHP 7.x but
66 // not on PHP 8.0+ (where the @ leaves the mask non-zero), so 24
67 // such ERROR entries reached the email reporter in the May 10
68 // debug zip, including 2 from current 4.1.17 sites on PHP 8.3.
69 // The bitwise form covers both eras: on PHP 7.x error_reporting()
70 // is 0 and `& $errno` is 0; on PHP 8.0+ the specific level bit is
71 // cleared and `& $errno` is also 0. Returning false here lets
72 // PHP's default handler honour the suppression.
73 if ((error_reporting() & $errno) === 0) {
74 return false;
75 }
76
77 $abj404logging = abj_service('logging');
78 $f = abj_service('functions');
79 $errorTypeClassifier = new ABJ_404_Solution_ErrorTypeClassifier();
80 $onlyAWarning = false;
81
82 try {
83 // if the error file does not contain the name of our plugin then we ignore it.
84 $slashPos = $f->strpos(ABJ404_NAME, '/');
85 $pluginFolder = $f->substr(ABJ404_NAME, 0, ($slashPos !== false ? $slashPos : null));
86 if ($f->strpos($errfile, $pluginFolder) === false) {
87 // let the normal error handler handle it.
88
89 // this would display the error for other plugins but show @author user
90 // stacktrace from this plugin.
91 // // try calling the original error handler.
92 // if (is_callable(self::$originalErrorHandler)) {
93 // return call_user_func_array(self::$originalErrorHandler,
94 // array($errno, $errstr, $errfile, $errline));
95 // }
96 return false;
97
98 } else {
99 // for our own plugin errors make sure we see them.
100 if ($GLOBALS['abj404_display_errors']) {
101 error_reporting(E_ALL);
102 ABJ_404_Solution_PhpRuntimeCapabilityAdapter::setIni(
103 array('directive' => 'display_errors', 'value' => '1'));
104 }
105 }
106
107 // Recoverable host/infrastructure warnings (headers-already-sent,
108 // open_basedir restriction, ...) are E_WARNINGs the plugin can only
109 // degrade past, never fix -- the host itself forbids the operation.
110 // Downgrade them below error level so getLatestErrorLine() does not
111 // count them and the plugin does not phone home a type='error'
112 // report about a server-side condition only the site owner can
113 // resolve. The allowlist lives in the classifier so a future
114 // host-only warning is added in one place, not as another special
115 // case here (ErrorTypeClassifier::isRecoverableHostWarning: report
116 // 104 headers-already-sent, report 149 open_basedir).
117 $onlyAWarning = $errorTypeClassifier->isRecoverableHostWarning($errno, $errstr);
118
119 $extraInfo = "(none)";
120 $ctxDebugInfo = abj_service('request_context')->debug_info;
121 if ($ctxDebugInfo !== '') {
122 $extraInfo = stripcslashes(wp_kses_post((string)json_encode($ctxDebugInfo)));
123 }
124 $errmsg = "ABJ404-SOLUTION Normal error handler error: errno: " .
125 wp_kses_post((string)json_encode($errno)) . ", errstr: " . wp_kses_post((string)json_encode($errstr)) .
126 ", \nerrfile: " . stripcslashes(wp_kses_post((string)json_encode($errfile))) .
127 ", \nerrline: " . wp_kses_post((string)json_encode($errline)) .
128 ', \nAdditional info: ' . $extraInfo . ", mbstring: " .
129 (extension_loaded('mbstring') ? 'true' : 'false');
130
131 if ($abj404logging != null) {
132 if ($errno === E_NOTICE) {
133 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)');
134 $whitelist = isset($GLOBALS['abj404_whitelist']) && is_array($GLOBALS['abj404_whitelist'])
135 ? $GLOBALS['abj404_whitelist'] : array();
136 if (in_array($serverName, $whitelist, true)) {
137 $e = new Exception;
138 $abj404logging->debugMessage($errmsg . ', Trace:' . $e->getTraceAsString());
139 }
140 } elseif ($onlyAWarning) {
141 $abj404logging->debugMessage($errmsg);
142 } else {
143 $abj404logging->errorMessage($errmsg);
144 }
145 } else {
146 echo $errmsg;
147 }
148 } catch (Throwable $ex) {
149 // Last-resort breadcrumb: the inner logging path itself failed,
150 // so we can't go through $abj404logging. Match the pattern used
151 // by AdminRuntimeErrorNotice::reportAdminRuntimeError() and
152 // Ajax_SuggestionCompute::handleShutdown(). Widening from
153 // Exception to Throwable is intentional because Error types are
154 // exactly the case the outer handler exists for.
155 abj404_logPhpFallback(
156 'fatal-handler-fallback',
157 'error handler itself failed (code ' . $ex->getCode() . '): ' . $ex->getMessage()
158 );
159 }
160
161 // show all warnings and errors.
162 if ($GLOBALS['abj404_display_errors']) {
163 error_reporting(E_ALL);
164 ABJ_404_Solution_PhpRuntimeCapabilityAdapter::setIni(
165 array('directive' => 'display_errors', 'value' => '1'));
166 }
167 // let the original error handler handle it.
168 return false;
169 }
170
171 /** @return bool */
172 static function FatalErrorHandler(): bool {
173 $lasterror = error_get_last();
174 return self::processFatalError($lasterror);
175 }
176
177 /**
178 * Release the OOM reserve before fatal fallback rendering.
179 *
180 * @return void
181 */
182 public static function releaseReservedMemory(): void {
183 self::$reservedMemory = null;
184 }
185
186 /**
187 * Resolve the crash-beacon file path while the request is HEALTHY and cache
188 * it in a primitive global, so the fatal handler can write the beacon using
189 * only that string (the fatal handler must NOT call wp_upload_dir()/options/
190 * the service container during an OOM shutdown; doing so risks re-entering
191 * the exact failure class it is trying to report). Ensures the directory
192 * exists now, while there is memory to do so.
193 *
194 * @return void
195 */
196 private static function precomputeCrashBeaconPath(): void {
197 try {
198 if (!function_exists('abj404_getUploadsDir')) {
199 return;
200 }
201 $dir = abj404_getUploadsDir();
202 if (function_exists('wp_mkdir_p')) {
203 wp_mkdir_p($dir);
204 }
205 $GLOBALS['abj404_crash_beacon_path'] = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR
206 . ABJ_404_Solution_CrashBeaconStore::FILE_NAME;
207 } catch (\Throwable $e) {
208 // Precompute is best-effort: if it fails the fatal handler simply
209 // skips beacon capture (it never resolves the path itself).
210 abj404_logPhpFallback('crash-beacon-precompute', $e->getMessage());
211 }
212 }
213
214 /**
215 * Process a fatal error (shutdown handler).
216 * Public for unit tests (allows injecting a fake last error).
217 *
218 * @param array<string, mixed>|null $lasterror
219 * @return bool
220 */
221 public static function processFatalError($lasterror): bool {
222 $processor = new ABJ_404_Solution_FatalErrorProcessor();
223 return $processor->process($lasterror);
224 }
225 }
226