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

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

481 lines 22.1 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 * Post-upgrade opcache guard.
8 *
9 * PHP's opcache revalidates cached bytecode per file, on its own schedule
10 * (`opcache.validate_timestamps` + a nonzero `opcache.revalidate_freq`). For a
11 * few seconds after a plugin update the class graph can therefore be MIXED:
12 * a file whose path is new to this release compiles fresh from disk, while a
13 * file whose path is unchanged is still served as the previous release's
14 * bytecode. If a parent and its subclass land on opposite sides of that split
15 * and a method signature changed between the two releases, PHP raises an
16 * uncatchable E_ERROR at class-linking time.
17 *
18 * Observed in production (westcoato.kinsta.cloud, 2026-07-18, PHP 8.4/8.5,
19 * upgrade 4.2.0 -> 4.3.1):
20 *
21 * Declaration of ABJ_404_Solution_FunctionsPreg::substr(...) must be
22 * compatible with ABJ_404_Solution_Functions::substr(...)
23 *
24 * `includes/core/Functions.php` is a path that only exists in 4.3.0+, so it
25 * compiled fresh; `includes/php/FunctionsPreg.php` kept its path across the
26 * upgrade, so the 4.2.0 bytecode was still cached. The same log shows a second,
27 * independent symptom of the same mixed state one second earlier: 4.2.0
28 * bytecode asking for `includes/sql/getRedirectsForViewStaged/...`, a directory
29 * the 4.3.1 tree does not contain.
30 *
31 * Ordering, not coverage, is what makes this fixable. The fatal happens while
32 * the autoloader links classes, so any mitigation that lives inside the booted
33 * plugin (as the previous `invalidateOpcacheForCriticalFiles()` did) runs
34 * strictly too late for the request that dies. This file is therefore required
35 * and executed by `404-solution.php` BEFORE any other plugin file is required
36 * and before `spl_autoload_register()`, so the invalidated files are recompiled
37 * from disk for the very request that detects the upgrade.
38 *
39 * The file set is DERIVED, never hand-listed: a hand-maintained list is
40 * drift-prone by construction and would miss the next subclass that happens to
41 * sit at a stable path. The derivation asks the opcode cache which of ITS
42 * cached scripts live under the plugin directory, which is both exact (a file
43 * the cache does not hold cannot be stale) and free of filesystem access. A
44 * bounded directory walk is kept only as a fallback for hosts that expose
45 * `opcache_invalidate()` without `opcache_get_status()`.
46 *
47 * What this CANNOT protect, stated plainly so nobody re-derives it: the upgrade
48 * that first ships this guard. `404-solution.php` keeps its path across
49 * releases, so a warm worker can be running the PREVIOUS release's bytecode of
50 * the entry point itself -- and that bytecode has no call to this file. The
51 * guard therefore protects every upgrade from the release that introduces it
52 * onward, and the introducing upgrade still depends on opcache revalidating the
53 * entry point on its own schedule. There is no way to fix that from inside the
54 * plugin; code that is not running cannot invalidate itself.
55 */
56 // allow-no-test-found: boot-time global functions executed from 404-solution.php before the autoloader exists; there is no class seam to name a same-named unit file after. Behavior is covered end-to-end by OpcacheInvalidateOnUpgradeTest (boot-order probe, derived-set coverage, gate, restrict_api guard, truncation).
57
58 /**
59 * Name of the option holding the plugin version whose files were last flushed
60 * from opcache. Kept separate from `abj404_settings['DB_VERSION']` so the flush
61 * happens exactly once per upgrade even when the database upgrade itself is
62 * failing or repeatedly deferred.
63 */
64 if (!defined('ABJ404_OPCACHE_VERSION_OPTION')) {
65 define('ABJ404_OPCACHE_VERSION_OPTION', 'abj404_opcache_version');
66 }
67
68 if (!function_exists('abj404_opcache_api_is_restricted')) {
69 /**
70 * Decide whether `opcache_invalidate()` is callable from this file.
71 *
72 * Hosts can set `opcache.restrict_api` to a path prefix; PHP then emits an
73 * E_WARNING and refuses the call for any script outside that prefix. See the
74 * note at includes/core/ErrorHandler.php about suppressed
75 * `@opcache_invalidate()` warnings reaching the email reporter on such hosts.
76 * Checking the prefix up front means we never make the restricted call at
77 * all, instead of making it several hundred times and suppressing each one.
78 *
79 * @param mixed $restrictSetting Raw value of the opcache.restrict_api ini setting.
80 * @param string $callerFile Absolute path of the file making the call.
81 * @return bool True when the API must not be called from $callerFile.
82 */
83 function abj404_opcache_api_is_restricted($restrictSetting, $callerFile) {
84 if (!is_string($restrictSetting)) {
85 return false;
86 }
87 $prefix = trim($restrictSetting);
88 if ($prefix === '') {
89 return false;
90 }
91 if (!is_string($callerFile) || $callerFile === '') {
92 return true;
93 }
94 // PHP compares the caller path against the prefix with a plain
95 // case-sensitive prefix match on POSIX, case-insensitive on Windows.
96 if (DIRECTORY_SEPARATOR === '\\') {
97 return stripos($callerFile, $prefix) !== 0;
98 }
99 return strpos($callerFile, $prefix) !== 0;
100 }
101 }
102
103 if (!function_exists('abj404_opcache_scripts_under')) {
104 /**
105 * Select the cached scripts that live under the plugin directory.
106 *
107 * `opcache_get_status(true)` reports every script currently held in the
108 * shared opcode cache, keyed by absolute path. That list IS the set of
109 * files that can serve stale bytecode: a file the cache does not hold gets
110 * compiled from disk on first include and cannot be stale by definition.
111 * Filtering it by the plugin root therefore derives the exact target set
112 * with no filesystem access at all, which is what makes this affordable to
113 * run from the boot path.
114 *
115 * @param mixed $scripts The `scripts` member of an opcache_get_status(true) result.
116 * @param string $root Absolute plugin directory, trailing separator optional.
117 * @return string[] Absolute paths, sorted.
118 */
119 function abj404_opcache_scripts_under($scripts, $root) {
120 if (!is_array($scripts) || !is_string($root) || $root === '') {
121 return array();
122 }
123 // Compare with a single trailing separator on both sides so a sibling
124 // plugin directory sharing our prefix (`404-solution-pro/`) cannot be
125 // swept into the flush.
126 $prefix = rtrim(str_replace('\\', '/', $root), '/') . '/';
127 $caseInsensitive = (DIRECTORY_SEPARATOR === '\\');
128
129 $matched = array();
130 foreach ($scripts as $path => $ignored) {
131 if (!is_string($path) || $path === '') {
132 continue;
133 }
134 $normalized = str_replace('\\', '/', $path);
135 $isMatch = $caseInsensitive
136 ? (stripos($normalized, $prefix) === 0)
137 : (strpos($normalized, $prefix) === 0);
138 if ($isMatch) {
139 $matched[] = $path;
140 }
141 }
142 sort($matched);
143
144 return $matched;
145 }
146 }
147
148 if (!function_exists('abj404_opcache_collect_plugin_php_files')) {
149 /**
150 * FALLBACK derivation: every PHP file the plugin can include at runtime.
151 *
152 * Only used when `opcache_get_status()` is unavailable (a host that put it
153 * in `disable_functions` while leaving `opcache_invalidate()` callable).
154 * The cached-script list is preferred because it is both exact and free;
155 * this walk costs a stat per directory entry, which is a few milliseconds
156 * on a release tree (~520 PHP files) but grows with whatever else the site
157 * owner has left inside the plugin directory. Hence the caps.
158 *
159 * Walks $root breadth-first, skipping dot-directories (`.git`, `.queue`,
160 * ...) and `node_modules`. Neither can hold PHP this plugin includes, and
161 * both are excluded from the release package, so pruning them is a property
162 * of the tree rather than a maintained exclusion list. Everything else is
163 * included, so a future top-level source directory is covered without
164 * anyone remembering to add it here.
165 *
166 * Symlinked directories are not followed. A self-referential link inside
167 * the plugin directory would otherwise make this walk run forever at boot
168 * (this repository has one: `vendor/vendor`), and code reached only through
169 * a link out of the plugin directory is not this plugin's bytecode to flush.
170 *
171 * @param string $root Absolute directory to walk.
172 * @param int $maxFiles Runaway guard; a plugin release ships ~520 PHP files.
173 * @param float $maxSeconds Wall-clock budget. A stray backup or media
174 * directory inside the plugin folder can hold tens
175 * of thousands of entries on slow shared storage;
176 * a boot-path walk must give up rather than spend
177 * the request's whole execution budget. Reported as
178 * truncated, never as full coverage.
179 * @return array{files: string[], truncated: bool}
180 */
181 function abj404_opcache_collect_plugin_php_files($root, $maxFiles = 20000, $maxSeconds = 2.0) {
182 /** @var string[] $files */
183 $files = array();
184 $truncated = false;
185
186 if (!is_string($root) || $root === '' || !is_dir($root) || $maxFiles < 1) {
187 return array('files' => $files, 'truncated' => false);
188 }
189
190 // No clock adapter exists this early in the boot: the autoloader has
191 // not been registered yet, by design. microtime() is the only option.
192 $deadline = ($maxSeconds > 0) ? (microtime(true) + $maxSeconds) : null;
193
194 /** @var string[] $pending */
195 $pending = array(rtrim($root, '/\\'));
196 while ($pending !== array()) {
197 if ($deadline !== null && microtime(true) > $deadline) {
198 $truncated = true;
199 break;
200 }
201 $directory = array_pop($pending);
202 $entries = @scandir($directory);
203 if (!is_array($entries)) {
204 // Unreadable directory (permissions, open_basedir). Skipping it
205 // costs coverage of that subtree only; opcache's own timestamp
206 // revalidation remains the backstop there.
207 continue;
208 }
209 foreach ($entries as $entry) {
210 if ($entry === '.' || $entry === '..' || $entry === '') {
211 continue;
212 }
213 $path = $directory . DIRECTORY_SEPARATOR . $entry;
214 if (is_link($path)) {
215 continue;
216 }
217 if (is_dir($path)) {
218 if ($entry[0] === '.' || $entry === 'node_modules') {
219 continue;
220 }
221 $pending[] = $path;
222 continue;
223 }
224 if (substr($entry, -4) !== '.php') {
225 continue;
226 }
227 if (count($files) >= $maxFiles) {
228 $truncated = true;
229 break 2;
230 }
231 $files[] = $path;
232 }
233 }
234
235 sort($files);
236
237 return array('files' => $files, 'truncated' => $truncated);
238 }
239 }
240
241 if (!function_exists('abj404_opcache_read_version_stamp')) {
242 /**
243 * Read the plugin version whose files were last flushed from opcache.
244 *
245 * @return string|null Null when the options API is not available yet, which
246 * means the change cannot be gated and must be skipped.
247 */
248 function abj404_opcache_read_version_stamp() {
249 if (function_exists('get_site_option')) {
250 $stamp = get_site_option(ABJ404_OPCACHE_VERSION_OPTION, '');
251 } elseif (function_exists('get_option')) {
252 $stamp = get_option(ABJ404_OPCACHE_VERSION_OPTION, '');
253 } else {
254 return null;
255 }
256 return is_string($stamp) ? $stamp : '';
257 }
258 }
259
260 if (!function_exists('abj404_opcache_write_version_stamp')) {
261 /**
262 * Record the plugin version whose files have now been flushed.
263 *
264 * Uses the network-wide option on multisite: opcache is per PHP worker, not
265 * per blog, so one flush per network per release is the correct scope.
266 *
267 * @param string $version
268 * @return bool
269 */
270 function abj404_opcache_write_version_stamp($version) {
271 if (function_exists('update_site_option')) {
272 return (bool) update_site_option(ABJ404_OPCACHE_VERSION_OPTION, $version);
273 }
274 if (function_exists('update_option')) {
275 return (bool) update_option(ABJ404_OPCACHE_VERSION_OPTION, $version);
276 }
277 return false;
278 }
279 }
280
281 if (!function_exists('abj404_opcache_scripts_from_status')) {
282 /**
283 * Decide whether an opcache_get_status() result can name our target files.
284 *
285 * Split out from the caller so this judgement is directly testable against
286 * crafted status arrays; the shapes below are configurations a test process
287 * cannot enter and leave at will.
288 *
289 * Returns null (meaning "ask the filesystem instead") when:
290 *
291 * - the call failed, or the host reports opcache disabled for this SAPI;
292 * - `opcache.file_cache_only` is on. PHP omits the `scripts` key entirely
293 * then, because there is no shared-memory table to report. Treating
294 * "no scripts reported" as "nothing is cached" would be wrong, so we
295 * look at the disk instead. Measured caveat, so nobody re-derives it:
296 * on a PURE file-cache-only host neither `opcache_invalidate()` nor
297 * `opcache_reset()` can evict an entry (both operate on the shared
298 * memory table that does not exist there), so the flush is a no-op and
299 * opcache's own timestamp revalidation remains the only recovery. The
300 * fallback still matters for mixed setups, where a file cache backs a
301 * live shared-memory cache that this branch would otherwise skip;
302 * - the script list is present but empty. Either the cache is genuinely
303 * cold (the walk then invalidates nothing, costing a few milliseconds
304 * once) or the host is reporting something we do not understand. Both
305 * are better served by looking at the disk than by concluding there is
306 * no work to do.
307 *
308 * @param mixed $status Return value of opcache_get_status(true).
309 * @param string $root Absolute plugin directory.
310 * @return string[]|null Absolute paths, or null when the status cannot answer.
311 */
312 function abj404_opcache_scripts_from_status($status, $root) {
313 if (!is_array($status)) {
314 return null;
315 }
316 if (array_key_exists('opcache_enabled', $status) && !$status['opcache_enabled']) {
317 return null;
318 }
319 if (!empty($status['file_cache_only'])) {
320 return null;
321 }
322 if (!isset($status['scripts']) || !is_array($status['scripts']) || $status['scripts'] === array()) {
323 return null;
324 }
325 return abj404_opcache_scripts_under($status['scripts'], $root);
326 }
327 }
328
329 if (!function_exists('abj404_opcache_target_files')) {
330 /**
331 * Derive the set of plugin files whose bytecode may be stale.
332 *
333 * Preferred source is the shared opcode cache's own script list, which is
334 * exact (only cached files can be stale) and costs no filesystem access.
335 * The directory walk is the fallback for every configuration where that
336 * list cannot answer; see abj404_opcache_scripts_from_status().
337 *
338 * @param string $root Absolute plugin directory.
339 * @return array{files: string[], source: string, truncated: bool}
340 */
341 function abj404_opcache_target_files($root) {
342 // Load the dependency-free capability boundary only after the upgrade
343 // guard has been consulted. Loading any ordinary plugin class before
344 // that decision would recreate the stale class-linking fatal this
345 // guard exists to prevent.
346 require_once dirname(__DIR__) . '/core/PhpRuntimeCapabilityAdapter.php';
347 require_once dirname(__DIR__) . '/core/OpcacheAdapter.php';
348 if (ABJ_404_Solution_PhpRuntimeCapabilityAdapter::isFunctionAvailable('opcache_get_status')) {
349 // Suppressed because a host with opcache compiled in but disabled
350 // for this SAPI raises a warning here rather than returning false.
351 $fromStatus = abj404_opcache_scripts_from_status(
352 ABJ_404_Solution_OpcacheAdapter::status(true),
353 $root
354 );
355 if ($fromStatus !== null) {
356 return array(
357 'files' => $fromStatus,
358 'source' => 'opcache-script-list',
359 'truncated' => false,
360 );
361 }
362 }
363
364 $collected = abj404_opcache_collect_plugin_php_files($root);
365
366 return array(
367 'files' => $collected['files'],
368 'source' => 'directory-walk',
369 'truncated' => $collected['truncated'],
370 );
371 }
372 }
373
374 if (!function_exists('abj404_opcache_refresh_after_upgrade')) {
375 /**
376 * Flush stale plugin bytecode when the on-disk plugin version has changed.
377 *
378 * Must be called from `404-solution.php` before any other plugin file is
379 * required. Returns a result record rather than a bare list so the boot path
380 * can stash it for diagnostics without this function needing a logger (none
381 * exists this early in the boot).
382 *
383 * Known limit, deliberately not engineered around: the version stamp lives
384 * in the database, which is shared across a multi-server deployment, while
385 * the opcode cache is per PHP master process. On such a deployment the node
386 * that first observes the version change flushes; the others skip and fall
387 * back to opcache's own timestamp revalidation, i.e. the behaviour every
388 * node had before this guard existed. Making the stamp per-node would mean
389 * inventing a node identity that survives restarts and is stable behind a
390 * load balancer, which is a larger and more fragile problem than the
391 * seconds-wide window it would close.
392 *
393 * @return array{ran: bool, reason: string, invalidated: string[], scanned: int, truncated: bool, source: string}
394 */
395 function abj404_opcache_refresh_after_upgrade() {
396 $result = array(
397 'ran' => false,
398 'reason' => '',
399 'invalidated' => array(),
400 'scanned' => 0,
401 'truncated' => false,
402 'source' => '',
403 );
404
405 if (!defined('ABJ404_PATH') || !defined('ABJ404_VERSION')) {
406 $result['reason'] = 'boot-constants-missing';
407 return $result;
408 }
409
410 $stamp = abj404_opcache_read_version_stamp();
411 if ($stamp === null) {
412 // Without a persistent stamp there is no way to make this one-time,
413 // and flushing the plugin's whole bytecode set on every single
414 // request is a worse bug than the one being fixed. opcache's own
415 // timestamp revalidation stays the backstop.
416 $result['reason'] = 'options-api-unavailable';
417 return $result;
418 }
419 if ($stamp === (string) ABJ404_VERSION) {
420 $result['reason'] = 'version-unchanged';
421 return $result;
422 }
423
424 // Claim the upgrade BEFORE doing any work, not after. The stamp is what
425 // makes this one-time, so if it cannot be persisted -- read-only replica,
426 // full disk, options table missing -- then doing the work anyway means
427 // repeating it on every single request from now on. On a host that falls
428 // back to the directory walk that is seconds of boot time per request:
429 // far worse than the seconds-wide staleness window being closed. Claim
430 // first, and if the claim does not stick, do nothing at all.
431 //
432 // The cost of claiming first is that a request killed between the claim
433 // and the flush leaves the flush undone. That degrades to exactly the
434 // pre-guard behaviour (opcache revalidates on its own schedule), so the
435 // trade is strictly in favour of claiming first.
436 $result['ran'] = true;
437
438 if (!abj404_opcache_write_version_stamp((string) ABJ404_VERSION)) {
439 // update_option() also reports false when the stored value already
440 // equals the new one, which here means a concurrent request claimed
441 // this upgrade between our read and our write and is doing the flush.
442 // Both outcomes mean "do nothing", but they are different facts and
443 // the reason string is the only record either one leaves.
444 $result['reason'] = (abj404_opcache_read_version_stamp() === (string) ABJ404_VERSION)
445 ? 'claimed-by-concurrent-request'
446 : 'stamp-write-failed';
447 return $result;
448 }
449
450 // This final, dependency-free class is safe to link only after the
451 // persistent version gate above has made the upgrade decision.
452 require_once dirname(__DIR__) . '/core/PhpRuntimeCapabilityAdapter.php';
453 require_once dirname(__DIR__) . '/core/OpcacheAdapter.php';
454 if (!ABJ_404_Solution_PhpRuntimeCapabilityAdapter::isFunctionAvailable('opcache_invalidate')) {
455 $result['reason'] = 'opcache-unavailable';
456 return $result;
457 }
458 if (abj404_opcache_api_is_restricted(ini_get('opcache.restrict_api'), __FILE__)) {
459 $result['reason'] = 'opcache-api-restricted';
460 return $result;
461 }
462
463 $collected = abj404_opcache_target_files(ABJ404_PATH);
464 $result['scanned'] = count($collected['files']);
465 $result['truncated'] = $collected['truncated'];
466 $result['source'] = $collected['source'];
467
468 foreach ($collected['files'] as $file) {
469 // A file that is not currently cached returns false; that is a
470 // normal outcome here, not an error, so only successes are recorded.
471 if (ABJ_404_Solution_OpcacheAdapter::invalidate($file, true)) {
472 $result['invalidated'][] = $file;
473 }
474 }
475
476 $result['reason'] = 'version-changed';
477
478 return $result;
479 }
480 }
481