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: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 1.2.4 — 2026-10-05

- Keep compiled environment caches and immutable dumps in OPcache on reads. Invalidate only a mutable cache actually rewritten by this process; unchanged writes do not waste shared memory.
- Include the installed Runtime version in generated MU loader and kernel plugin headers for security inventories.
- Production doctor accepts sealed, readable project-owned immutable caches when run as PHP-FPM. Mutable maintenance, external roots, symlinks and group/world write protection retain their ownership boundaries.
- Verify 8,000 warm OPcache reads without a reset and complete two SSH/MariaDB/FPM deployments with the actual PHP identity.

## 1.2.3 — 2026-10-05

- Preserve existing outbound WordPress HTTP integrations in 1.x: production and staging no longer enable `WP_HTTP_BLOCK_EXTERNAL` automatically. Explicit environment/constant opt-in retains operator host overrides and the Composer-managed core-host fallback.
Expand Down
20 changes: 20 additions & 0 deletions docs/releases/1.2.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Runtime 1.2.4

Update with `composer update sympress/runtime --with-all-dependencies`, commit
the lockfile and regenerate release artifacts with `vendor/bin/runtime` and
`vendor/bin/runtime dump-env production`.

Environment cache reads no longer invalidate OPcache. Only a successful changed
mutable cache write invalidates that file; immutable dumps and identical writes
remain compiled. The regression fixture performs 8,000 warm reads with a 16 MB
OPcache and timestamp validation disabled, then verifies changed-cache refresh.

Generated MU loader and kernel headers contain the installed Runtime version.
Regeneration resolves the missing-version inventory finding. This does not add
the private Security package or require its vulnerability gate in any project.

Doctor can run as PHP-FPM against a warmed immutable release owned by the deploy
user. The cache must be inside the project, owned by its release owner, readable
and unwritable by PHP-FPM, with an explicit immutable flag and build ID. External
cache roots and mutable cache maintenance still require the calling owner.
Group/world-writable directories and symlinks remain failures.
2 changes: 1 addition & 1 deletion src/Console/Doctor.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public function inspect(bool $production = false, bool $databaseHealth = false,
}
try {
$path = $id === 'cache' ? $kernel->cache() : $kernel->build();
$kernel->assertSafe($path, $id);
$kernel->assertSafe($path, $id, readOnly: true);
$kernelDirectories['kernel-' . $id] = $path;
$record('kernel.' . $id, is_dir($path) && is_readable($path) ? 'pass' : 'unknown', is_dir($path) ? 'Selected kernel directory is readable.' : 'Selected kernel directory has not been created; run cache warmup as the PHP-FPM user.');
} catch (Throwable) {
Expand Down
3 changes: 0 additions & 3 deletions src/Env/EnvCacheFormat.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ public static function read(string $file, bool $dataOnly = false): array
}
$data = (new EnvCacheLiteral())->decode($source);
}
if (!$dataOnly && function_exists('opcache_invalidate')) {
opcache_invalidate($file, true);
}
$data = $dataOnly ? $data : (static fn (string $path): mixed => require $path)($file);
} catch (Throwable) {
throw new RuntimeException('Cannot read environment cache.');
Expand Down
2 changes: 1 addition & 1 deletion src/Env/EnvReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ public function dumpCached(string $file, bool $immutable = false): bool
return false;
}

return SecureFileWriter::write($file, EnvCacheFormat::encode($payload), $this->fileMode);
return SecureFileWriter::write($file, EnvCacheFormat::encode($payload), $this->fileMode, invalidateOpcache: !$immutable);
}

/** @internal */
Expand Down
9 changes: 7 additions & 2 deletions src/Env/SecureFileWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public static function canWrite(string $file): bool
return !is_link($file) && !is_dir($file);
}

public static function write(string $file, string $content, int $mode = 0600): bool
public static function write(string $file, string $content, int $mode = 0600, bool $invalidateOpcache = false): bool
{
if (!in_array($mode, [0600, 0640], true)) {
throw new InvalidArgumentException('Generated file mode must be 0600 or 0640.');
Expand Down Expand Up @@ -60,9 +60,14 @@ public static function write(string $file, string $content, int $mode = 0600): b
return false;
}

return @chmod($temporary, $mode)
$written = @chmod($temporary, $mode)
&& @file_put_contents($temporary, $content) === strlen($content)
&& @rename($temporary, $file);
if ($written && $invalidateOpcache && function_exists('opcache_invalidate')) {
opcache_invalidate($file, true);
}

return $written;
} finally {
if (is_file($temporary)) {
unlink($temporary);
Expand Down
8 changes: 4 additions & 4 deletions src/Kernel/CacheLocation.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ public static function fallbackRoot(string $project, array $publicRoots = []): s
*
* @param list<string> $publicRoots
*/
public static function assertTarget(string $path, string $root, bool $external, array $publicRoots = [], bool $implicit = false): void
public static function assertTarget(string $path, string $root, bool $external, array $publicRoots = [], bool $implicit = false, ?int $readOnlyOwner = null): void
{
if (!Path::isBasePath($root, $path) || self::isPublic($path, $publicRoots)) {
throw new RuntimeException('Kernel cache target must remain inside its private cache root and outside the webroot.');
}
$ancestor = $path;
while (true) {
self::assertDirectory($ancestor);
self::assertDirectory($ancestor, $external ? null : $readOnlyOwner);
if ($ancestor === $root) {
break;
}
Expand All @@ -94,7 +94,7 @@ public static function assertTarget(string $path, string $root, bool $external,
}
}

private static function assertDirectory(string $directory): void
private static function assertDirectory(string $directory, ?int $readOnlyOwner = null): void
{
if (is_link($directory)) {
throw new RuntimeException('Refusing a symlinked kernel cache directory.');
Expand All @@ -105,7 +105,7 @@ private static function assertDirectory(string $directory): void
if (!is_dir($directory) || (fileperms($directory) & 0022) !== 0) {
throw new RuntimeException('Kernel cache directories must be private and not group/world writable; create them as the PHP-FPM user and warm the cache as that user.');
}
if (fileowner($directory) !== self::currentOwner()) {
if (fileowner($directory) !== self::currentOwner() && !(fileowner($directory) === $readOnlyOwner && is_readable($directory) && !is_writable($directory))) {
throw new RuntimeException('The kernel cache directory belongs to another user. Configure APP_CACHE_DIR for the PHP-FPM identity and warm it as that user.');
}
}
Expand Down
8 changes: 6 additions & 2 deletions src/Kernel/KernelPaths.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function usesFallback(): bool
return $this->configured('APP_CACHE_DIR') === null && $this->cache() !== $this->paths->root('var/cache/' . $this->environment() . '/kernel');
}

public function assertSafe(string $path, string $kind): void
public function assertSafe(string $path, string $kind, bool $readOnly = false): void
{
$configured = $this->configured($kind === 'build' ? 'APP_BUILD_DIR' : 'APP_CACHE_DIR');
if ($kind === 'build' && $configured === null) {
Expand All @@ -61,7 +61,11 @@ public function assertSafe(string $path, string $kind): void
if (!$external) {
(new ProjectBoundary($this->paths))->assertWritablePath($path);
}
CacheLocation::assertTarget($path, $root, $external, $this->publicRoots(), $implicit);
$immutable = filter_var($this->env->rawValue('SYMPRESS_KERNEL_IMMUTABLE_CACHE'), FILTER_VALIDATE_BOOLEAN);
$buildId = $this->env->rawValue('SYMPRESS_KERNEL_BUILD_ID');
$owner = $readOnly && !$external && !$implicit && $immutable && is_string($buildId) && preg_match('/\A[A-Za-z0-9._-]{1,128}\z/', $buildId) === 1
? fileowner($this->paths->root()) : false;
CacheLocation::assertTarget($path, $root, $external, $this->publicRoots(), $implicit, $owner === false ? null : $owner);
}

public function buildIdFile(): string
Expand Down
12 changes: 12 additions & 0 deletions src/Package/PackageFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace SymPress\Runtime\Package;

use Composer\InstalledVersions;
use RuntimeException;
use SymPress\Runtime\Application\RunContext;
use SymPress\Runtime\Config\ConfigLoader;
Expand Down Expand Up @@ -49,6 +50,17 @@ public function findPathOf(Package $package): string
return $package->getInstallPath();
}

public function runtimeVersion(): string
{
$version = $this->findByName('sympress/runtime')?->getPrettyVersion();
if ($version === null && InstalledVersions::isInstalled('sympress/runtime')) {
$version = InstalledVersions::getPrettyVersion('sympress/runtime');
}

return is_string($version) && preg_match('/\A[vV]?[0-9]+\.[0-9]+\.[0-9]+(?:[.+-][a-zA-Z0-9.+-]+)?\z/', $version) === 1
? ltrim($version, 'vV') : 'dev';
}

/** @return list<Package> */
public function search(string $name): array
{
Expand Down
2 changes: 1 addition & 1 deletion src/Step/Builtin/KernelBootStep.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public function run(Config $config, Paths $paths): int
}
$root = var_export(Path::makeRelative($paths->root(), dirname($file)), true);
$autoload = var_export(Path::makeRelative($paths->vendor('autoload.php'), dirname($file)), true);
$source = "<?php\n// " . OverwritePolicy::MARKER . " kernel-boot:v1\n/** Plugin Name: SymPress Runtime Kernel */\n";
$source = "<?php\n// " . OverwritePolicy::MARKER . " kernel-boot:v1\n/**\n * Plugin Name: SymPress Runtime Kernel\n * Version: " . $this->packages->runtimeVersion() . "\n */\n";
$source .= "if (!defined('ABSPATH')) { return; }\nrequire_once __DIR__ . '/' . " . $autoload . ";\n";
$source .= "if (\\SymPress\\Kernel\\App::kernel() === null) {\n \\SymPress\\Kernel\\App::bootKernel(new \\SymPress\\Kernel\\Kernel\\SiteKernel(__DIR__ . '/' . " . $root . "));\n}\n";

Expand Down
4 changes: 3 additions & 1 deletion src/Step/Builtin/MuLoaderStep.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use SymPress\Runtime\Filesystem\ProjectBoundary;
use SymPress\Runtime\Generation\ArtifactWriter;
use SymPress\Runtime\Package\MuPluginList;
use SymPress\Runtime\Package\PackageFinder;
use SymPress\Runtime\Step\ConditionalStepInterface;
use SymPress\Runtime\Step\FileCreationStepInterface;
use Symfony\Component\Filesystem\Path;
Expand All @@ -21,7 +22,7 @@ final class MuLoaderStep implements FileCreationStepInterface, ConditionalStepIn
/** @var array<string, string> */
private array $plugins = [];

public function __construct(private readonly MuPluginList $list, private readonly FileContentBuilder $builder, private readonly ArtifactWriter $writer)
public function __construct(private readonly MuPluginList $list, private readonly FileContentBuilder $builder, private readonly ArtifactWriter $writer, private readonly PackageFinder $packages)
{
}

Expand Down Expand Up @@ -52,6 +53,7 @@ public function run(Config $config, Paths $paths): int
$template = 'wpstarter-mu-loader.php';
}
$content = $this->builder->build($paths, $template, [
'RUNTIME_VERSION' => $this->packages->runtimeVersion(),
'MU_PLUGINS_ARRAY' => var_export($relative, true),
'MU_NATIVE' => var_export($config['compatibility-profile']->is('native'), true),
'MU_PLUGINS_LIST' => str_replace(['\\', "'"], ['\\\\', "\\'"], implode(', ', $relative)),
Expand Down
1 change: 1 addition & 0 deletions templates/sympress-runtime-mu-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// @generated by sympress/runtime mu-loader:v1
/**
* Plugin Name: SymPress Runtime MU loader
* Version: {{{RUNTIME_VERSION}}}
* Description: Loads the discovered Composer and local MU plugin entry points.
*/

Expand Down
63 changes: 63 additions & 0 deletions tests/Integration/EnvCacheOpcacheTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace SymPress\Runtime\Tests\Integration;

use SymPress\Runtime\Tests\Support\TemporaryProject;
use Symfony\Component\Process\Process;

final class EnvCacheOpcacheTest extends TemporaryProject
{
public function testWarmReadsDoNotWasteOpcacheAndChangedMutableWritesRefreshIt(): void
{
$this->write('probe.php', <<<'PHP'
<?php
require $argv[1];
use SymPress\Runtime\Env\EnvCacheFormat;
use SymPress\Runtime\Env\SecureFileWriter;
$first = EnvCacheFormat::encode(['format' => 1, 'value' => 'first']);
foreach (['cache.php', 'dump.php'] as $file) {
SecureFileWriter::write($file, $first);
if (!opcache_compile_file($file)) {
throw new RuntimeException('OPcache fixture must be enabled.');
}
}
$before = opcache_get_status(false);
for ($i = 0; $i < 4000; ++$i) {
foreach (['cache.php', 'dump.php'] as $file) {
if (EnvCacheFormat::read($file)['value'] !== 'first') {
throw new RuntimeException('Warm environment value changed.');
}
}
}
$after = opcache_get_status(false);
$second = EnvCacheFormat::encode(['format' => 1, 'value' => 'second']);
SecureFileWriter::write('cache.php', $second, invalidateOpcache: true);
$refreshed = EnvCacheFormat::read('cache.php')['value'];
$written = opcache_get_status(false);
for ($i = 0; $i < 100; ++$i) {
SecureFileWriter::write('cache.php', $second, invalidateOpcache: true);
EnvCacheFormat::read('cache.php');
}
$unchanged = opcache_get_status(false);
echo json_encode([$before, $after, $refreshed, $written, $unchanged]);
PHP);
$process = new Process([
PHP_BINARY, '-d', 'opcache.enable_cli=1', '-d', 'opcache.validate_timestamps=0',
'-d', 'opcache.file_update_protection=0', '-d', 'opcache.memory_consumption=16',
'-d', 'opcache.interned_strings_buffer=4',
'-d', 'opcache.max_accelerated_files=10000', '-d', 'opcache.jit_buffer_size=0',
$this->root . '/probe.php', dirname(__DIR__, 2) . '/vendor/autoload.php',
], $this->root);
$process->mustRun();
[$before, $after, $refreshed, $written, $unchanged] = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
self::assertTrue($before['opcache_enabled']);
self::assertSame($before['memory_usage']['wasted_memory'], $after['memory_usage']['wasted_memory']);
self::assertSame($before['opcache_statistics']['oom_restarts'], $after['opcache_statistics']['oom_restarts']);
self::assertSame($before['opcache_statistics']['manual_restarts'], $after['opcache_statistics']['manual_restarts']);
self::assertGreaterThanOrEqual(8000, $after['opcache_statistics']['hits'] - $before['opcache_statistics']['hits']);
self::assertSame('second', $refreshed);
self::assertSame($written['memory_usage']['wasted_memory'], $unchanged['memory_usage']['wasted_memory']);
}
}
3 changes: 2 additions & 1 deletion tests/Integration/KernelBootTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ private function fixture(array $packages = [], bool $enabled = true): void
{
$this->write('composer.json', json_encode(['extra' => ['sympress-runtime' => ['require-wp' => false, 'db-check' => false, 'kernel-boot' => $enabled]]], JSON_THROW_ON_ERROR));
$this->write('vendor/autoload.php', '<?php require ' . var_export(dirname(__DIR__, 2) . '/vendor/autoload.php', true) . '; require_once __DIR__ . "/kernel-stub.php";');
$this->write('vendor/composer/installed.json', json_encode(['packages' => [['name' => 'sympress/kernel', 'version' => '1.0.0', 'type' => 'library', 'install-path' => '../sympress/kernel'], ...$packages]], JSON_THROW_ON_ERROR));
$this->write('vendor/composer/installed.json', json_encode(['packages' => [['name' => 'sympress/kernel', 'version' => '1.0.0', 'type' => 'library', 'install-path' => '../sympress/kernel'], ['name' => 'sympress/runtime', 'version' => 'v1.2.4', 'type' => 'composer-plugin', 'install-path' => '../sympress/runtime'], ...$packages]], JSON_THROW_ON_ERROR));
$this->write('vendor/kernel-stub.php', <<<'PHP'
<?php
namespace SymPress\Kernel;
Expand Down Expand Up @@ -48,6 +48,7 @@ public function testOptInCreatesGuardedBootAndDisablingRemovesOnlyOwnedFile(): v
self::assertSame(0, $run->getExitCode(), $run->getErrorOutput());
$file = $this->root . '/wp-content/mu-plugins/sympress-runtime-kernel.php';
self::assertFileExists($file);
self::assertStringContainsString(' * Version: 1.2.4', (string) file_get_contents($file));
$boot = new Process([PHP_BINARY, '-r', 'define("ABSPATH", __DIR__); require "wp-content/mu-plugins/sympress-runtime-kernel.php"; require "wp-content/mu-plugins/sympress-runtime-kernel.php"; echo json_encode([SymPress\\Kernel\\App::$boots, realpath(SymPress\\Kernel\\App::kernel()->root)]);'], $this->root);
$boot->mustRun();
self::assertSame([1, $this->root], json_decode($boot->getOutput(), true, flags: JSON_THROW_ON_ERROR));
Expand Down
Loading