Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
where applicable.

## Unreleased
## 1.1.5 — 2026-10-05

- Store container metadata and discovery descriptors as atomic private JSON, so unchanged readers retain OPcache entries while CLI publications remain visible to existing FPM workers with timestamp validation disabled. Ignore and rebuild legacy PHP metadata without executing it.
- Restore automatic private-cache fallback outside served directories for classic WordPress webroots and cold read-only projects; reject pre-created symlinks, foreign owners and writable shared cache roots.

## 1.1.4 — 2026-10-03

- Fail unsafe explicit APP_CACHE_DIR settings with deployment guidance and migrate implicit legacy caches once into visible durable project storage shared by CLI/PHP-FPM.
- Refresh mutable metadata/discovery OPcache reads after atomic replacement while retaining immutable build-ID cache behavior.
Expand Down
30 changes: 15 additions & 15 deletions docs/cache-request-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,19 @@ configuration file and runs the real container cache hit path. The probe counts
content-hash bytes and OPcache invalidations. The server and fixture are removed
afterward; no customer application or database is touched.

The before/after run on PHP 8.5.9, 2026-10-02, produced:
The 2026-10-05 PHP 8.5.9 regression checks record zero content-hash bytes and zero
OPcache invalidations on unchanged mutable container reads. Discovery readers
also perform zero invalidations. Metadata is now JSON; compiled containers retain
generation-specific PHP filenames. A native fixture keeps the same FPM worker
alive with `opcache.validate_timestamps=0`, updates container and discovery data
from separate CLI writers, and verifies that subsequent requests select the new
generation without forcing metadata invalidation.

| Measurement | Previous content default | Metadata default |
| --- | ---: | ---: |
| Warm hits | 40/40 | 40/40 |
| Hashed bytes per request | 1,288,952 | 0 |
| OPcache invalidations per request | 1 | 0 |
| Cache selection p50 / p95, ms | 1.364 / 1.648 | 0.942 / 1.006 |
| HTTP p50 / p95, ms | 1.876 / 2.518 | 1.379 / 1.439 |

These are local fixture timings, not WordPress response-time guarantees. Input
size, filesystems, PHP configuration and concurrent load affect latency.
`CacheRequestCompatibilityTest` verifies zero warm hashing/invalidation, timestamp
invalidation, explicit content-mode detection and safe migration of group-writable
cache artifacts. The metadata mode intentionally cannot detect an edit preserving
both size and mtime; clear the cache for such edits.
No timing benchmark was rerun for 1.1.5. The earlier 2026-10-02 candidate timings
did not describe the reader invalidation introduced in 1.1.4 and are not evidence
of current response latency. Filesystems, PHP configuration and concurrent load
affect latency; the fixture is not a WordPress response-time guarantee.
`CacheRequestCompatibilityTest` covers timestamp invalidation, explicit
content-mode detection, legacy PHP metadata migration, classic/read-only default
paths and safe migration of group-writable cache artifacts. Metadata mode cannot
detect an edit preserving both size and mtime; clear the cache for such edits.
35 changes: 22 additions & 13 deletions docs/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -990,7 +990,7 @@ wp console container:dump --format=yaml
## Runtime Cache

Both debug and production use compiled-container cache hits. A hit loads recorded
config paths from `meta.php`, validates consumed input signatures, and takes a shared
config paths from `meta.json`, validates consumed input signatures, and takes a shared
reader lock; it does not execute config loaders or rebuild discovery globs. Only
misses take the exclusive writer lock and recheck after waiting. Writer metadata
and container publication are atomic, private files use mode 0600, and newly
Expand All @@ -1004,21 +1004,29 @@ project. The default is `var/cache/<environment>/kernel`. Existing read-only war
caches can still be read. An implicit legacy group/world-writable cache migrates to
durable project storage at `var/cache-private-<uid>/<environment>/kernel` without
executing or modifying old PHP dumps; creation logs the selected path once. This
path is shared by CLI and PHP-FPM even with systemd PrivateTmp. An explicit unsafe
`APP_CACHE_DIR`, a web-exposed default or an unwritable cold project fails with an
actionable diagnostic instead of silently selecting another cache. Symlinked
directories are rejected; private migration directories are owner-checked and
never admit group/world writes. Provision an explicit durable private cache with
path is shared by CLI and PHP-FPM even with systemd PrivateTmp. If the project is
the webroot or a cold project cannot be written, the implicit cache uses a
project-specific, owner-checked directory under the system temporary directory,
outside DOCUMENT_ROOT/WP_CONTENT_DIR. Pre-created symlinks, other owners and
group/world-writable directories are rejected. This fallback restores automatic
boot for classic and read-only WordPress projects; it is not a shared deployment
cache when CLI and FPM have different users or PrivateTmp namespaces. An explicit
unsafe `APP_CACHE_DIR` still fails with an actionable diagnostic. Provision an
explicit durable private cache with
the same CLI/PHP-FPM identity and mode 0700, run production cache warmup and
`lint:container` under that identity, then verify a request before switching traffic.
Keep configured cache parents trusted and outside DOCUMENT_ROOT/WP_CONTENT_DIR.

Normal validation compares modification time and size for root/package Composer
metadata and consumed configuration, including imports. Warm requests do not read
these file contents. Mutable metadata/discovery reads invalidate their PHP OPcache
entries before loading, so atomic replacement remains visible with timestamp
validation disabled; immutable build-ID policy retains those entries. Publication
also invalidates files that were written. Missing recorded config signatures force a rebuild.
these file contents. Container metadata and discovery descriptors are atomic JSON
files (`meta.json` and `discovery-packages.json`), so their replacement by a CLI
writer is visible to existing FPM workers even with OPcache timestamp validation
disabled. Unchanged mutable and immutable readers do not invalidate OPcache.
Compiled containers remain PHP files with generation-specific names; writers
invalidate only the compiled PHP file they publish. Legacy `meta.php` and
`discovery-packages.php` are ignored and rebuilt once without executing them.
Missing recorded config signatures force a rebuild.
Edits preserving both size and modification time require a cache clear or explicit
`SYMPRESS_KERNEL_CONTENT_HASHES=1`. That opt-in mode hashes consumed inputs and debug
source files, detects those edits, and has a distinct cache identity. Recorded
Expand All @@ -1044,8 +1052,9 @@ its boot state and rethrows, without serving a stale container. The kernel error
hook remains available and the built-in diagnostic logs class/location and a
correlation identifier, excluding raw exception messages. The WordPress/PHP
boundary determines the failure response (normally HTTP 500); operators can map
it to a maintenance 503/retry policy. A readonly cache miss requires a writable
private cache or explicit deployment warmup. Configure production PHP to hide
it to a maintenance 503/retry policy. An explicit read-only cache miss requires
deployment warmup or a writable private cache; implicit cold paths may use the
private fallback described above. Configure production PHP to hide
exception text and monitor failed readiness before switching traffic.

## Environment Parameters
Expand Down Expand Up @@ -1138,7 +1147,7 @@ Useful debug points:

- `kernel.container_configured`, to inspect loaded configuration files.
- `kernel.container_ready`, to inspect the runtime container.
- `var/cache/<env>/kernel/meta.php`, to inspect fingerprint, class, and cache
- `var/cache/<env>/kernel/meta.json`, to inspect fingerprint, class, and cache
file metadata.
- The showcase plugin screen for attributes, locators, tags, and lazy services.

Expand Down
33 changes: 13 additions & 20 deletions src/Discovery/KernelPackageManifestCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,9 @@ public function read(): ?array
return null;
}
try {
if (!$this->immutable() && function_exists('opcache_invalidate')) {
opcache_invalidate($file, true);
}
$metadata = require $file;
} catch (\ParseError) {
$contents = file_get_contents($file);
$metadata = $contents === false ? null : json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}

Expand Down Expand Up @@ -120,17 +118,14 @@ public function write(array $packages, array $metadata = [], array $inputs = [])
}

sort($packages);
$payload = sprintf(
"<?php\n\nreturn %s;\n",
var_export(
[
'fingerprint' => $this->fingerprint(),
'packages' => array_values(array_unique($packages)),
'metadata' => $metadata,
'inputs' => $inputs,
],
true,
),
$payload = json_encode(
[
'fingerprint' => $this->fingerprint(),
'packages' => array_values(array_unique($packages)),
'metadata' => $metadata,
'inputs' => $inputs,
],
JSON_THROW_ON_ERROR,
);
$temporaryFile = sprintf('%s.%s.tmp', $file, bin2hex(random_bytes(6)));

Expand All @@ -140,9 +135,7 @@ public function write(array $packages, array $metadata = [], array $inputs = [])
return;
}
chmod($temporaryFile, 0600);
if (@rename($temporaryFile, $file) && function_exists('opcache_invalidate')) {
opcache_invalidate($file, true);
}
@rename($temporaryFile, $file);
} finally {
umask($previousMask);
if (is_file($temporaryFile)) {
Expand All @@ -164,7 +157,7 @@ private function cacheFile(): ?string
}

$configured = $_SERVER['APP_CACHE_DIR'] ?? $_ENV['APP_CACHE_DIR'] ?? getenv('APP_CACHE_DIR');
return CachePath::resolve($this->projectDir, $environment, is_string($configured) && $configured !== '' ? $configured : null) . '/discovery-packages.php';
return CachePath::resolve($this->projectDir, $environment, is_string($configured) && $configured !== '' ? $configured : null) . '/discovery-packages.json';
}

private function fingerprint(): string
Expand Down
60 changes: 50 additions & 10 deletions src/Kernel/CachePath.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static function resolve(string $project, string $environment, ?string $co
}
return self::privateFallback($project, $environment);
}
if ($configured === null && !is_file($path . '/meta.php') && !self::writableAncestor($path)) {
if ($configured === null && !is_file($path . '/meta.json') && !self::writableAncestor($path)) {
return self::privateFallback($project, $environment);
}
return $path;
Expand Down Expand Up @@ -73,20 +73,27 @@ public static function canonical(string $path): string

private static function privateFallback(string $project, string $environment): string
{
$user = function_exists('posix_geteuid') ? (string) posix_geteuid() : hash('sha256', get_current_user());
$owner = self::currentOwner();
$user = (string) $owner;
$root = $project . '/var/cache-private-' . $user;
$resolved = self::canonical($root);
foreach ([$_SERVER['DOCUMENT_ROOT'] ?? null, defined('WP_CONTENT_DIR') ? constant('WP_CONTENT_DIR') : null] as $public) {
if (is_string($public) && $public !== '' && ($resolved === self::canonical($public) || str_starts_with($resolved, rtrim(self::canonical($public), '/') . '/'))) {
throw new \RuntimeException('No private kernel cache is available outside the webroot. Set APP_CACHE_DIR to a durable private directory and warm it as the PHP-FPM user.');
if (self::isPublic($root) || !self::writableAncestor($root)) {
// A cold read-only project or classic WordPress docroot still boots.
// A shared temporary parent is safe only with a private, owned root:
// never follow or adopt a pre-created symlink or another user's tree.
$temporary = self::canonical(sys_get_temp_dir());
$permissions = fileperms($temporary);
if ($permissions === false || (($permissions & 0022) !== 0 && ($permissions & 01000) === 0)) {
throw new \RuntimeException('A private kernel cache requires a trusted temporary parent or APP_CACHE_DIR; shared temporary directories must have the sticky bit.');
}
$root = rtrim($temporary, '/') . '/sympress-kernel-' . $user . '-'
. substr(hash('sha256', self::canonical($project)), 0, 24);
}
$created = !is_dir($root);
if ($created && !self::writableAncestor($root)) {
throw new \RuntimeException('The private kernel cache cannot be created. Set APP_CACHE_DIR to a durable directory shared by CLI and PHP-FPM, create it with mode 0700, and warm it as the PHP-FPM user.');
if (self::isPublic($root)) {
throw new \RuntimeException('No private kernel cache is available outside the webroot. Set APP_CACHE_DIR to a durable private directory and warm it as the PHP-FPM user.');
}
$created = !is_dir($root);
self::ensureDirectory($root);
if (function_exists('posix_geteuid') && fileowner($root) !== posix_geteuid()) {
if (fileowner($root) !== $owner) {
throw new \RuntimeException('The private kernel cache root belongs to another user; configure APP_CACHE_DIR for the PHP-FPM identity.');
}
if ($created) {
Expand All @@ -95,6 +102,39 @@ private static function privateFallback(string $project, string $environment): s
return $root . '/' . $environment . '/kernel';
}

private static function isPublic(string $path): bool
{
$resolved = self::canonical($path);
foreach ([$_SERVER['DOCUMENT_ROOT'] ?? null, defined('WP_CONTENT_DIR') ? constant('WP_CONTENT_DIR') : null] as $public) {
if (is_string($public) && $public !== '' && ($resolved === self::canonical($public) || str_starts_with($resolved, rtrim(self::canonical($public), '/') . '/'))) {
return true;
}
}
return false;
}

private static function currentOwner(): int
{
if (function_exists('posix_geteuid')) {
return posix_geteuid();
}
// get_current_user() identifies the script owner, not the process user.
// The owner of a file we create remains reliable without ext-posix.
$probe = tmpfile();
if (!is_resource($probe)) {
throw new \RuntimeException('Unable to verify private cache ownership; configure APP_CACHE_DIR.');
}
try {
$status = fstat($probe);
if ($status === false) {
throw new \RuntimeException('Unable to verify private cache ownership; configure APP_CACHE_DIR.');
}
return $status['uid'];
} finally {
fclose($probe);
}
}

private static function writableAncestor(string $path): bool
{
while (!file_exists($path)) {
Expand Down
24 changes: 9 additions & 15 deletions src/Kernel/ContainerCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function tryUseRuntimeContainer(
array $runtimeConfigFiles,
): bool {

$metaFile = sprintf('%s/meta.php', $this->cacheDir);
$metaFile = sprintf('%s/meta.json', $this->cacheDir);
$lockFile = sprintf('%s/container.lock', $this->cacheDir);
if (
!is_file($metaFile) || !is_file($lockFile) || is_link($metaFile) || is_link($lockFile)
Expand Down Expand Up @@ -80,7 +80,7 @@ public function createRuntimeContainer(

$filesystem = new Filesystem();
CachePath::ensureDirectory($this->cacheDir);
$metaFile = sprintf('%s/meta.php', $this->cacheDir);
$metaFile = sprintf('%s/meta.json', $this->cacheDir);
$lockFile = sprintf('%s/container.lock', $this->cacheDir);
$fingerprint = $this->fingerprints->fingerprint($bundles, $configFiles);
if (is_link($lockFile)) {
Expand Down Expand Up @@ -166,10 +166,8 @@ public function createRuntimeContainer(
chmod($containerFile, 0600);
$filesystem->dumpFile(
$metaFile,
sprintf(
"<?php\n\nreturn %s;\n",
var_export(
[
json_encode(
[
'fingerprint' => $fingerprint,
'runtime_config_files' => $configFiles,
'config_discovery' => $this->fingerprints->discoveryResourceManifest($configDirectories),
Expand All @@ -178,15 +176,13 @@ public function createRuntimeContainer(
'source_resources' => $sourceResources,
'class' => $class,
'file' => basename($containerFile),
],
true,
),
],
JSON_THROW_ON_ERROR,
),
);

chmod($metaFile, 0600);
if (function_exists('opcache_invalidate')) {
opcache_invalidate($metaFile, true);
opcache_invalidate($containerFile, true);
}
} finally {
Expand All @@ -212,12 +208,10 @@ private function readMetadata(string $file): ?array
return null;
}
try {
if (!$this->fingerprints->immutable() && function_exists('opcache_invalidate')) {
opcache_invalidate($file, true);
}
$metadata = require $file;
$contents = file_get_contents($file);
$metadata = $contents === false ? null : json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
return is_array($metadata) ? $metadata : null;
} catch (\ParseError) {
} catch (\JsonException) {
return null;
}
}
Expand Down
27 changes: 27 additions & 0 deletions tests/Discovery/KernelPackageManifestCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PHPUnit\Framework\TestCase;
use SymPress\Kernel\Discovery\KernelPackageManifestCache;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;

final class KernelPackageManifestCacheTest extends TestCase
{
Expand Down Expand Up @@ -41,6 +42,32 @@ public function testManifestRoundTripsUntilComposerMetadataChanges(): void
self::assertNull($cache->read());
}

public function testUnchangedManifestReadersDoNotInvalidateOpcache(): void
{
$project = $this->tmpPath('discovery-reader-cost');
$code = <<<'PHP'
namespace SymPress\Kernel\Discovery {
function opcache_invalidate(string $filename, bool $force = false): bool {
$GLOBALS['reader_invalidations']++;
return \opcache_invalidate($filename, $force);
}
}
namespace {
require $argv[1];
$cache = new SymPress\Kernel\Discovery\KernelPackageManifestCache($argv[2], 'production', ['sympress/']);
$GLOBALS['reader_invalidations'] = 0;
$cache->write(['sympress/kernel']);
$GLOBALS['reader_invalidations'] = 0;
$first = $cache->read();
$second = $cache->read();
echo json_encode([$first, $second, $GLOBALS['reader_invalidations']], JSON_THROW_ON_ERROR);
}
PHP;
$process = new Process([PHP_BINARY, '-r', $code, dirname(__DIR__, 2) . '/vendor/autoload.php', $project]);
$process->mustRun();
self::assertSame([['sympress/kernel'], ['sympress/kernel'], 0], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR));
}

private function tmpPath(string $prefix): string
{
$path = sprintf('%s/%s-%s', sys_get_temp_dir(), $prefix, uniqid('', true));
Expand Down
Loading
Loading