Repository navigation
OPcache file cache is inert under the wasm SAPI on PHP ≤ 8.4 and silently active on PHP 8.5; run({ code }) shares one cache key and opcache_reset() cannot invalidate it #4465
Description
Summary
@php-wasm/universal writes an OPcache file cache configuration into /internal/shared/php.ini on first run (packages/php-wasm/universal/src/lib/php.ts ~L400–410: opcache.enable=1, enable_cli=1, file_cache=/internal/shared/opcache, file_cache_only=1, file_cache_consistency_checks=1). Two PHP-side facts make the result differ from what that configuration suggests, and both matter for embedders of @php-wasm/web / @php-wasm/node:
- On PHP ≤ 8.4 OPcache never starts under the default SAPI name
wasm.accel_startup()runsaccel_find_sapi()against a fixed whitelist (apache, fastcgi, cli-server, cgi-fcgi, fpm-fcgi, fpmi-fcgi, apache2handler, litespeed, uwsgi, fuzzer, frankenphp, ngx-php, plus the literalcli/phpdbgwhenenable_cli=1) before it looks atfile_cache_only, and on failure unloads the extension ("Opcode Caching is only supported in Apache, FPM, FastCGI, FrankenPHP, LiteSpeed and uWSGI SAPIs").opcache_get_status()returnsfalseand/internal/shared/opcachestays empty.enable_cli=1does not help: it whitelists only the namescli/phpdbg, notwasm. Only callers that pass a whitelistedsapiName(asbootRequestHandler/Blueprint runs do withcli) get a cache. - PHP 8.5 removed the whitelist (php-src
3088d6406847, "Remove the Opcache SAPI whitelist", GH-19351). On the shipped 8.5.10 builds the file cache therefore starts underwasmwith the default ini:opcache_get_status()→{"opcache_enabled":false,"file_cache_only":true,…}and.binfiles appear under/internal/shared/opcache/<system_id>/. Measured on@php-wasm/nodeand on@php-wasm/web(Chromium).
Once the cache is live, three properties of file-cache-only mode are easy to trip over:
opcache_reset()andopcache_invalidate()are no-ops (both guard onZCG(accelerator_enabled), whichaccel_activate()forces tofalsein file-cache-only mode). The only invalidation is deleting<file_cache>/<system_id>/<absolute script path>.bin.hotSwapPHPRuntime()carries/internalover, so the cache survives a runtime rotation.- Loading a
.binvalidates magic,system_idand — only withvalidate_timestamps=1— the script'sst_mtimein whole seconds (MEMFS truncates to seconds); size and content are never compared. An embedder that setsvalidate_timestamps=0(we did, in Moodle Playground) gets stale bytecode for every rewritten file; with the default1, two different contents written to the same path within the same second are indistinguishable. PHP.run({ code })writes every snippet to the same path/internal/eval.php(php.ts~L790) and compiles it throughzend_compile_file, so all snippets share one cache key. Today they are protected only byopcache.file_update_protection(default 2 s: a file modified less than 2 s before the request is not stored). Withfile_update_protection=0(or any sequence that lets aneval.php.binbe stored) a laterrun({ code })executes a previous snippet's bytecode. Reproduced withvalidate_timestamps=1, file_update_protection=0(two snippets in the same second) and withvalidate_timestamps=0(persists indefinitely).
Reproduction (Node, 3.1.57/3.1.58)
// npm i @php-wasm/node @php-wasm/universal
import { loadNodeRuntime } from '@php-wasm/node';
import { PHP, __private__dont__use } from '@php-wasm/universal';
for (const version of ['8.3', '8.4', '8.5']) {
const php = new PHP(await loadNodeRuntime(version, { emscriptenOptions: { processId: 1 } }));
// await php.setSapiName('cgi-fcgi'); // uncomment: the cache becomes active on 8.3/8.4 as well
const FS = php[__private__dont__use].FS;
const info = (await php.run({ code: '<?php echo PHP_VERSION, " ", PHP_SAPI, " ", json_encode(opcache_get_status(false));' })).text;
// compile a script old enough to pass file_update_protection, then rewrite it
php.writeFile('/tmp/a.php', '<?php echo "v1";');
const old = new Date(Date.now() - 10_000); FS.utime('/tmp/a.php', old, old);
await php.run({ scriptPath: '/tmp/a.php' });
php.writeFile('/tmp/a.php', '<?php echo "v2";');
const rewritten = (await php.run({ scriptPath: '/tmp/a.php' })).text;
const reset = (await php.run({ code: '<?php var_export([opcache_reset(), opcache_invalidate("/tmp/a.php", true)]);' })).text;
const bins = php.listFiles('/internal/shared/opcache').map((id) => `${id}: ${php.listFiles(`/internal/shared/opcache/${id}`).length} entries`);
console.log({ version, info, rewritten, reset, bins });
php.exit();
}Observed (shipped binaries, default ini):
| PHP | PHP_SAPI |
opcache_get_status() |
.bin written |
opcache_reset() / opcache_invalidate() |
|---|---|---|---|---|
| 8.3.33 | wasm |
false |
no | false / false (extension not loaded) |
| 8.4.25 | wasm |
false |
no | same |
| 8.5.10 | wasm |
{opcache_enabled:false, file_cache_only:true} |
yes (system_id 6728f976…) |
false / false, .bin stays |
8.3.33 + setSapiName('cgi-fcgi') |
cgi-fcgi |
{file_cache_only:true} |
yes (556d4ecd…) |
false / false, .bin stays |
With the default validate_timestamps=1 the rewritten script correctly returns v2 (mtime differs); with validate_timestamps=0 it keeps returning v1. In the browser (@php-wasm/web 3.1.57, Chromium) the 8.5.10 build shows the same status and populated cache directory, and a .bin produced by the Node build loads in the browser build (same system_id).
Suggestions (for discussion)
- Document SAPI selection for web runtimes: OPcache's file cache is inert under
wasmon PHP ≤ 8.4 unlesssetSapiName()is called with a whitelisted name before the first request, and PHP 8.5 enables it automatically. ExposingsapiNameon the web boot path (as the request-handler boot already does) would make this explicit. - Exclude the eval path from the cache: ship an
opcache.blacklist_filenamewith/internal/eval.php(the blacklist is honored on store in file-cache-only mode), or give eachrun({ code })a unique script path, so correctness no longer depends onfile_update_protectiontiming. - Keep
validate_timestamps=1as a hard default and document that embedders must not set it to0with the file cache (whole-second mtime granularity). - Document invalidation:
opcache_reset()/opcache_invalidate()do nothing here; a helper that unlinks<file_cache>/<system_id>/<path>.bin(or the whole<system_id>tree) — and clearing it inhotSwapPHPRuntime()when a clean runtime is requested — would give embedders a working primitive. - Optionally extend
packages/php-wasm/node/src/test/php-opcache.spec.tsto assert cache activity (status object,.binpresent) per PHP version, not only that PHP runs.
Out of scope
Shared-memory OPcache / JIT (#3617, #3618, #3719) and the already-merged #4384/#4385/#4390. Related earlier work: #2400 added the file-cache configuration.
References
- php-src 8.3/8.4
ext/opcache/ZendAccelerator.c(accel_find_sapi,accel_startup,file_cache_compile_file),ext/opcache/zend_file_cache.c(zend_file_cache_script_load,zend_file_cache_get_bin_file_path),ext/opcache/zend_accelerator_module.c(opcache_reset/opcache_invalidateguards); php-src3088d6406847(8.5). packages/php-wasm/universal/src/lib/php.ts(ini block ~L400–410,setSapiName~L500,run({code})→/internal/eval.php~L790,hotSwapPHPRuntime~L1514 re-applying the SAPI name),packages/php-wasm/compile/php/php_wasm.c(php_wasm_sapi_modulename"wasm"L988,wasm_set_sapi_nameL1025) — trunkd7b19bdb.- Context: found while measuring Moodle Playground (ateeducacion/moodle-playground), where enabling the cache via a whitelisted SAPI name plus
validate_timestamps=1cut cold start by ~33 % and per-request PHP time from 58 to 17 ms; happy to share the full measurement package.
Activity