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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ where applicable.
- Validate consumed configuration content once per mutable warm request while preserving same-timestamp edits, imported resources and the explicit immutable build-ID policy.
- Boot existing installations with group-writable compiled-cache directories through a fresh private generation without executing or chmod-adopting prior PHP cache artifacts.

## Unreleased
## 1.1.3 — 2026-10-02

- Default mutable cache validation to mtime/size metadata; keep content hashing as an explicit `SYMPRESS_KERNEL_CONTENT_HASHES=1` mode. Warm container and discovery reads retain OPcache entries.
- Verify upgrade from group-writable caches without executing or modifying prior metadata, discovery or container artifacts.

### Added

Expand Down
30 changes: 30 additions & 0 deletions docs/cache-request-cost.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Cache request cost

Mutable caches use mtime/size signatures by default. Set
`SYMPRESS_KERNEL_CONTENT_HASHES=1` when detecting same-size edits with preserved
timestamps is required. Switching modes changes the cache identity. Immutable
production snapshots use `SYMPRESS_KERNEL_IMMUTABLE_CACHE=1` and a unique build ID;
every changed release input requires a changed build ID.

Run `php tools/benchmark-cache-requests.php` to measure 40 warm HTTP requests
through PHP's built-in server with OPcache enabled. The fixture has one 1 MiB PHP
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:

| 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.
21 changes: 12 additions & 9 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 content, and takes a shared
config paths from `meta.php`, 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 @@ -1009,13 +1009,15 @@ existing PHP dumps. Symlinked directories are rejected; private fallback directo
owner-checked and never admit group/world writes. Keep configured cache parents trusted and outside
DOCUMENT_ROOT/WP_CONTENT_DIR.

Normal validation hashes root/package Composer metadata and consumed config contents,
including imported files, so equal size/timestamp edits invalidate correctly. Top-level
configuration is no longer hashed again in the deployment identity. Missing recorded
config digests force a rebuild. This reduces work on each real warm request while
retaining content checks across requests; a stat-only digest cache could miss same-timestamp edits.
Recorded config directory entries detect newly added/deleted config files. Debug
also hashes source contents. Production source changes require a new
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 and do not invalidate OPcache entries; publication invalidates
only files that were written. 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
config directory entries detect newly added/deleted config files. Debug tracks source
metadata by default. Production source changes require a new
`SYMPRESS_KERNEL_BUILD_ID`, a cache clear, or optional
`SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES=1` (mtime/size source checks).

Expand Down Expand Up @@ -1196,7 +1198,8 @@ Useful debug points:

Compiler-provided `DirectoryResource` inputs (for example external ORM entity
paths) participate in mutable freshness checks and generated class identity.
Recursive PHP resources hash contents, additions and removals in stable order;
Recursive PHP resources compare file metadata, additions and removals in stable order
(or content hashes with `SYMPRESS_KERNEL_CONTENT_HASHES=1`);
child symlink files/directories are not followed outside the configured root.
A cold build may compile twice when compiler-added resources change its initial
identity. The second builder is fresh, uses the final `kernel.container_class`,
Expand Down
3 changes: 2 additions & 1 deletion src/Discovery/BundleDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use SymPress\Kernel\Bundle\BundleInterface;
use SymPress\Kernel\Bundle\BundleMetadata;
use SymPress\Kernel\Bundle\BundleRegistry;
use SymPress\Kernel\Kernel\ResourceFingerprint;
use SymPress\Kernel\Resolver\ActivePackageResolver;
use Symfony\Component\DependencyInjection\Kernel\RequiredBundle;

Expand Down Expand Up @@ -526,7 +527,7 @@ private function composerMetadata(string $composerFile): array
'type' => $original['type'] ?? '',
'extra' => ['kernel' => array_intersect_key($kernel, array_flip(['bundle', 'entry', 'requires']))],
];
$this->metadataHashes[$composerFile] = hash('sha256', $contents);
$this->metadataHashes[$composerFile] = ResourceFingerprint::file($composerFile);

return $this->metadata[$composerFile];
}
Expand Down
12 changes: 3 additions & 9 deletions src/Discovery/KernelPackageManifestCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Composer\InstalledVersions;
use SymPress\Kernel\Kernel\CachePath;
use SymPress\Kernel\Kernel\ContainerResourceFingerprinter;
use SymPress\Kernel\Kernel\ResourceFingerprint;

final class KernelPackageManifestCache
{
Expand Down Expand Up @@ -42,9 +43,6 @@ public function read(): ?array
return null;
}
try {
if (function_exists('opcache_invalidate')) {
opcache_invalidate($file, true);
}
$metadata = require $file;
} catch (\ParseError) {
return null;
Expand Down Expand Up @@ -176,6 +174,7 @@ private function fingerprint(): string
(string) $this->projectDir,
(string) $this->environment,
implode(',', $this->packagePrefixes),
ResourceFingerprint::contentHashes() ? 'content' : 'metadata',
$this->immutable() ? 'immutable' : $this->fileFingerprint($this->rootComposerFile()),
$this->immutable() ? $this->buildIdentity() : $this->fileFingerprint($this->rootComposerLockFile()),
$this->immutable() ? 'immutable' : $this->fileFingerprint($this->installedPackagesFile()),
Expand Down Expand Up @@ -219,11 +218,6 @@ private function installedPackagesFile(): string

private function fileFingerprint(string $file): string
{
if ($file === '' || !is_file($file)) {
return 'missing';
}

clearstatcache(true, $file);
return hash_file('sha256', $file) ?: 'unreadable';
return ResourceFingerprint::file($file);
}
}
3 changes: 0 additions & 3 deletions src/Kernel/ContainerCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,6 @@ private function readMetadata(string $file): ?array
return null;
}
try {
if (function_exists('opcache_invalidate')) {
opcache_invalidate($file, true);
}
$metadata = require $file;
return is_array($metadata) ? $metadata : null;
} catch (\ParseError) {
Expand Down
12 changes: 3 additions & 9 deletions src/Kernel/ContainerResourceFingerprinter.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public function fingerprint(BundleRegistry $bundles, array $configFiles): string
$this->projectDir,
$this->environment,
(string) (int) $this->debug,
ResourceFingerprint::contentHashes() ? 'content' : 'metadata',
$this->deploymentFingerprint(),
$this->kernelFingerprint(),

Expand All @@ -42,7 +43,7 @@ public function fingerprint(BundleRegistry $bundles, array $configFiles): string
$parts[] = $this->fileFingerprint($bundle->composerFile());
}

// Consumed config contents (including imports) are validated separately by
// Consumed config signatures (including imports) are validated separately by
// configResourcesAreFresh and participate in the compiled generation key.
// Keep only discovery identity here to avoid reading each config twice on hits.
array_push($parts, ...$configFiles);
Expand Down Expand Up @@ -336,14 +337,7 @@ private function configResourceValue(string $key): string

private function fileFingerprint(string $file): string
{
if (!is_file($file)) {
return 'missing';
}

clearstatcache(true, $file);
$hash = hash_file('sha256', $file);

return is_string($hash) ? $hash : 'unreadable';
return ResourceFingerprint::file($file);
}

private function deploymentFingerprint(): string
Expand Down
33 changes: 33 additions & 0 deletions src/Kernel/ResourceFingerprint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace SymPress\Kernel\Kernel;

/** @internal */
final class ResourceFingerprint
{
public static function contentHashes(): bool
{
$value = $_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES']
?? $_ENV['SYMPRESS_KERNEL_CONTENT_HASHES']
?? getenv('SYMPRESS_KERNEL_CONTENT_HASHES');

return filter_var($value, FILTER_VALIDATE_BOOL) === true;
}

public static function file(string $file): string
{
clearstatcache(true, $file);
$stat = @stat($file);
if ($stat === false || !is_file($file)) {
return 'missing';
}
if (!self::contentHashes()) {
return sprintf('stat:%d:%d', $stat['mtime'], $stat['size']);
}
$hash = hash_file('sha256', $file);

return is_string($hash) ? 'sha256:' . $hash : 'unreadable';
}
}
8 changes: 8 additions & 0 deletions tests/Fixtures/cache-hash-probe.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace SymPress\Kernel\Kernel;

use function hash_file as native_hash_file;
use function opcache_invalidate as native_opcache_invalidate;

function hash_file(string $algorithm, string $filename, bool $binary = false): string|false
{
Expand All @@ -13,3 +14,10 @@ function hash_file(string $algorithm, string $filename, bool $binary = false): s

return native_hash_file($algorithm, $filename, $binary);
}

function opcache_invalidate(string $filename, bool $force = false): bool
{
$GLOBALS['cache_request_invalidations'] = ($GLOBALS['cache_request_invalidations'] ?? 0) + 1;

return native_opcache_invalidate($filename, $force);
}
2 changes: 1 addition & 1 deletion tests/Fixtures/cache-request.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@
$files = $kernel->configureContainer($container->builder(), $container, $bundles);
$kernel->createRuntimeContainer($container, $bundles, $files);
}
echo json_encode(['hit' => $hit, 'hashes' => $GLOBALS['cache_request_hashes'] ?? [], 'bytes' => $GLOBALS['cache_request_bytes'] ?? 0, 'milliseconds' => (hrtime(true) - $start) / 1e6, 'value' => $container->getParameter('imported.value')], JSON_THROW_ON_ERROR);
echo json_encode(['hit' => $hit, 'hashes' => $GLOBALS['cache_request_hashes'] ?? [], 'bytes' => $GLOBALS['cache_request_bytes'] ?? 0, 'invalidations' => $GLOBALS['cache_request_invalidations'] ?? 0, 'milliseconds' => (hrtime(true) - $start) / 1e6, 'value' => $container->getParameter('imported.value')], JSON_THROW_ON_ERROR);
42 changes: 40 additions & 2 deletions tests/Kernel/CacheRequestCompatibilityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use SymPress\Kernel\Bundle\BundleRegistry;
use SymPress\Kernel\Kernel\CachePath;
use SymPress\Kernel\Discovery\KernelPackageManifestCache;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;

Expand All @@ -18,20 +19,29 @@ public function testExistingGroupWritableCacheUsesFreshPrivateStorage(): void
mkdir($directory, 0775, true);
chmod($directory, 0775);
file_put_contents($directory . '/meta.php', '<?php throw new \\RuntimeException("Untrusted old cache was executed");');
file_put_contents($directory . '/discovery-packages.php', '<?php throw new \\RuntimeException("Untrusted old discovery was executed");');
file_put_contents($directory . '/container_0123456789abcdef.php', '<?php throw new \\RuntimeException("Untrusted old container was executed");');
$old = hash_file('sha256', $directory . '/meta.php');
$cache = CachePath::resolve($project, 'test');
self::assertNotSame($directory, $cache);
self::assertSame($cache, CachePath::resolve($project, 'test', $project . '/var/cache'));
$discovery = new KernelPackageManifestCache($project, 'test', ['sympress/']);
self::assertNull($discovery->read());
$discovery->write(['sympress/kernel']);
self::assertSame(['sympress/kernel'], $discovery->read());
$kernel = $this->kernel($project);
$container = $kernel->createContainer();
$files = $kernel->configureContainer($container->builder(), $container, new BundleRegistry());
$kernel->createRuntimeContainer($container, new BundleRegistry(), $files);
self::assertTrue($kernel->tryUseRuntimeContainer($kernel->createContainer(), new BundleRegistry()));
self::assertSame(0700, fileperms($cache) & 0777);
self::assertSame(0775, fileperms($directory) & 0777);
self::assertSame($old, hash_file('sha256', $directory . '/meta.php'));
self::assertSame(0600, fileperms($cache . '/discovery-packages.php') & 0777);
(new Filesystem())->remove($cache);
}

public function testSuccessiveRequestsHashConfigOnceAndDetectSameTimestampReplacement(): void
public function testSuccessiveRequestsUseMetadataWithoutHashesOrOpcacheInvalidation(): void
{
$project = $this->tmpPath('request-hashes');
mkdir($project . '/config', 0700, true);
Expand All @@ -46,13 +56,41 @@ public function testSuccessiveRequestsHashConfigOnceAndDetectSameTimestampReplac
foreach (range(1, 2) as $unused) {
$request = $run();
self::assertTrue($request['hit']);
self::assertSame(1, $request['hashes'][$file]);
self::assertSame([], $request['hashes']);
self::assertSame(0, $request['bytes']);
self::assertSame(0, $request['invalidations']);
}
$stamp = filemtime($file);
$this->writeConfig($file . '.new', 'other');
touch($file . '.new', $stamp);
rename($file . '.new', $file);
$request = $run();
self::assertTrue($request['hit']);
self::assertSame('first', $request['value']);
touch($file, $stamp + 1);
$request = $run();
self::assertFalse($request['hit']);
self::assertSame('other', $request['value']);
}

public function testExplicitContentModeDetectsSameSizeSameTimestampReplacement(): void
{
$project = $this->tmpPath('request-content-hashes');
mkdir($project . '/config', 0700, true);
$file = $project . '/config/services.php';
$this->writeConfig($file, 'first');
$run = static function () use ($project): array {
$process = new Process([PHP_BINARY, dirname(__DIR__) . '/Fixtures/cache-request.php', $project], env: ['SYMPRESS_KERNEL_CONTENT_HASHES' => '1']);
$process->mustRun();

return json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
};
self::assertFalse($run()['hit']);
self::assertTrue($run()['hit']);
$stamp = filemtime($file);
$this->writeConfig($file, 'other');
touch($file, $stamp);
$request = $run();
self::assertFalse($request['hit']);
self::assertSame('other', $request['value']);
}
Expand Down
1 change: 1 addition & 0 deletions tests/Kernel/KernelTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ protected function tearDown(): void
$_ENV['DB_PASSWORD'],
$_ENV['WP_AUTH_KEY'],
$_SERVER['SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES'],
$_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES'],
$GLOBALS['kernel_test_do_actions'],
$GLOBALS['kernel_test_filter_values'],
);
Expand Down
7 changes: 6 additions & 1 deletion tests/Kernel/RuntimeCacheCompatibilityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use SymPress\Kernel\Bundle\BundleRegistry;
use SymPress\Kernel\Kernel\CachePath;
use SymPress\Kernel\Kernel\ContainerResourceFingerprinter;
use SymPress\Kernel\Kernel\ResourceFingerprint;
use SymPress\Kernel\Kernel\SiteKernel;
use SymPress\Kernel\Kernel\KernelConfigurationResolver;
use SymPress\Kernel\Tests\Support\TestSiteConfig;
Expand All @@ -28,6 +29,7 @@ final class RuntimeCacheCompatibilityTest extends KernelTestCase
{
public function testRecursiveDirectoryResourcesTrackContentsAdditionRemovalAndIgnoreSymlinkEscapes(): void
{
$_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES'] = '1';
$root = $this->tmpPath('external-resources');
mkdir($root, 0700, true);
$file = $root . '/mapped.php';
Expand Down Expand Up @@ -170,6 +172,7 @@ public function testRuntimeCacheInvalidatesWhenImportedConfigFileChanges(): void

public function testDebugHitSkipsConfigExecutionAndDetectsSameTimestampEdits(): void
{
$_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES'] = '1';
$project = $this->tmpPath('debug-cache');
mkdir($project . '/config', 0700, true);
$file = $project . '/config/services.php';
Expand All @@ -196,6 +199,7 @@ public function testDebugHitSkipsConfigExecutionAndDetectsSameTimestampEdits():

public function testImportedSameTimestampEditRebuildsActualParameter(): void
{
$_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES'] = '1';
$project = $this->tmpPath('import-content');
mkdir($project . '/config', 0700, true);
$this->writeImportingConfig($project . '/config/services.php', 'first');
Expand Down Expand Up @@ -453,6 +457,7 @@ public function testRuntimeCompilationPreservesSourceServiceLocatorGraphForLint(

public function testPackageDescriptorsReuseAcrossRequestsAndInvalidateConsumedContent(): void
{
$_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES'] = '1';
$project = $this->tmpPath('descriptor-cache');
mkdir($project, 0700);
file_put_contents($project . '/composer.json', '{}');
Expand All @@ -461,7 +466,7 @@ public function testPackageDescriptorsReuseAcrossRequestsAndInvalidateConsumedCo
$stamp = filemtime($input);
$cache = new KernelPackageManifestCache($project, 'test', ['sympress/']);
$descriptors = [$input => ['name' => 'sympress/one', 'type' => 'library', 'extra' => ['kernel' => ['bundle' => 'Example']]]];
$cache->write(['sympress/one'], $descriptors, [$input => hash_file('sha256', $input)]);
$cache->write(['sympress/one'], $descriptors, [$input => ResourceFingerprint::file($input)]);
$nextRequest = new KernelPackageManifestCache($project, 'test', ['sympress/']);
self::assertSame(['sympress/one'], $nextRequest->read());
self::assertSame($descriptors, $nextRequest->metadata());
Expand Down
Loading
Loading