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 / root-boot / RuntimeHelpers.php

RuntimeHelpers.php in 404 Solution trunk, at includes/root-boot/RuntimeHelpers.php

261 lines 9.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 /**
7 * Runtime helpers for root-file callbacks (logging, clock, admin-policy gate).
8 *
9 * The plugin entry point owns early boot, shutdown, and cron callbacks where the
10 * service container may not be available yet. These helpers keep those callbacks
11 * on the same logging/clock/authorization abstractions the rest of the plugin
12 * uses, while degrading safely when the container or its classes are missing.
13 */
14 // allow-no-test-found: boot-time global helper functions (logging/clock/admin-policy gate) required directly by 404-solution.php before the service container exists; no isolated unit-file seam. abj404_logRuntimeWarning is exercised in CatchExceptionCoverageAuditTest and OpcacheStaleAfterUpgradeTest; the clock helpers back the Clock-injection tests.
15
16 if (!function_exists('abj404_record_missing_file')) {
17 /**
18 * Append a missing/corrupt plugin file path to the boot-state missing-files
19 * list. Centralizes the typed write to $GLOBALS['abj404_missing_files'] so
20 * callers (the autoloader, the boot sequence) don't each have to re-assert the
21 * global's array shape.
22 *
23 * @param string $path
24 * @return void
25 */
26 function abj404_record_missing_file(string $path): void {
27 if (!isset($GLOBALS['abj404_missing_files']) || !is_array($GLOBALS['abj404_missing_files'])) {
28 $GLOBALS['abj404_missing_files'] = array();
29 }
30 $GLOBALS['abj404_missing_files'][] = $path;
31 }
32 }
33
34 if (!function_exists('abj404_logRuntimeWarning')) {
35 /**
36 * Route root-file runtime warnings through the plugin logger when available.
37 *
38 * The root plugin file owns early boot, shutdown, and cron callbacks where the
39 * service container may not be available yet. This helper keeps normal runtime
40 * failures in the plugin log while preserving a last-resort PHP error log
41 * breadcrumb if logger resolution itself fails.
42 *
43 * @param string $context
44 * @param \Throwable|null $throwable
45 * @return void
46 */
47 function abj404_logRuntimeWarning(string $context, ?\Throwable $throwable = null): void {
48 $line = $context;
49 if ($throwable !== null) {
50 $line .= ' (code ' . (string)$throwable->getCode() . ') at ' .
51 $throwable->getFile() . ':' . (string)$throwable->getLine() .
52 ': ' . $throwable->getMessage();
53 }
54
55 $loggerFailure = null;
56 try {
57 $logger = null;
58 if (function_exists('abj_service_optional')) {
59 $logger = abj_service_optional('logging');
60 }
61 if (!is_object($logger) && class_exists('ABJ_404_Solution_Logging', false)) {
62 $logger = ABJ_404_Solution_Logging::getInstance();
63 }
64 if (is_object($logger) && method_exists($logger, 'warn')) {
65 $logger->warn($line);
66 return;
67 }
68 if (is_object($logger) && method_exists($logger, 'errorMessage')) {
69 $logger->errorMessage($line, $throwable instanceof Exception ? $throwable : null);
70 return;
71 }
72 } catch (\Throwable $loggingError) {
73 $loggerFailure = $loggingError;
74 }
75
76 $fallback = $line;
77 if ($loggerFailure !== null) {
78 $fallback .= ' | logger failure (code ' . (string)$loggerFailure->getCode() . ') at ' .
79 $loggerFailure->getFile() . ':' . (string)$loggerFailure->getLine() .
80 ': ' . $loggerFailure->getMessage();
81 }
82 abj404_logPhpFallback('early-boot', $fallback);
83 }
84 }
85
86 if (!function_exists('abj404_isSelfUpdateRaceThrowable')) {
87 /**
88 * Is this throwable the plugin's own files being replaced underneath a live
89 * request, rather than a defect in the plugin's code?
90 *
91 * WordPress replaces the plugin directory while requests are still running on
92 * the installed release, so for a few seconds a request can read new class
93 * files, or read no file at all, and be unable to resolve one of OUR classes.
94 * That is a hosting-lifecycle event the plugin can only degrade past and which
95 * resolves itself on the very next request; per the defensive philosophy it
96 * belongs at warning level, not at error level where it emails the maintainer
97 * about a condition nobody needs to act on. Production report 266
98 * (staging-criticalimpactcom.kinsta.cloud, 4.3.2 to 4.3.3) is one such email.
99 *
100 * Deliberately narrow, and safe to be narrow in exactly one direction: an
101 * unresolvable ABJ_404_Solution class can no longer be a plugin BUG, because
102 * ClassLoadingReachabilityTest::testProductionClassReferencesAreReachable()
103 * fails the build when any production class reference is not classmapped,
104 * same-file, or directly required. What is left at runtime is a missing or
105 * mid-swap file, i.e. infrastructure. Everything else -- an \Exception, a
106 * TypeError, a call to an undefined method, a non-plugin class -- stays at
107 * error level and keeps reaching the inbox.
108 *
109 * @param \Throwable|null $throwable
110 * @return bool
111 */
112 function abj404_isSelfUpdateRaceThrowable($throwable): bool {
113 if (!($throwable instanceof \Error)) {
114 return false;
115 }
116 // PHP 8 quotes the name with ", PHP 7 with '. Interfaces, traits and enums
117 // are in the same classmap and fail with the same sentence.
118 return preg_match(
119 '/^(Class|Interface|Trait|Enum) ["\']?ABJ_404_Solution[A-Za-z0-9_]*["\']? not found$/',
120 $throwable->getMessage()
121 ) === 1;
122 }
123 }
124
125 if (!function_exists('abj404_logCallbackFailure')) {
126 /**
127 * Log a throwable caught by a plugin callback that must not crash its host
128 * (a WordPress hook, a cron-context report send, an admin page render),
129 * choosing the severity from the CAUSE rather than from the call site.
130 *
131 * A self-update race degrades to a warning, so it stays in the debug log for
132 * support without counting as an error or triggering an error report. Anything
133 * else keeps the caller's previous error-level behavior verbatim, including
134 * errorMessage()'s Exception-only second parameter.
135 *
136 * @param object|null $logger ABJ_404_Solution_Logging, or anything
137 * exposing warn()/errorMessage().
138 * @param string $message Already-composed log line.
139 * @param \Throwable|null $throwable The caught throwable.
140 * @return void
141 */
142 function abj404_logCallbackFailure($logger, string $message, $throwable): void {
143 if (abj404_isSelfUpdateRaceThrowable($throwable)
144 && is_object($logger) && method_exists($logger, 'warn')) {
145 $logger->warn($message . ' | plugin files were being replaced mid-request; '
146 . 'this resolves on the next request');
147 return;
148 }
149 if (is_object($logger) && method_exists($logger, 'errorMessage')) {
150 $logger->errorMessage($message, $throwable instanceof \Exception ? $throwable : null);
151 return;
152 }
153 abj404_logRuntimeWarning($message, $throwable instanceof \Throwable ? $throwable : null);
154 }
155 }
156
157 if (!function_exists('abj404_resolve_clock')) {
158 /**
159 * Resolve the project clock from the root plugin bootstrap.
160 *
161 * The normal service helper is not available until Loader.php pulls in the
162 * bootstrap files, but root-file callbacks also run before or after that
163 * boundary. This keeps those callbacks on the same clock abstraction without
164 * making early boot depend on the fully initialized container.
165 *
166 * @return object|null
167 */
168 function abj404_resolve_clock() {
169 static $fallbackClock = null;
170
171 try {
172 if (function_exists('abj_clock')) {
173 return abj_clock();
174 }
175 if (is_object($fallbackClock)) {
176 return $fallbackClock;
177 }
178 if (class_exists('ABJ_404_Solution_SystemClock')) {
179 $fallbackClock = new ABJ_404_Solution_SystemClock();
180 return $fallbackClock;
181 }
182 } catch (\Throwable $e) {
183 abj404_logRuntimeWarning('Clock resolution failed in root bootstrap', $e);
184 }
185
186 return null;
187 }
188 }
189
190 if (!function_exists('abj404_now')) {
191 /**
192 * Current epoch seconds for root-file callbacks.
193 *
194 * @return int
195 */
196 function abj404_now(): int {
197 $clock = abj404_resolve_clock();
198 if (is_object($clock) && method_exists($clock, 'now')) {
199 try {
200 return (int)$clock->now();
201 } catch (\Throwable $e) {
202 abj404_logRuntimeWarning('Clock now() failed in root bootstrap', $e);
203 }
204 }
205
206 static $reportedUnavailable = false;
207 if (!$reportedUnavailable) {
208 abj404_logRuntimeWarning('Clock unavailable in root bootstrap; using zero epoch fallback');
209 $reportedUnavailable = true;
210 }
211 return 0;
212 }
213 }
214
215 if (!function_exists('abj404_now_float')) {
216 /**
217 * Current epoch seconds with sub-second precision for root-file callbacks.
218 *
219 * @return float
220 */
221 function abj404_now_float(): float {
222 $clock = abj404_resolve_clock();
223 if (is_object($clock) && method_exists($clock, 'nowFloat')) {
224 try {
225 return (float)$clock->nowFloat();
226 } catch (\Throwable $e) {
227 abj404_logRuntimeWarning('Clock nowFloat() failed in root bootstrap', $e);
228 }
229 }
230
231 return (float)abj404_now();
232 }
233 }
234
235 if (!function_exists('abj404_current_user_is_plugin_admin')) {
236 /**
237 * Root-file policy gate for admin callbacks declared before normal classes run.
238 *
239 * Degraded boot screens still use direct WordPress capabilities because the
240 * policy class can be among the missing files. Runtime admin callbacks should
241 * call this helper so delegated plugin admins follow the same authorization
242 * decision everywhere.
243 *
244 * @return bool
245 */
246 function abj404_current_user_is_plugin_admin(): bool {
247 try {
248 if (class_exists('ABJ_404_Solution_PluginAdminAccessPolicy')) {
249 return ABJ_404_Solution_PluginAdminAccessPolicy::currentUserCanAccessPluginAdmin();
250 }
251 abj404_logRuntimeWarning(
252 'plugin admin access policy resolution failed because PluginAdminAccessPolicy is unavailable.'
253 );
254 } catch (\Throwable $e) {
255 abj404_logRuntimeWarning('plugin admin access policy resolution failed', $e);
256 }
257
258 return false;
259 }
260 }
261