Skip to content

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

Open

Description

@erseco

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:

  1. On PHP ≤ 8.4 OPcache never starts under the default SAPI name wasm. accel_startup() runs accel_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 literal cli/phpdbg when enable_cli=1) before it looks at file_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() returns false and /internal/shared/opcache stays empty. enable_cli=1 does not help: it whitelists only the names cli/phpdbg, not wasm. Only callers that pass a whitelisted sapiName (as bootRequestHandler/Blueprint runs do with cli) get a cache.
  2. 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 under wasm with the default ini: opcache_get_status() → {"opcache_enabled":false,"file_cache_only":true,…} and .bin files appear under /internal/shared/opcache/<system_id>/. Measured on @php-wasm/node and on @php-wasm/web (Chromium).

Once the cache is live, three properties of file-cache-only mode are easy to trip over:

  • opcache_reset() and opcache_invalidate() are no-ops (both guard on ZCG(accelerator_enabled), which accel_activate() forces to false in file-cache-only mode). The only invalidation is deleting <file_cache>/<system_id>/<absolute script path>.bin. hotSwapPHPRuntime() carries /internal over, so the cache survives a runtime rotation.
  • Loading a .bin validates magic, system_id and — only with validate_timestamps=1 — the script's st_mtime in whole seconds (MEMFS truncates to seconds); size and content are never compared. An embedder that sets validate_timestamps=0 (we did, in Moodle Playground) gets stale bytecode for every rewritten file; with the default 1, 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 through zend_compile_file, so all snippets share one cache key. Today they are protected only by opcache.file_update_protection (default 2 s: a file modified less than 2 s before the request is not stored). With file_update_protection=0 (or any sequence that lets an eval.php .bin be stored) a later run({ code }) executes a previous snippet's bytecode. Reproduced with validate_timestamps=1, file_update_protection=0 (two snippets in the same second) and with validate_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)

  1. Document SAPI selection for web runtimes: OPcache's file cache is inert under wasm on PHP ≤ 8.4 unless setSapiName() is called with a whitelisted name before the first request, and PHP 8.5 enables it automatically. Exposing sapiName on the web boot path (as the request-handler boot already does) would make this explicit.
  2. Exclude the eval path from the cache: ship an opcache.blacklist_filename with /internal/eval.php (the blacklist is honored on store in file-cache-only mode), or give each run({ code }) a unique script path, so correctness no longer depends on file_update_protection timing.
  3. Keep validate_timestamps=1 as a hard default and document that embedders must not set it to 0 with the file cache (whole-second mtime granularity).
  4. 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 in hotSwapPHPRuntime() when a clean runtime is requested — would give embedders a working primitive.
  5. Optionally extend packages/php-wasm/node/src/test/php-opcache.spec.ts to assert cache activity (status object, .bin present) 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_invalidate guards); php-src 3088d6406847 (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_module name "wasm" L988, wasm_set_sapi_name L1025) — trunk d7b19bdb.
  • Context: found while measuring Moodle Playground (ateeducacion/moodle-playground), where enabling the cache via a whitelisted SAPI name plus validate_timestamps=1 cut cold start by ~33 % and per-request PHP time from 58 to 17 ms; happy to share the full measurement package.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions