From 2ed5ccf9ddaf6310c82cc0c1393e64a9f57423bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:05:47 +0000 Subject: [PATCH 01/28] test(opcache): prove BinaryCacheFile::refresh() semantics under real shared memory RefreshWorkflowTest covers the patch -> refresh -> run loop under opcache.file_cache_only=1, where no shared-memory copy exists and writing the binary is the whole story. This adds SharedMemoryRefreshTest, whose workers all run with SHM ACTIVE (opcache.enable_cli=1 + opcache.file_cache=, NOT file_cache_only), exercising exactly what the file_cache_only legs never touch: - a warm worker's single include populates BOTH shared memory and the .bin, and after an API patch + refresh() a FRESH worker (empty SHM, like a pool worker after restart) executes the patched body - loaded through opcache's own consistency-checked file-cache-into-SHM path and resident in shared memory afterwards (opcache_is_script_cached); - the negative control that motivates refresh(): within ONE worker process, patch + save() WITHOUT invalidation leaves the SHM-resident original in service on re-include, while a fresh worker proves the patched binary really is on disk - a SHM-resident script is not re-read until invalidated; - within ONE worker process, refresh() evicts the SHM-resident copy. Each CLI process owns a private SHM segment, so the class docblock spells out what every leg can honestly prove. Deliberately not asserted (found while building this): in the invalidating process itself a re-include after refresh() does not pick the patched binary up, because opcache_invalidate() with opcache.file_cache set unlinks the .bin that save() has just written (zend_file_cache_invalidate); enshrining that in an assertion would freeze a bug, so it stays follow-up work on refresh(). Workers use opcache.file_update_protection=0 and opcache.validate_timestamps=0 for determinism, exit code 2 marks "shared-memory shape not exercised" and skips loudly (never silent), and the tests carry the opcache + opcache-relocator groups so the --fail-on-skipped gates cover them on NTS and the ZTS gate excludes them alongside the other relocator tests. Fixes #125 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- tests/OpCache/SharedMemoryRefreshTest.php | 252 +++++++++++++++++++ tests/OpCache/fixtures/shm-answer.php | 13 + tests/OpCache/scripts/run-shm.php | 41 +++ tests/OpCache/scripts/shm-refresh-worker.php | 118 +++++++++ 4 files changed, 424 insertions(+) create mode 100644 tests/OpCache/SharedMemoryRefreshTest.php create mode 100644 tests/OpCache/fixtures/shm-answer.php create mode 100644 tests/OpCache/scripts/run-shm.php create mode 100644 tests/OpCache/scripts/shm-refresh-worker.php diff --git a/tests/OpCache/SharedMemoryRefreshTest.php b/tests/OpCache/SharedMemoryRefreshTest.php new file mode 100644 index 00000000..66f7ee5e --- /dev/null +++ b/tests/OpCache/SharedMemoryRefreshTest.php @@ -0,0 +1,252 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Reflection\ReflectionValue; + +/** + * BinaryCacheFile::refresh() semantics under REAL opcache shared memory - the + * SHM-active counterpart of RefreshWorkflowTest (issue #125). Every worker here + * runs with opcache.enable_cli=1 + opcache.file_cache= and WITHOUT + * file_cache_only, so scripts live in shared memory with the file cache as the + * second-level store. + * + * Each CLI process owns a private SHM segment, so the legs are explicit about + * what they prove: + * + * - a warm worker's single include populates BOTH shared memory and the .bin, + * and after a patch + refresh() a FRESH worker (empty SHM, like a pool + * worker after restart) executes the patched body - loaded through + * opcache's own file-cache-into-SHM path, checksums verified, and resident + * in shared memory afterwards (something file_cache_only never exercises); + * - within ONE worker process, save() alone leaves the SHM-resident body in + * service on re-include - the patched binary on disk is NOT re-read until + * invalidated, which is exactly the semantics that motivate refresh(); + * - within ONE worker process, refresh() evicts the SHM-resident copy + * (opcache_is_script_cached() flips to false). + * + * Deliberately NOT asserted (found while building this test, reported from + * issue #125): in the invalidating process itself a re-include after refresh() + * does not pick the patched binary up. opcache_invalidate() with + * opcache.file_cache set calls zend_file_cache_invalidate(), which UNLINKS the + * .bin that refresh()'s save() has just written, so the re-include recompiles + * the original source (and even with the ordering inverted, the re-include + * only consults the file cache under opcache.revalidate_path=1 - with the + * default key lookup the invalidated hash entry short-circuits path resolution + * and zend_file_cache_script_load() bails on the unresolved opened_path). + * Asserting the current behaviour would enshrine the bug; fixing it is + * follow-up work on refresh(), not on this test. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class SharedMemoryRefreshTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX NTS payloads only' + . ' (ZTS is issue #118, Windows is issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testFreshWorkerExecutesPatchedBodyFromSharedMemoryAfterRefresh(): void + { + $fixture = self::shmFixturePath(); + $cacheDir = self::freshShmCacheDir(); + + // One include in a warm worker fills shared memory AND the file cache + self::assertSame('value=41 shm=1', self::runShmWorker($fixture, $cacheDir)); + $binPath = BinaryCacheFile::locate($cacheDir, $fixture); + self::assertFileExists($binPath, 'the warm worker must populate the file cache alongside shared memory'); + + // Patch the compiled body in the .bin and refresh it + $file = BinaryCacheFile::read($binPath, $fixture); + self::patchAnswerLiteral($file); + $file->refresh(); + + // A fresh worker (empty SHM) must execute the PATCHED body - and shm=1 + // proves opcache loaded the patched binary INTO shared memory through + // its own consistency-checked file-cache path, not a process-memory + // fallback and not a recompile of the (unchanged) source + self::assertSame('value=42 shm=1', self::runShmWorker($fixture, $cacheDir)); + } + + public function testSharedMemoryResidentScriptIsServedUntilInvalidated(): void + { + $fixture = self::shmFixturePath(); + $cacheDir = self::freshShmCacheDir(); + + // The negative control: in one worker process, patch + save() WITHOUT + // refresh() - the re-include keeps executing the SHM-resident original + $stdout = self::runShmPatchWorker('save', $fixture, $cacheDir); + self::assertStringContainsString('shm-populated: ok', $stdout); + self::assertStringContainsString('patched-literal: ok', $stdout); + self::assertStringContainsString('stale-shm-after-save: ok', $stdout); + self::assertStringContainsString('SHM SAVE OK', $stdout); + + // ...while the patched binary really is on disk: a fresh worker (whose + // empty SHM cannot mask the file cache) executes the patched body, so + // the stale 41 above was shared memory shielding the script - not a + // patch that failed to land + self::assertSame('value=42 shm=1', self::runShmWorker($fixture, $cacheDir)); + } + + public function testRefreshEvictsTheSharedMemoryResidentCopy(): void + { + $stdout = self::runShmPatchWorker('refresh', self::shmFixturePath(), self::freshShmCacheDir()); + + self::assertStringContainsString('shm-populated: ok', $stdout); + self::assertStringContainsString('patched-literal: ok', $stdout); + self::assertStringContainsString('refresh-evicts-shm: ok', $stdout); + self::assertStringContainsString('SHM REFRESH OK', $stdout); + } + + /** + * Patches the fixture's single long literal 41 -> 42 through the wrappers + */ + private static function patchAnswerLiteral(BinaryCacheFile $file): void + { + $patched = 0; + foreach ($file->getReflection()->getScriptFunction()->getLiterals() as $literal) { + $literal->getNativeValue($value); + if ($literal->getBaseType() === ReflectionValue::IS_LONG && $value === 41) { + $literal->setNativeValue(42); + ++$patched; + } + } + self::assertSame(1, $patched, 'expected to patch exactly one literal in the fixture'); + } + + /** + * Includes the fixture in a worker with shared memory active (file cache as + * the second level, NOT file_cache_only) and returns "value= shm=<0|1>" + */ + private static function runShmWorker(string $fixture, string $cacheDir): string + { + $command = [ + PHP_BINARY, + ...self::sharedMemoryOptions($cacheDir), + __DIR__ . '/scripts/run-shm.php', + $fixture, + ]; + + return self::runWorker($command, 'shared-memory run'); + } + + /** + * Runs the include -> patch -> save()/refresh() sequence inside ONE worker + * process whose private SHM holds the fixture, and returns its stdout + */ + private static function runShmPatchWorker(string $mode, string $fixture, string $cacheDir): string + { + $command = [ + PHP_BINARY, + '-d', 'ffi.enable=1', + '-d', 'zend.assertions=1', + '-d', 'assert.exception=1', + '-d', 'display_errors=on', + '-d', 'error_reporting=-1', + '-d', 'memory_limit=512M', + ...self::sharedMemoryOptions($cacheDir), + __DIR__ . '/scripts/shm-refresh-worker.php', + $mode, + $fixture, + $cacheDir, + ]; + + return self::runWorker($command, "shared-memory {$mode}"); + } + + /** + * The ini set that makes shared memory + second-level file cache active and + * deterministic: no file_cache_only, no update protection (the fixture may + * be freshly checked out), no timestamp validation (the legs compare cache + * contents, not mtimes) and no JIT (it refuses the file cache) + * + * @return list + */ + private static function sharedMemoryOptions(string $cacheDir): array + { + return [ + '-d', 'opcache.enable=1', + '-d', 'opcache.enable_cli=1', + '-d', 'opcache.file_cache=' . $cacheDir, + '-d', 'opcache.file_cache_consistency_checks=1', + '-d', 'opcache.file_update_protection=0', + '-d', 'opcache.validate_timestamps=0', + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + ]; + } + + /** + * @param list $command + */ + private static function runWorker(array $command, string $label): string + { + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process, "Unable to spawn the {$label} worker"); + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + $report = "STDOUT:\n{$stdout}\nSTDERR:\n{$stderr}"; + if ($exitCode === 2) { + // Never a silent pass: the shared-memory shape was not exercised at all + self::markTestSkipped("Opcache shared memory could not be activated in the {$label} worker\n{$report}"); + } + self::assertSame(0, $exitCode, "The {$label} worker failed ({$exitCode})\n{$report}"); + + return trim($stdout); + } + + private static function shmFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/shm-answer.php'); + self::assertIsString($path); + + return $path; + } + + /** + * A per-test cache directory for the shared-memory workers. Opcache + * disables the file cache when the directory does not exist, so it is + * created here rather than left to the child. + */ + private static function freshShmCacheDir(): string + { + if (!extension_loaded('Zend OPcache')) { + self::markTestSkipped('Zend OPcache extension is not loaded'); + } + self::$cacheDir = sys_get_temp_dir() . '/zengine-opcache-' . bin2hex(random_bytes(6)); + if (!mkdir(self::$cacheDir, 0o755, true)) { + self::fail('Cannot create the file-cache directory ' . self::$cacheDir); + } + + return self::$cacheDir; + } +} diff --git a/tests/OpCache/fixtures/shm-answer.php b/tests/OpCache/fixtures/shm-answer.php new file mode 100644 index 00000000..ca0ea2a8 --- /dev/null +++ b/tests/OpCache/fixtures/shm-answer.php @@ -0,0 +1,13 @@ + + * file-cache load: `value` is the body the engine actually executed and + * `shm=1` proves the cache binary passed opcache's own loader checks INTO + * shared memory (opcache_is_script_cached() is false for process-memory + * fallbacks). + * + * argv: [1] = fixture script to include (must `return` a value) + * + * Output: "value= shm=<0|1>". Exit 2 when opcache shared memory is not + * active in this process - the parent skips, never a silent pass. + */ +declare(strict_types=1); + +$status = function_exists('opcache_get_status') ? opcache_get_status(false) : false; +if (!is_array($status) || !empty($status['file_cache_only'])) { + fwrite(STDERR, "opcache shared memory is not active in the child process\n"); + exit(2); +} + +$fixture = realpath($argv[1] ?? ''); +if ($fixture === false) { + fwrite(STDERR, "fixture script not found\n"); + exit(1); +} + +$value = include $fixture; +if (!is_int($value)) { + fwrite(STDERR, 'the fixture must return an int, got ' . var_export($value, true) . "\n"); + exit(1); +} +$shm = opcache_is_script_cached($fixture) ? 1 : 0; + +echo "value={$value} shm={$shm}\n"; diff --git a/tests/OpCache/scripts/shm-refresh-worker.php b/tests/OpCache/scripts/shm-refresh-worker.php new file mode 100644 index 00000000..f14a1311 --- /dev/null +++ b/tests/OpCache/scripts/shm-refresh-worker.php @@ -0,0 +1,118 @@ + resident, refresh() -> +// evicted): each call site states the expectation that holds at that point +$assertResidency = static function (string $path, bool $resident, string $message) use ($fail): void { + if (opcache_is_script_cached($path) !== $resident) { + $fail($message); + } +}; + +$mode = $argv[1] ?? ''; +$fixture = realpath($argv[2] ?? ''); +$cacheDir = $argv[3] ?? ''; +if (!in_array($mode, ['save', 'refresh'], true)) { + $fail("unknown mode '{$mode}', expected 'save' or 'refresh'"); +} +if ($fixture === false || $cacheDir === '') { + $fail('usage: shm-refresh-worker.php '); +} + +// 1. Load the fixture: with SHM active + opcache.file_cache set, one include +// populates both the shared-memory hash and the file-cache .bin +$first = include $fixture; +if ($first !== 41) { + $fail('the pristine fixture must return 41, got ' . var_export($first, true)); +} +$assertResidency($fixture, true, 'the fixture is not resident in shared memory after the include'); +$binPath = BinaryCacheFile::locate($cacheDir, $fixture); +if (!is_file($binPath)) { + $fail("the include did not populate the file cache: {$binPath} is missing"); +} +echo "shm-populated: ok\n"; + +// 2. Patch the compiled body in the .bin through the framework wrappers +Core::init(); +$file = BinaryCacheFile::read($binPath, $fixture); +$main = $file->getReflection()->getScriptFunction(); +$patched = 0; +foreach ($main->getLiterals() as $literal) { + $literal->getNativeValue($value); + if ($literal->getBaseType() === ReflectionValue::IS_LONG && $value === 41) { + $literal->setNativeValue(42); + ++$patched; + } +} +if ($patched !== 1) { + $fail("expected to patch exactly one literal, patched {$patched}"); +} +echo "patched-literal: ok\n"; + +if ($mode === 'save') { + // 3a. save() alone: the patched binary lands on disk, but the script stays + // resident in shared memory - a re-include in THIS process must keep + // executing the original body, proving a SHM-resident script is not + // re-read until it is invalidated (the semantics that motivate refresh()) + $file->save(); + $second = include $fixture; + if ($second !== 41) { + $fail('save() alone must leave the SHM-resident body in service, got ' . var_export($second, true)); + } + $assertResidency($fixture, true, 'save() alone must not evict the shared-memory copy'); + echo "stale-shm-after-save: ok\n"; + echo "SHM SAVE OK\n"; +} else { + // 3b. refresh(): the invalidation half of the contract - the SHM-resident + // copy is evicted, so this process no longer serves the stale body. + // The reload half (a re-include picking the patched binary back up + // in THIS process) is deliberately NOT asserted: see the test class. + $file->refresh(); + $assertResidency($fixture, false, 'refresh() must evict the shared-memory copy of the fixture'); + echo "refresh-evicts-shm: ok\n"; + echo "SHM REFRESH OK\n"; +} From 242102359450c85d1adea7c01e5ce6e1383ca1e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:14:07 +0000 Subject: [PATCH 02/28] fix(opcache): invalidate before writing in BinaryCacheFile::refresh() In a process running opcache shared memory WITH opcache.file_cache, opcache_invalidate() does more than evict the SHM entry: it also unlinks the script's cache binary (zend_file_cache_invalidate). refresh() used to save() first and invalidate second, so the invalidation deleted the patched binary refresh() had just written - the next load recompiled the original source and the patch was silently lost. refresh() now invalidates first and writes second, so the unlink hits the STALE binary. The ordering also picks the right failure direction: if save() throws after the invalidation, the worst case is a cache miss and a recompile of the original source - never a lost patch presented as a successful refresh. Under opcache.file_cache_only (and in processes without active opcache) opcache_invalidate() is a no-op, so the file-cache-only semantics are unchanged - RefreshWorkflowTest stays green. Same-process pickup is documented rather than changed: after an in-process invalidation, opcache's default key lookup finds the invalidated hash entry without resolving the script path and never consults the file cache again, so a re-include in the same process picks the patched binary up only under opcache.revalidate_path=1; a fresh worker needs no such setting. SharedMemoryRefreshTest now asserts the fixed contract instead of documenting the gap: the refresh leg proves the binary survives the invalidation (through a stat-cache-clearing check, so a regression cannot hide behind PHP's stat cache) and that a re-include in the SAME worker executes the patched body, loaded from the file cache back into shared memory (the leg runs with opcache.revalidate_path=1, and the save-only negative control runs with it too, proving its staleness is genuine SHM shielding rather than the default-lookup quirk). Fixes #252 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/opcache-binary.md | 29 ++++++++++--- src/OpCache/BinaryCacheFile.php | 27 +++++++++--- tests/OpCache/SharedMemoryRefreshTest.php | 39 ++++++++++------- tests/OpCache/scripts/shm-refresh-worker.php | 45 ++++++++++++++++---- 4 files changed, 105 insertions(+), 35 deletions(-) diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index 5bc9c2b9..7b38d9e6 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -84,11 +84,30 @@ script, so the next include picks up the patched binary. ## Refresh and shared memory Under `opcache.file_cache_only=1` there is no shared-memory copy, so writing the -binary is enough for the next worker to load it. When opcache also uses shared -memory, a script already resident in SHM is **not** re-read until it is -invalidated — which is exactly what `refresh()` does. Loading a patched binary -directly into shared memory (and wiring it to the function/method hot-swap API) -is future work; see [hot-swap.md](hot-swap.md). +binary is enough for the next worker to load it (`opcache_invalidate()` is a +no-op in that mode). When opcache also uses shared memory, a script already +resident in SHM is **not** re-read until it is invalidated — which is exactly +what `refresh()` does. + +Two shared-memory subtleties `refresh()` accounts for: + +- **Invalidate before write.** In a process running SHM *with* + `opcache.file_cache`, `opcache_invalidate()` also unlinks the script's cache + binary (`zend_file_cache_invalidate`). `refresh()` therefore invalidates + first and writes second, so the unlink hits the stale binary — the worst + case if the write then fails is a cache miss and a recompile of the original + source, never a silently lost patch. +- **Same-process pickup needs `opcache.revalidate_path=1`.** After an + in-process invalidation, opcache's default key lookup finds the invalidated + hash entry without resolving the script path and never consults the file + cache again, so a re-include in the *same* process recompiles the source. + With `opcache.revalidate_path=1` the path is resolved, the patched binary is + loaded from the file cache back into shared memory, and the re-include + executes the patched body. A **fresh** worker (an empty SHM — e.g. a pool + worker after restart) picks the patched binary up with default settings. + +Loading a patched binary directly into shared memory (and wiring it to the +function/method hot-swap API) is future work; see [hot-swap.md](hot-swap.md). ## Scope and limits (v1) diff --git a/src/OpCache/BinaryCacheFile.php b/src/OpCache/BinaryCacheFile.php index fe903376..0969935f 100644 --- a/src/OpCache/BinaryCacheFile.php +++ b/src/OpCache/BinaryCacheFile.php @@ -297,13 +297,28 @@ private static function ensureDirectory(string $directory, ?int $permissions): v } /** - * Writes the (patched) binary and invalidates opcache's in-memory copy of - * the source script, so the next include picks the patched binary up. + * Invalidates opcache's in-memory copy of the source script and writes the + * (patched) binary, so the next load picks the patched binary up. * * A script already resident in shared memory is not re-read until it is * invalidated, which is what this does; under opcache.file_cache_only there - * is no shared copy and the write alone suffices. Invalidation is a no-op - * when opcache is not active in this process (the write still happens). + * is no shared copy and the write alone suffices (opcache_invalidate() is + * a no-op there, and also when opcache is not active in this process - the + * write still happens either way). + * + * The order is deliberately invalidate-BEFORE-save: in a process running + * shared memory WITH opcache.file_cache, opcache_invalidate() also unlinks + * the script's cache binary (zend_file_cache_invalidate), so invalidating + * after the write would delete the patched binary it just produced (issue + * #252). With this order the unlink hits the stale binary, and the worst + * case when save() then fails is a cache miss - a recompile of the + * original source - never a silently lost patch. + * + * Same-process pickup caveat: after an in-process invalidation, opcache's + * default key lookup skips path resolution for the invalidated entry and + * never consults the file cache again - the patched binary is loaded on a + * re-include only under opcache.revalidate_path=1; a fresh worker picks it + * up with default settings. * * @param int|null $timestamp Source mtime to stamp; defaults to the script's * current mtime so the binary stays valid under @@ -314,10 +329,10 @@ public function refresh(?int $timestamp = null): void if ($timestamp === null && $this->scriptPath !== null && is_file($this->scriptPath)) { $timestamp = filemtime($this->scriptPath) ?: null; } - $this->save(null, $timestamp); - if ($this->scriptPath !== null && function_exists('opcache_invalidate')) { opcache_invalidate($this->scriptPath, true); } + + $this->save(null, $timestamp); } } diff --git a/tests/OpCache/SharedMemoryRefreshTest.php b/tests/OpCache/SharedMemoryRefreshTest.php index 66f7ee5e..64e75c40 100644 --- a/tests/OpCache/SharedMemoryRefreshTest.php +++ b/tests/OpCache/SharedMemoryRefreshTest.php @@ -35,20 +35,21 @@ * - within ONE worker process, save() alone leaves the SHM-resident body in * service on re-include - the patched binary on disk is NOT re-read until * invalidated, which is exactly the semantics that motivate refresh(); - * - within ONE worker process, refresh() evicts the SHM-resident copy - * (opcache_is_script_cached() flips to false). + * - within ONE worker process, refresh() evicts the SHM-resident copy, the + * patched binary SURVIVES the invalidation (refresh() invalidates before + * it writes, because opcache_invalidate() with opcache.file_cache set + * unlinks the script's .bin - the ordering bug of issue #252), and a + * re-include executes the patched body, loaded from the file cache back + * into shared memory. * - * Deliberately NOT asserted (found while building this test, reported from - * issue #125): in the invalidating process itself a re-include after refresh() - * does not pick the patched binary up. opcache_invalidate() with - * opcache.file_cache set calls zend_file_cache_invalidate(), which UNLINKS the - * .bin that refresh()'s save() has just written, so the re-include recompiles - * the original source (and even with the ordering inverted, the re-include - * only consults the file cache under opcache.revalidate_path=1 - with the - * default key lookup the invalidated hash entry short-circuits path resolution - * and zend_file_cache_script_load() bails on the unresolved opened_path). - * Asserting the current behaviour would enshrine the bug; fixing it is - * follow-up work on refresh(), not on this test. + * The same-process legs run with opcache.revalidate_path=1: after an + * in-process invalidation, opcache's default key lookup finds the invalidated + * hash entry without resolving the script path and never consults the file + * cache again, so same-process pickup of the patched binary requires path + * revalidation (a fresh worker needs no such thing - the fresh-worker leg + * keeps the default lookup on purpose). The negative control runs with the + * same ini, proving its staleness is genuine SHM shielding, not the lookup + * quirk. */ #[Group('opcache')] #[Group('opcache-relocator')] @@ -113,13 +114,15 @@ public function testSharedMemoryResidentScriptIsServedUntilInvalidated(): void self::assertSame('value=42 shm=1', self::runShmWorker($fixture, $cacheDir)); } - public function testRefreshEvictsTheSharedMemoryResidentCopy(): void + public function testSameWorkerReExecutesPatchedBodyAfterRefresh(): void { $stdout = self::runShmPatchWorker('refresh', self::shmFixturePath(), self::freshShmCacheDir()); self::assertStringContainsString('shm-populated: ok', $stdout); self::assertStringContainsString('patched-literal: ok', $stdout); self::assertStringContainsString('refresh-evicts-shm: ok', $stdout); + self::assertStringContainsString('bin-survives-refresh: ok', $stdout); + self::assertStringContainsString('patched-body-on-reinclude: ok', $stdout); self::assertStringContainsString('SHM REFRESH OK', $stdout); } @@ -157,7 +160,12 @@ private static function runShmWorker(string $fixture, string $cacheDir): string /** * Runs the include -> patch -> save()/refresh() sequence inside ONE worker - * process whose private SHM holds the fixture, and returns its stdout + * process whose private SHM holds the fixture, and returns its stdout. + * + * revalidate_path=1 is what same-process pickup of a refreshed binary + * requires (see the class docblock); the save mode runs with it too, so + * its staleness is proven to be SHM shielding rather than the default + * lookup never consulting the file cache. */ private static function runShmPatchWorker(string $mode, string $fixture, string $cacheDir): string { @@ -169,6 +177,7 @@ private static function runShmPatchWorker(string $mode, string $fixture, string '-d', 'display_errors=on', '-d', 'error_reporting=-1', '-d', 'memory_limit=512M', + '-d', 'opcache.revalidate_path=1', ...self::sharedMemoryOptions($cacheDir), __DIR__ . '/scripts/shm-refresh-worker.php', $mode, diff --git a/tests/OpCache/scripts/shm-refresh-worker.php b/tests/OpCache/scripts/shm-refresh-worker.php index f14a1311..a118c997 100644 --- a/tests/OpCache/scripts/shm-refresh-worker.php +++ b/tests/OpCache/scripts/shm-refresh-worker.php @@ -14,8 +14,13 @@ * - save: save() only (no invalidation). A re-include must STILL execute * the original body - the SHM-resident copy is served and the * patched binary on disk is not re-read. - * - refresh: refresh() (save + opcache_invalidate). The SHM-resident copy - * must be evicted: opcache_is_script_cached() flips to false. + * - refresh: refresh() (opcache_invalidate + save, in that order - issue + * #252). The SHM-resident copy must be evicted, the patched + * binary must SURVIVE the invalidation (the unlink hits the stale + * binary, not the fresh one), and a re-include must execute the + * PATCHED body, loaded from the file cache back into shared + * memory. Same-process pickup only happens under + * opcache.revalidate_path=1, so this mode requires that ini. * * argv: [1] = mode ("save" | "refresh") * [2] = fixture script (must `return` a patchable long literal 41) @@ -53,6 +58,16 @@ } }; +// Binary presence also changes over the script's lifetime (the ordering bug of +// issue #252 was refresh() unlinking its own fresh binary), and PHP's stat +// cache would otherwise keep reporting the pre-refresh() state +$assertBinaryOnDisk = static function (string $path, string $message) use ($fail): void { + clearstatcache(true, $path); + if (!is_file($path)) { + $fail($message); + } +}; + $mode = $argv[1] ?? ''; $fixture = realpath($argv[2] ?? ''); $cacheDir = $argv[3] ?? ''; @@ -71,9 +86,7 @@ } $assertResidency($fixture, true, 'the fixture is not resident in shared memory after the include'); $binPath = BinaryCacheFile::locate($cacheDir, $fixture); -if (!is_file($binPath)) { - $fail("the include did not populate the file cache: {$binPath} is missing"); -} +$assertBinaryOnDisk($binPath, "the include did not populate the file cache: {$binPath} is missing"); echo "shm-populated: ok\n"; // 2. Patch the compiled body in the .bin through the framework wrappers @@ -107,12 +120,26 @@ echo "stale-shm-after-save: ok\n"; echo "SHM SAVE OK\n"; } else { - // 3b. refresh(): the invalidation half of the contract - the SHM-resident - // copy is evicted, so this process no longer serves the stale body. - // The reload half (a re-include picking the patched binary back up - // in THIS process) is deliberately NOT asserted: see the test class. + // 3b. refresh(): both halves of the contract inside one process. The + // invalidation half evicts the SHM-resident copy; the reload half + // re-includes the script, which must execute the PATCHED body loaded + // from the surviving binary back into shared memory. Same-process + // pickup requires opcache.revalidate_path=1 (with the default key + // lookup the invalidated hash entry short-circuits path resolution + // and the file cache is never consulted again) + if (ini_get('opcache.revalidate_path') !== '1') { + $fail('the refresh mode must run with opcache.revalidate_path=1 - fix the parent test command'); + } $file->refresh(); $assertResidency($fixture, false, 'refresh() must evict the shared-memory copy of the fixture'); echo "refresh-evicts-shm: ok\n"; + $assertBinaryOnDisk($binPath, 'refresh() must leave the patched binary in place, not unlink it (issue #252)'); + echo "bin-survives-refresh: ok\n"; + $second = include $fixture; + if ($second !== 42) { + $fail('the re-include after refresh() must execute the patched body, got ' . var_export($second, true)); + } + $assertResidency($fixture, true, 'the re-include must load the patched binary back into shared memory'); + echo "patched-body-on-reinclude: ok\n"; echo "SHM REFRESH OK\n"; } From f47797008ecadaf486e934a3a175ea033fdaa1a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:16:23 +0000 Subject: [PATCH 03/28] feat(reflection): copy literals with opcodes to lift the IS_CONST reach limit Un-sharing a method body for a builtin ZEND_RECV patch used to copy only the opcode array and rebase every IS_CONST operand back onto the source literals. An IS_CONST operand is a signed 32-bit opline-relative byte offset, so that only worked while the literals stayed within 2GB of the relocated opcodes - which an opcache-shared body never does, making slot substitution of builtin parameter types refuse every shared-memory method body. The copy now reproduces the engine's own pass_two() layout in one request- memory block: opcodes at the start, the literal zvals memcpy'd to the same 16-aligned offset right behind them, and every IS_CONST operand rebased onto the copied literal at the index its source operand addressed. With both halves in one block the rebased offset is bounded by the block size and always fits the 32-bit field, so shared-memory sources are fully supported and the refusal is gone. The zend.assertions verifier now proves each operand lands zval-aligned on the same literal index inside the copied table. Ownership follows the engine's one-shared-body model. The literal zvals are copied shallowly: releases happen only when the shared body refcount reaches zero, so exactly one dtor pass ever runs over exactly one of the sibling zval arrays, and with relative const addressing destroy_op_array() frees literals and opcodes as one allocation through the opcodes pointer (never a separate efree of literals once ZEND_ACC_DONE_PASS_TWO is set - the layout this block is built for). An opcache-shared source never reaches that pass at all: its refcount pointer is NULL and the immortal SHM payloads (interned strings, immutable arrays) are never refcounted. One block per patched method, request allocator reclaims whichever sibling the engine does not free. Enforcement surfaced a second shared-memory gap the old refusal was masking: opcache's optimizer assigns a mixed parameter's RECV (cached mask exactly MAY_BE_ANY) the RECV_NOTYPE handler variant, which never reads the cached mask, so patching the mask alone was silently unenforced. Such oplines are now rebound to the engine's generic mask-checking handler, transplanted from a donor opline whose int parameter can never be NOTYPE-specialized - exactly the handler the compiler assigns when a builtin parameter type is written in source. The two tests parked in the opcache-incompatible group return to the opcache-active runner job, leaving the group empty (the mechanism stays for future use), and docs/class-specialization.md documents the new copy model in place of the 2GB limitation. Fixes #131 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/class-specialization.md | 60 +++-- src/Reflection/ClassSpecializer.php | 238 +++++++++++++----- tests/Reflection/ClassSpecializerSlotTest.php | 9 - 3 files changed, 219 insertions(+), 88 deletions(-) diff --git a/docs/class-specialization.md b/docs/class-specialization.md index 490d6197..42105864 100644 --- a/docs/class-specialization.md +++ b/docs/class-specialization.md @@ -115,34 +115,50 @@ two cases, and both are rejected rather than silently unenforced: ### Un-sharing the opcode array -When a plain `ZEND_RECV` has to be patched, the method's opcodes are copied into request memory -first, because they are shared with the template by design. Three operand encodings matter: +When a plain `ZEND_RECV` has to be patched, the method's opcodes **and literals** are copied +into request memory first, because they are shared with the template by design. The copy +reproduces the engine's own `pass_two()` layout in one block - opcodes at the start, the +literal zvals at the same 16-aligned offset right behind them. Three operand encodings matter: | Operand | Encoding | Survives the copy? | |---|---|---| | jump targets | *signed* byte offset from the opline itself | yes - the whole array moves as a unit, so relative distances are unchanged | -| `IS_CONST` operands | byte offset **from the opline itself** | no - every one is rebased by the distance the array moved | +| `IS_CONST` operands | byte offset **from the opline itself** | no - every one is rebased onto the copied literal at the same index | | `live_range`, `try_catch_array` | opline indices | yes | -Literals sit immediately after the opcodes in one compiler-arena block, which is why a constant -operand is opline-relative and why moving the array alone would silently make every literal -reference point at the wrong zval. Under `zend.assertions=1` the copy is verified: every -`IS_CONST` operand must resolve to the same address it resolved to before the move, and every -jump offset must still land inside the array. - -An `IS_CONST` operand stores a **signed 32-bit** offset, so the relocated opcodes have to stay -within 2GB of the literals they point at. Request memory and the compiler arena are neighbours, -so this holds for an ordinary class - but an **opcache-shared body** lives in an mmap'd region -that can be arbitrarily far from the request heap, and a truncated offset would read whatever sat -at the wrapped address. That case is detected and rejected: substituting a *builtin parameter* -type needs a body that is not in shared memory. Class-like parameters, all return types and all -properties are unaffected, because none of them un-shares the opcodes. - -Ownership mirrors the duplicated `arg_info` blocks - `destroy_op_array()` frees whichever -`opcodes` pointer its holder carries once the shared body refcount reaches zero, so one sibling -block is released through the engine and the other is reclaimed by the request allocator at -request end. Bounded at one block per patched method, and only methods that actually need a -patch pay it. An opcache-shared source is safe because it is only ever read. +A constant operand is opline-relative because opcodes and literals normally share one +compiler-arena block, and it stores a **signed 32-bit** offset - which is exactly why the +literals travel with the opcodes. The source literals can be arbitrarily far from the +relocated opcodes (an **opcache-shared body** lives in an mmap'd region well over 2GB from +the request heap, where a truncated offset would read whatever sat at the wrapped address), +but with both halves copied into one block every rebased offset is bounded by the block size +and always fits. Opcache-shared bodies are therefore fully supported. Under +`zend.assertions=1` the copy is verified: every `IS_CONST` operand must resolve to the copied +literal at the very index its source operand resolved to (landing zval-aligned inside the +copied table), and every jump offset must still land inside the array. + +The literal zvals are copied **shallowly**: both blocks reference the same payloads (strings, +arrays, ASTs), matching how the engine treats the two of them as one shared body - releases +happen only when the shared body refcount reaches zero, so exactly one dtor pass ever runs +over exactly one of the sibling zval arrays. An opcache-shared source never reaches that pass +at all: its body refcount pointer is NULL, `destroy_op_array()` returns before touching +literals, and the immortal shared-memory payloads (interned strings, immutable arrays) are +never refcounted. + +One more thing rides on the un-shared copy: opline **handlers**. Opcache's optimizer assigns +a `mixed` parameter's RECV (cached mask exactly `MAY_BE_ANY`) the `RECV_NOTYPE` handler +variant, which never reads the cached mask - so after writing the new mask, the specializer +also rebinds the patched opline to the engine's generic, mask-checking handler (taken from a +donor opline that can never be NOTYPE-specialized), exactly what the compiler assigns when a +builtin parameter type is written in source. + +Ownership mirrors the duplicated `arg_info` blocks - with relative `IS_CONST` addressing the +engine frees literals and opcodes as ONE allocation through the `opcodes` pointer (it never +`efree()`s `literals` separately once `ZEND_ACC_DONE_PASS_TWO` is set, which this block layout +is built for), so one sibling block is released through the engine once the shared body +refcount reaches zero and the other is reclaimed by the request allocator at request end. +Bounded at one block per patched method, and only methods that actually need a patch pay it. +An opcache-shared source is safe because it is only ever read. ### Rejections diff --git a/src/Reflection/ClassSpecializer.php b/src/Reflection/ClassSpecializer.php index be285daa..2724cb5f 100644 --- a/src/Reflection/ClassSpecializer.php +++ b/src/Reflection/ClassSpecializer.php @@ -1286,13 +1286,18 @@ private function patchReceiveOpcodes( // A cached mask carrying a name or list bit means the compiler already routed this // parameter through the generic path that reads arg_info on every call, so the - // rewrite is live without touching an opcode - and the opcodes stay shared, which - // is both cheaper and free of the 32-bit reach limit below. + // rewrite is live without touching an opcode - and the opcodes stay shared with + // the template, which is the cheapest outcome of all. $routedThroughArgInfo = ($cachedMask & ( Core::engineConstant('_ZEND_TYPE_NAME_BIT') | Core::engineConstant('_ZEND_TYPE_LIST_BIT') )) !== 0; if (!$routedThroughArgInfo && $cachedMask !== $newMask) { - $patches[$index] = $newMask; + // Handlers are picked per OPLINE, not per opcode: opcache's optimizer gives a + // RECV whose cached mask is exactly MAY_BE_ANY (a `mixed` parameter) the + // RECV_NOTYPE variant, which tests nothing at all - so patching only the mask + // of such an opline would be silently unenforced. builtinTypeMask('mixed') IS + // MAY_BE_ANY, the very value the specialization rule compares against. + $patches[$index] = [$newMask, $cachedMask === self::builtinTypeMask('mixed')]; } } if ($patches === []) { @@ -1300,33 +1305,120 @@ private function patchReceiveOpcodes( } $copiedOpcodes = $this->duplicateOpcodes($opArrayCopy, $sourceOpArray, $total); - foreach ($patches as $index => $newMask) { + foreach ($patches as $index => [$newMask, $needsCheckingHandler]) { $patched = $copiedOpcodes[$index]; assert($patched instanceof CData); $cachedMask = $patched->op2; assert($cachedMask instanceof CData); $cachedMask->num = $newMask; + if ($needsCheckingHandler) { + self::restoreGenericReceiveHandler($patched); + } } } /** - * Copies a method's opcode array into request memory so the copy can be written to + * Rebinds a patched RECV opline to the engine's generic, mask-checking handler + * + * pass_two() assigns every RECV the generic handler, which tests `op2.num` on each call. + * Opcache's optimizer re-derives handlers with type-specialization rules and assigns + * `ZEND_RECV_NOTYPE` - a variant that receives the argument WITHOUT any check - to every + * RECV whose cached mask equals MAY_BE_ANY, because a `mixed` parameter accepts + * everything. A mask patched onto such an opline would never be read, so the opline is + * rebound to the generic handler, exactly what the compiler assigns when a builtin + * parameter type is written in source. The handler value comes from a donor opline that + * is generic in every compile mode (see receiveHandlerDonor()); it is transplanted + * through a pointer-sized integer view because FFI wraps C function pointers in opaque + * closure handles that cannot be copied field-to-field. + * + * @param \FFI\CData $patchedOpline The relocated (request-memory) RECV opline to rebind + */ + private static function restoreGenericReceiveHandler(object $patchedOpline): void + { + $donor = Core::cast( + 'zend_op_array *', + (new ReflectionMethod(self::class, 'receiveHandlerDonor'))->getEntryPointer(), + ); + $donorOpcodes = $donor->opcodes; + assert($donorOpcodes instanceof CData); + $donorOpline = $donorOpcodes[0]; + assert($donorOpline instanceof CData); + if ($donorOpline->opcode !== OpCode::RECV) { + throw new ClassSpecializationException( + 'Cannot rebind the RECV handler: the donor method did not compile to a leading ' + . 'ZEND_RECV opline, which indicates an engine behavior change', + ); + } + $handlerOffset = Core::type('zend_op')->getStructFieldOffset('handler'); + $donorHandler = Core::pointerAtAddress('uintptr_t *', Core::addressOf($donorOpcodes) + $handlerOffset)[0]; + assert(is_int($donorHandler)); + $patchedAddress = Core::addressOf(Core::addr($patchedOpline)); + self::storeHandlerSlot(Core::pointerAtAddress('uintptr_t *', $patchedAddress + $handlerOffset), $donorHandler); + } + + /** + * Stores one raw handler value into the pointer-sized slot of an opline + * + * The write mutates engine-visible memory behind the FFI pointer, which static + * analysis cannot see - hence the explicit impurity marker. + * + * @phpstan-impure + * @param \FFI\CData $handlerSlot + */ + private static function storeHandlerSlot(object $handlerSlot, int $handlerValue): void + { + $handlerSlot[0] = $handlerValue; + } + + /** + * Donor of the engine's generic, mask-checking ZEND_RECV handler + * + * Never called - it exists to be COMPILED. An `int` parameter caches MAY_BE_LONG, a mask + * the optimizer's RECV_NOTYPE specialization rule (`op2.num == MAY_BE_ANY`) can never + * match, so the first opline of this method carries the generic RECV handler in every + * compile mode - plain pass_two() and opcache-optimized alike. restoreGenericReceiveHandler() + * reads it from here instead of hardcoding VM internals. + */ + // @phpstan-ignore method.unused (looked up by name through ReflectionMethod, never called) + private static function receiveHandlerDonor(int $probe): void {} + + /** + * Copies a method's opcode array - literals included - into request memory so the copy + * can be written to + * + * The copy reproduces the engine's own pass_two() layout in one request-memory block: + * opcodes at the start, the literal zvals at the same 16-aligned offset right behind them + * (`ZEND_MM_ALIGNED_SIZE_EX(sizeof(zend_op) * last, 16)`). Copying the literals WITH the + * opcodes is what makes the relocation universally valid: an IS_CONST operand is a *signed + * 32-bit* byte offset from the opline itself, and while the source pair always sat together, + * the source literals can be arbitrarily far from the relocated opcodes - an opcache-shared + * body lives in an mmap'd region well over 2GB from the request heap. With both halves in + * one block every rebased offset is bounded by the block size and always fits. * - * Two of the three operand encodings survive a straight memcpy and one does not: + * Two of the three operand encodings survive the memcpy untouched and one is rebased: * * - jump targets are *signed* byte offsets from the opline itself, so they survive because - * the whole array moves as a unit and every target keeps the same relative distance; + * the opcode array moves as a unit and every target keeps the same relative distance; * - `live_range` and `try_catch_array` address oplines by index, so they are unaffected; * - **IS_CONST operands are byte offsets from the opline itself** (the engine resolves them - * as `(char *) opline + node.constant`, and literals sit immediately after the opcodes in - * one compiler-arena block), so every one of them has to be rebased by the distance the - * array moved. - * - * Ownership mirrors the duplicated arg_info blocks: `destroy_op_array()` frees whichever - * `opcodes` pointer its holder carries once the shared body refcount reaches zero, so one - * sibling block is released through the engine and the other is reclaimed by the request - * allocator at request end. Bounded at one block per patched method. An opcache-shared - * source is safe because it is only ever read - the copy is what gets written. + * as `(char *) opline + node.constant`), so every one of them is rebased to address the + * copied literal at the very index its source addressed. + * + * The literal zvals are copied SHALLOWLY - the copy references the same payloads (strings, + * arrays, ASTs) as the source, exactly like the engine treats the two blocks as one shared + * body: releases happen only when the shared body refcount reaches zero, so exactly one + * dtor pass ever runs over exactly one of the sibling zval arrays. An opcache-shared source + * never even reaches that pass - its body refcount pointer is NULL, destroy_op_array() + * returns before touching literals, and the immortal shared-memory payloads (interned + * strings, immutable arrays) are never refcounted at all. + * + * Ownership mirrors the duplicated arg_info blocks: with relative IS_CONST addressing the + * engine frees literals and opcodes as ONE allocation through the `opcodes` pointer (it + * never efree()s `literals` separately once ZEND_ACC_DONE_PASS_TWO is set, which this block + * layout is built for), so one sibling block is released through the engine when the shared + * refcount hits zero and the other is reclaimed by the request allocator at request end. + * Bounded at one block per patched method. An opcache-shared source is safe because it is + * only ever read - the copy is what gets written. * * @return CData The copied zend_op[] block * @param \FFI\CData $opArrayCopy @@ -1335,72 +1427,95 @@ private function patchReceiveOpcodes( private function duplicateOpcodes(object $opArrayCopy, object $sourceOpArray, int $total): object { $sourceOpcodes = $sourceOpArray->opcodes; - assert($sourceOpcodes instanceof CData); + $totalLiterals = $sourceOpArray->last_literal; + assert($sourceOpcodes instanceof CData && is_int($totalLiterals)); $opcodeSize = Core::sizeOfType(zend_op::class); - $memory = Core::new("zend_op[{$total}]", false); - Core::memcpy($memory, $sourceOpcodes, $total * $opcodeSize); + $zvalSize = Core::sizeOfType(zval::class); - $sourceBase = Core::addressOf($sourceOpcodes); - $copyBase = Core::addressOf(Core::cast('zend_op *', Core::addr($memory))); - $shift = $sourceBase - $copyBase; + // ZEND_MM_ALIGNED_SIZE_EX(sizeof(zend_op) * last, 16): the engine's own literal offset + $literalsOffset = ($total * $opcodeSize + 15) & ~15; + $blockSize = $literalsOffset + $totalLiterals * $zvalSize; + $memory = Core::new("char[{$blockSize}]", false); + $copiedOpcodes = Core::cast('zend_op *', $memory); + Core::memcpy($copiedOpcodes, $sourceOpcodes, $total * $opcodeSize); + + $copyBase = Core::addressOf($copiedOpcodes); + $opcodeShift = Core::addressOf($sourceOpcodes) - $copyBase; + + $literalShift = null; + if ($totalLiterals > 0) { + $sourceLiterals = $sourceOpArray->literals; + assert($sourceLiterals instanceof CData); + $copiedLiterals = Core::pointerAtAddress('zval *', $copyBase + $literalsOffset); + Core::memcpy($copiedLiterals, $sourceLiterals, $totalLiterals * $zvalSize); + $literalShift = Core::addressOf($sourceLiterals) - ($copyBase + $literalsOffset); + $opArrayCopy->literals = $copiedLiterals; + } for ($index = 0; $index < $total; $index++) { - $opline = $memory[$index]; + $opline = $copiedOpcodes[$index]; assert($opline instanceof CData); foreach (self::CONSTANT_OPERAND_FIELDS as $typeField => $operandField) { if ($opline->{$typeField} !== OpLine::IS_CONST) { continue; } + // An IS_CONST operand always addresses a literal, so a method carrying one + // always carries a literal table + assert($literalShift !== null); $operand = $opline->{$operandField}; assert($operand instanceof CData); $current = $operand->constant; assert(is_int($current)); - // znode_op.constant is a uint32_t holding a SIGNED opline-relative offset, so the - // literal has to stay within 2GB of the relocated opline. Request memory and the - // compiler arena are neighbours, but an opcache-shared body lives in an mmap'd - // region that can be arbitrarily far away - and a silently truncated offset would - // read whatever happens to sit at the wrapped address. - $relocated = self::asSignedOffset($current) + $shift; - if ($relocated < -0x80000000 || $relocated > 0x7FFFFFFF) { - throw new ClassSpecializationException( - 'Cannot un-share the opcodes of this method: its literals are ' - . abs($relocated) . ' bytes from the relocated opcode array, which does not ' - . 'fit the signed 32-bit offset an IS_CONST operand stores. This happens when ' - . 'the body is opcache-shared, because shared memory is too far from the ' - . 'request heap; substituting a builtin parameter type needs a body that is ' - . 'not in shared memory.', - ); - } - $operand->constant = $relocated & 0xFFFFFFFF; + // znode_op.constant is a uint32_t holding a SIGNED opline-relative offset: the + // opcodes moved by $opcodeShift and the literal it addressed moved by + // $literalShift, so their relative distance changed by the difference. The + // result is bounded by the combined block size, so it always fits 32 bits. + $operand->constant = (self::asSignedOffset($current) + $opcodeShift - $literalShift) & 0xFFFFFFFF; } } - $opArrayCopy->opcodes = Core::cast('zend_op *', Core::addr($memory)); - assert(self::opcodeCopyResolvesIdentically($sourceOpcodes, $memory, $total, $opcodeSize)); + $opArrayCopy->opcodes = $copiedOpcodes; + assert(self::opcodeCopyResolvesIdentically($sourceOpArray, $opArrayCopy, $total, $opcodeSize)); - return $memory; + return $copiedOpcodes; } /** * Verifies that a relocated opcode block still means exactly what the source meant * - * Every IS_CONST operand must resolve to the same zval address it resolved to before the - * move, and every jump offset must still land inside the array. This runs under - * zend.assertions=1 and compiles out in production: a wrong relocation rule is the one - * mistake here that would otherwise surface as memory corruption at some later call rather - * than as a failure at specialization time. + * Every IS_CONST operand must resolve to the copied literal at the very index its source + * operand resolved to (and land zval-aligned inside the copied literal table), and every + * jump offset must still land inside the array. This runs under zend.assertions=1 and + * compiles out in production: a wrong relocation rule is the one mistake here that would + * otherwise surface as memory corruption at some later call rather than as a failure at + * specialization time. * - * @param \FFI\CData $sourceOpcodes - * @param \FFI\CData $copiedOpcodes + * @param \FFI\CData $sourceOpArray + * @param \FFI\CData $opArrayCopy */ private static function opcodeCopyResolvesIdentically( - object $sourceOpcodes, - object $copiedOpcodes, + object $sourceOpArray, + object $opArrayCopy, int $total, int $opcodeSize, ): bool { + $sourceOpcodes = $sourceOpArray->opcodes; + $copiedOpcodes = $opArrayCopy->opcodes; + $totalLiterals = $sourceOpArray->last_literal; + assert($sourceOpcodes instanceof CData && $copiedOpcodes instanceof CData && is_int($totalLiterals)); $sourceBase = Core::addressOf($sourceOpcodes); - $copyBase = Core::addressOf(Core::cast('zend_op *', Core::addr($copiedOpcodes))); + $copyBase = Core::addressOf($copiedOpcodes); + $zvalSize = Core::sizeOfType(zval::class); + + $sourceLiteralsBase = null; + $copyLiteralsBase = null; + if ($totalLiterals > 0) { + $sourceLiterals = $sourceOpArray->literals; + $copiedLiterals = $opArrayCopy->literals; + assert($sourceLiterals instanceof CData && $copiedLiterals instanceof CData); + $sourceLiteralsBase = Core::addressOf($sourceLiterals); + $copyLiteralsBase = Core::addressOf($copiedLiterals); + } for ($index = 0; $index < $total; $index++) { $sourceOpline = $sourceOpcodes[$index]; @@ -1420,10 +1535,19 @@ private static function opcodeCopyResolvesIdentically( $sourceConstant = $sourceOperand->constant; $copiedConstant = $copiedOperand->constant; assert(is_int($sourceConstant) && is_int($copiedConstant)); - // Both are unsigned views of a signed offset, so compare the resolved addresses - $sourceTarget = $sourceBase + $index * $opcodeSize + self::asSignedOffset($sourceConstant); - $copiedTarget = $copyBase + $index * $opcodeSize + self::asSignedOffset($copiedConstant); - if ($sourceTarget !== $copiedTarget) { + // An IS_CONST operand with no literal table to land in cannot be right + if ($sourceLiteralsBase === null || $copyLiteralsBase === null) { + return false; + } + // Both are unsigned views of a signed offset; the literals moved with the + // opcodes, so the resolved targets must sit at the SAME DELTA within their + // respective literal tables - and inside them, on a zval boundary + $sourceDelta = $sourceBase + $index * $opcodeSize + self::asSignedOffset($sourceConstant) - $sourceLiteralsBase; + $copiedDelta = $copyBase + $index * $opcodeSize + self::asSignedOffset($copiedConstant) - $copyLiteralsBase; + if ($sourceDelta !== $copiedDelta) { + return false; + } + if ($copiedDelta < 0 || $copiedDelta >= $totalLiterals * $zvalSize || $copiedDelta % $zvalSize !== 0) { return false; } } diff --git a/tests/Reflection/ClassSpecializerSlotTest.php b/tests/Reflection/ClassSpecializerSlotTest.php index 807aab9f..3d77de20 100644 --- a/tests/Reflection/ClassSpecializerSlotTest.php +++ b/tests/Reflection/ClassSpecializerSlotTest.php @@ -13,7 +13,6 @@ namespace ZEngine\Reflection; -use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use ZEngine\Stub\TestClass; use ZEngine\Stub\TestSlotSpecializationTemplate; @@ -118,12 +117,6 @@ public function testParameterAndReturnTypesAreAddressedIndependently(): void $instance->setNamed('not an int'); } - // TODO(#131): once literals are copied alongside opcodes the 2GB IS_CONST reach limit - // disappears and this test returns to the opcache-active runner job. Until then a - // shared-memory method body always refuses slot substitution (the template class is - // compiled into SHM when the runner itself has opcache active), so the substitution - // this test asserts cannot happen there by design. - #[Group('opcache-incompatible')] public function testBuiltinTypedParameterIsRewrittenAndEnforced(): void { $newName = 'ZEngine\Stub\Specialized\SlotBuiltinParamCopy'; @@ -190,8 +183,6 @@ public function testConstantFoldedReturnCheckIsRejected(): void ])); } - // TODO(#131): same 2GB IS_CONST reach refusal as testBuiltinTypedParameterIsRewrittenAndEnforced - #[Group('opcache-incompatible')] public function testOpcodeCopyPreservesLiteralsAndJumps(): void { $newName = 'ZEngine\Stub\Specialized\SlotOpcodeRelocationCopy'; From c720776a829af89f22c7ff4925f0f952f70d8afe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:16:33 +0000 Subject: [PATCH 04/28] feat(gen): export the zend_inheritance_cache_get/add hook points Add the two ZEND_API extern function pointers from Zend/zend_inheritance.h to the variables manifest and regenerate the linux targets. Opcache installs its shared-memory lookup/publication callbacks into these globals; exporting them lets the runtime intercept the *_add pointer and decline publication of classes that received address-keyed handlers during lazy linking (#241). The darwin/windows artifacts cannot be generated on this machine and are refreshed by their native generate workflows, which trigger on pull requests touching tools/generator/**. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- include/8.4/linux-x64-nts/engine.h | 2 ++ include/8.4/linux-x64-zts/engine.h | 2 ++ tools/generator/symbols.php | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/include/8.4/linux-x64-nts/engine.h b/include/8.4/linux-x64-nts/engine.h index 5fa96e46..cc6d6ca3 100644 --- a/include/8.4/linux-x64-nts/engine.h +++ b/include/8.4/linux-x64-nts/engine.h @@ -1059,4 +1059,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/linux-x64-zts/engine.h b/include/8.4/linux-x64-zts/engine.h index aa2e5590..84710549 100644 --- a/include/8.4/linux-x64-zts/engine.h +++ b/include/8.4/linux-x64-zts/engine.h @@ -1152,4 +1152,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/tools/generator/symbols.php b/tools/generator/symbols.php index 6b952424..4de8c2a8 100644 --- a/tools/generator/symbols.php +++ b/tools/generator/symbols.php @@ -194,6 +194,13 @@ 'zend_error_cb', 'zend_throw_exception_hook', 'zend_interrupt_function', + // Opcache inheritance-cache hook points (Zend/zend_inheritance.h): opcache + // installs its SHM lookup/publication callbacks here. z-engine intercepts + // the *_add pointer to DECLINE publication of classes that received + // address-keyed handlers while linking on a lazy temporary (issue #241), + // which keeps those classes process-local and mutable (issue #238) + 'zend_inheritance_cache_get', + 'zend_inheritance_cache_add', // 32 hex chars identifying the exact engine build (Zend/zend_system_id.h); // opcache stamps it into every file-cache binary header 'zend_system_id', From 4795077ace2f5a07dc6f5b32c6edc411ac9e4bac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:16:52 +0000 Subject: [PATCH 05/28] feat(core): decline inheritance-cache publication for handler-hooked classes Under opcache, a class declared in a cached script links on a temporary mutable copy (zend_lazy_class_load); when linking completes, opcache's zend_inheritance_cache_add persists the linked result into shared memory, the class-table bucket is repointed at the published entry and the temporary dies - together with every z-engine handler keyed to its address. That silently lost handlers installed from an interface_gets_implemented hook (#238), and the interim fix was a loud SharedMemoryException from every installer. The real fix: Core::init() saves the opcache callback and installs an FFI interceptor over the zend_inheritance_cache_add global. Handler installation on a lazy-linking copy now records the temporary's address in a decline set instead of throwing; when that class finishes linking, the interceptor answers NULL - the engine's ordinary "not cached" outcome (opcache itself returns it when SHM is full or a restart is pending) - so the temporary stays in the class table as a process-local, request-lifetime class. The handlers keep firing, the class simply pays re-linking per process instead of cache reuse, and unhooked classes delegate to opcache unchanged. Decline records are consumed on interception and dropped at shutdown(), so the set stays bounded. FPM hazard removal: publishing handlers through the class entry (ce->default_object_handlers) was rejected because it would place a per-process trampoline address into shared memory, where sibling workers would dereference garbage. Declining publication inverts that: nothing belonging to the hooked class ever enters SHM. The interceptor can fire during compile-time early binding, where CG(in_compilation) promotes every thrown exception to an immediate fatal error - its hot path is therefore fully throw-free, using the new Core::pointerAddressOf() (addressOf() minus the throwing array-decay probe) for pointer identity. The SharedMemoryException guard remains only as fallback for platforms whose generated engine definitions predate the exported symbol (darwin/ windows until their generate workflows refresh them). The regression child now proves the #238 semantics end to end: the hook observes the lazy copy, setCreateObjectHandler/setWritePropertyHandler succeed, a property write on a new instance fires the installed handler, the surviving class entry is the very address the handlers were installed on (process-local, not immutable), and an untouched sibling class linking against a hook-free cached interface is still published into the inheritance cache (observable as its entry becoming immutable). Fixes #241 Fixes #238 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/hot-swap.md | 2 +- phpstan-baseline.neon | 12 ++ src/Core.php | 147 ++++++++++++++++++ src/OpCache/SharedMemoryException.php | 16 +- src/Reflection/ReflectionClass.php | 39 +++-- src/System/Hook/InheritanceCacheAddHook.php | 103 ++++++++++++ tests/HotSwap/OpcacheSupportMatrixTest.php | 16 +- .../opcache-interface-hook-fixture.php | 13 +- .../opcache-interface-hook-sibling.php | 23 +++ .../scripts/opcache-interface-hook.php | 68 ++++++-- tests/Reflection/ReflectionClassTest.php | 6 +- 11 files changed, 401 insertions(+), 44 deletions(-) create mode 100644 src/System/Hook/InheritanceCacheAddHook.php create mode 100644 tests/HotSwap/scripts/opcache-interface-hook-sibling.php diff --git a/docs/hot-swap.md b/docs/hot-swap.md index afedaec4..01a53148 100644 --- a/docs/hot-swap.md +++ b/docs/hot-swap.md @@ -128,7 +128,7 @@ covered in [opcache-binary.md](opcache-binary.md). | Immutable **class**: method `redefine()`, `addMethod()`, `removeMethods()`, trait configuration, `HotSwap::prepare()` | Supported via class copy-out: the class entry is deep-copied into request memory with the [class-specialization](class-specialization.md) copy model (own tables and property/constant blocks, method entries duplicated at the `zend_op_array` level with the compiled bodies still shared with SHM), the class-table bucket and the engine's fast class-name cache are repointed at the copy, and the mutation is applied to it. The copy is an ordinary userland class the engine dismantles at request end. | | Immutable **preloaded** class (`ZEND_ACC_PRELOADED`) | Rejected with `SharedMemoryException`: a preloaded class keeps its class-table bucket across the requests of a worker, while the copy lives in request memory - repointing the bucket would leave it dangling for the next request. | | Immutable class the copy machinery does not support (enum/interface/trait, property hooks, internal ancestor or internal methods) | Rejected with `SharedMemoryException` carrying the refusal reason. | -| Class observed **mid-linking** on an opcache lazy-linking temporary (`ZEND_ACC_CACHED` set, `ZEND_ACC_LINKED` clear - the only state an `interface_gets_implemented` hook ever sees for a cached implementor) | Handler installation (`setXxxHandler()`, `installExtensionHandlers()`) rejected with `SharedMemoryException`: handlers are keyed by class-entry address and the temporary is discarded when opcache's inheritance cache persists the linked class, so they would be silently lost ([#238](https://github.com/lisachenko/z-engine/issues/238)). Probe with `ReflectionClass::isLazyLinkingCopy()`; install after linking completes, or run the bootstrap with opcache off. [#241](https://github.com/lisachenko/z-engine/issues/241) tracks making the installation stick by declining the inheritance cache for hooked classes. | +| Class observed **mid-linking** on an opcache lazy-linking temporary (`ZEND_ACC_CACHED` set, `ZEND_ACC_LINKED` clear - the only state an `interface_gets_implemented` hook ever sees for a cached implementor) | Supported via **inheritance-cache decline** ([#241](https://github.com/lisachenko/z-engine/issues/241)): handlers are keyed by class-entry address, and without intervention the temporary would be discarded as soon as opcache's inheritance cache persists the linked class, silently losing them ([#238](https://github.com/lisachenko/z-engine/issues/238)). So handler installation (`setXxxHandler()`, `installExtensionHandlers()`) records the entry, and z-engine's interceptor over `zend_inheritance_cache_add` answers NULL for it when linking completes - the engine's ordinary "not cached" outcome (opcache itself returns it when SHM is full). The temporary then stays in the class table as a process-local, request-lifetime class: the handlers keep firing, and the class simply pays re-linking per process/request instead of being reused from the cache. Unhooked classes delegate to opcache unchanged and keep full cache reuse. FPM-safety: because the hooked class is never published, no per-process trampoline or handlers-block address ever reaches shared memory (publishing the handlers through the class entry instead was rejected for exactly that hazard). Probe with `ReflectionClass::isLazyLinkingCopy()`. Fallback: on a platform whose generated engine definitions predate the `zend_inheritance_cache_add` export, the installation still throws `SharedMemoryException` instead of being silently lost - regenerate with `composer gen-headers`. | | Runtime-declared functions/classes (never in SHM, even with opcache enabled) | Full mutation surface. | | Static variables of an immutable function | Readable: `getStaticVariables()` follows the map-ptr offset slot opcache stores into shared op_arrays and returns the live per-process table once the first call materialized it (the declaration defaults before that). | diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b0e4f10d..2a242d33 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -186,12 +186,24 @@ parameters: count: 1 path: src/Core.php + - + message: '#^Access to an undefined property FFI\:\:\$zend_inheritance_cache_add\.$#' + identifier: property.notFound + count: 1 + path: src/Core.php + - message: '#^Access to an undefined property FFI\:\:\$zend_system_id\.$#' identifier: property.notFound count: 1 path: src/Core.php + - + message: '#^Dead catch \- FFI\\Exception is never thrown in the try block\.$#' + identifier: catch.neverThrown + count: 1 + path: src/Core.php + - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' identifier: foreach.nonIterable diff --git a/src/Core.php b/src/Core.php index 42744494..f315398d 100644 --- a/src/Core.php +++ b/src/Core.php @@ -25,6 +25,7 @@ use ZEngine\System\Executor; use ZEngine\System\Hook\AstProcessHook; use ZEngine\System\Hook\ErrorCallbackHook; +use ZEngine\System\Hook\InheritanceCacheAddHook; use ZEngine\System\Hook\InterruptHook; use ZEngine\Type\HashTable; @@ -250,6 +251,27 @@ class Core */ private static array $generatedFunctions = []; + /** + * Interceptor installed over opcache's zend_inheritance_cache_add callback (issue #241) + * + * Present and installed only when the engine binding exports the symbol AND opcache + * published a callback into it; null otherwise, in which case handler installation on + * a lazy-linking temporary keeps the throw-guard fallback of issue #238. + */ + private static ?InheritanceCacheAddHook $inheritanceCacheAddHook = null; + + /** + * Addresses of temporary lazy-linking class entries whose publication into opcache's + * inheritance cache must be declined, so they stay process-local and their + * address-keyed handlers stay valid (issues #238/#241) + * + * Entries are consumed by the interceptor when linking completes and the whole set is + * dropped on shutdown(), so it never outlives the request that recorded it. + * + * @var array + */ + private static array $declinedInheritanceCachePublications = []; + /** * Whether Core::shutdown() has run: no engine pointers may be written anymore */ @@ -333,10 +355,106 @@ public static function init(): void self::preloadFrameworkClasses(); self::loadEngineConstants(); + self::installInheritanceCacheInterception(); self::$initialized = true; } + /** + * Installs the interceptor over opcache's zend_inheritance_cache_add callback (idempotent) + * + * The interceptor is what lets handler installation on a lazy-linking temporary stick + * (issue #241): a class recorded via declineInheritanceCachePublication() is answered + * with NULL - the engine's ordinary "not cached" outcome - so the temporary stays in + * the class table as a process-local class instead of being replaced by the published + * shared-memory entry. Everything else delegates to the saved opcache callback. + * + * Not installed when the engine binding predates the exported symbol (stale platform + * artifacts - canDeclineInheritanceCachePublication() then reports false and the + * ReflectionClass throw-guard of issue #238 stays in charge) or when the pointer is + * NULL (no opcache in this process - no lazy-linking temporaries can exist either, + * and installing a trampoline while zend_inheritance_cache_get stays NULL would only + * disable the engine's is_cacheable fast path bookkeeping for no benefit). + */ + private static function installInheritanceCacheInterception(): void + { + if (self::$inheritanceCacheAddHook !== null && self::$inheritanceCacheAddHook->isInstalled()) { + return; + } + self::$inheritanceCacheAddHook = null; + try { + /** @var CData|null $originalAdd Untyped read off the FFI binding boundary */ + $originalAdd = self::$engine->zend_inheritance_cache_add; + } catch (FFI\Exception) { + // Engine definitions without the symbol (regenerate with `composer gen-headers`) + return; + } + if ($originalAdd === null) { + return; + } + + $hook = new InheritanceCacheAddHook( + static fn(int $classEntryAddress): bool => self::takeInheritanceCacheDecline($classEntryAddress), + self::$engine, + ); + $hook->install(); + self::$inheritanceCacheAddHook = $hook; + } + + /** + * Checks whether inheritance-cache publication can be declined in this process + * + * True when the zend_inheritance_cache_add interceptor is installed (engine binding + * exports the symbol and opcache published a callback). When false under opcache, + * handler installation on a lazy-linking temporary falls back to the loud + * SharedMemoryException guard of issue #238. + */ + public static function canDeclineInheritanceCachePublication(): bool + { + return self::$inheritanceCacheAddHook !== null && self::$inheritanceCacheAddHook->isInstalled(); + } + + /** + * Records a class entry whose publication into opcache's inheritance cache must be + * declined when its linking completes (issue #241) + * + * Called with the address of the temporary lazy-linking copy - the entry an + * interface_gets_implemented hook observes and installs handlers on. Declined, the + * class stays process-local and mutable (re-linked per request instead of reused + * from the cache), so handlers keyed to its address survive linking and no + * process-local trampoline address ever reaches shared memory. + * + * @param int $classEntryAddress Address of the temporary zend_class_entry (Core::addressOf()) + */ + public static function declineInheritanceCachePublication(int $classEntryAddress): void + { + if (!self::canDeclineInheritanceCachePublication()) { + throw new \LogicException( + 'Inheritance-cache publication cannot be declined: the zend_inheritance_cache_add ' + . 'interceptor is not installed (probe with Core::canDeclineInheritanceCachePublication())', + ); + } + self::$declinedInheritanceCachePublications[$classEntryAddress] = true; + } + + /** + * Consumes one recorded decline for the given class entry address + * + * The entry is removed when found, so the set stays bounded: a hit means the class + * just finished linking and its cache publication is being declined right now. + * + * @internal called by InheritanceCacheAddHook::handle() from inside the engine callback + */ + public static function takeInheritanceCacheDecline(int $classEntryAddress): bool + { + if (!isset(self::$declinedInheritanceCachePublications[$classEntryAddress])) { + return false; + } + unset(self::$declinedInheritanceCachePublications[$classEntryAddress]); + + return true; + } + /** * Preloads definition and Core for ffi.preload mode, should be called during preload stage for better performance * @@ -726,6 +844,29 @@ public static function addressOf(object $pointer): int return (int) self::cast('uintptr_t', $pointer)->cdata; } + /** + * Returns the numeric address of a POINTER CData without any internal throw + * + * Exactly addressOf() for values that are already pointers, minus cast()'s + * array-decay probe, which throws and catches an FFI\Exception per call. Engine + * callbacks that can fire while CG(in_compilation) is set (the intercepted + * zend_inheritance_cache_add runs during compile-time early binding) must not + * throw AT ALL - the engine promotes every thrown exception to an immediate + * fatal error there, before any catch block runs. + * + * @param CData|object $pointer Pointer CData (never a C array); statically + * stub-typed views are accepted + * + * @internal for engine-callback hot paths (InheritanceCacheAddHook) + */ + public static function pointerAddressOf(object $pointer): int + { + assert($pointer instanceof CData); + $address = self::$engine->cast('uintptr_t', $pointer)->cdata; + + return \is_int($address) ? $address : 0; + } + /** * Materializes a typed pointer from a numeric address (the inverse of addressOf()) * @@ -994,6 +1135,12 @@ public static function shutdown(): void // lifetime by design. Dropping the registry only releases the bookkeeping. self::$trackedBlocks = []; + // The interceptor itself was uninstalled by the chain unwind above; pending decline + // records (classes whose linking never completed, e.g. after a bailout) die with + // the request that recorded them + self::$inheritanceCacheAddHook = null; + self::$declinedInheritanceCachePublications = []; + self::$isShutdown = true; } diff --git a/src/OpCache/SharedMemoryException.php b/src/OpCache/SharedMemoryException.php index 8b617f09..d0110a6f 100644 --- a/src/OpCache/SharedMemoryException.php +++ b/src/OpCache/SharedMemoryException.php @@ -83,13 +83,15 @@ public static function functionNotPublished(string $lowerKey): self } /** - * Handler installation targeted a temporary lazy-linking class copy (issue #238) + * Handler installation targeted a temporary lazy-linking class copy while declining + * the inheritance cache is unavailable (issues #238/#241) * * Handlers are tracked per class-entry address; the lazy-linking temporary is * discarded as soon as opcache's inheritance cache persists the linked class, so - * anything installed on it would be silently lost. Throwing here is the loud - * failure until declining the inheritance cache for hooked classes (issue #241) - * makes the installation actually stick. + * anything installed on it would be silently lost. Normally z-engine keeps such a + * class process-local by declining its inheritance-cache publication (issue #241); + * this loud fallback fires only when the interception cannot be installed - engine + * definitions generated before the zend_inheritance_cache_add symbol was exported. */ public static function handlerInstallationDuringLazyLinking(string $className, string $handlerName): self { @@ -97,8 +99,10 @@ public static function handlerInstallationDuringLazyLinking(string $className, s "Cannot install the {$handlerName} handler on {$className}: the class entry is the " . 'temporary copy opcache links classes on (lazy loading for the inheritance cache) ' . 'and discards when linking completes, so the installed handlers would be silently ' - . 'lost (issue #238). Install handlers after the class is fully linked, or run this ' - . 'code path with opcache disabled', + . 'lost (issue #238), and the generated engine definitions of this platform predate ' + . 'the zend_inheritance_cache_add interception that would keep the class process-local ' + . '(issue #241). Regenerate the engine definitions with `composer gen-headers`, install ' + . 'handlers after the class is fully linked, or run this code path with opcache disabled', ); } } diff --git a/src/Reflection/ReflectionClass.php b/src/Reflection/ReflectionClass.php index 447ea918..c457ba79 100644 --- a/src/Reflection/ReflectionClass.php +++ b/src/Reflection/ReflectionClass.php @@ -2645,7 +2645,7 @@ public function setCreateObjectHandler(Closure $handler): CreateObjectHook if ($this->isInternal()) { trigger_error('Create object handler is available for user-defined classes only', E_USER_ERROR); } - $this->assertNotLazyLinkingCopy('create_object'); + $this->keepLazyLinkingCopyProcessLocal('create_object'); self::getObjectHandlers($this->pointer); $hook = new CreateObjectHook($handler, $this->pointer); @@ -2668,7 +2668,7 @@ public function setCreateObjectHandler(Closure $handler): CreateObjectHook */ public function setGetIteratorHandler(Closure $handler): GetIteratorHook { - $this->assertNotLazyLinkingCopy('get_iterator'); + $this->keepLazyLinkingCopyProcessLocal('get_iterator'); $hook = new GetIteratorHook($handler, $this->pointer); $hook->install(); @@ -2687,7 +2687,7 @@ public function setInterfaceGetsImplementedHandler(Closure $handler): InterfaceG } // An interface entry can itself be the lazy temporary while it links against // its own parent interfaces - $this->assertNotLazyLinkingCopy('interface_gets_implemented'); + $this->keepLazyLinkingCopyProcessLocal('interface_gets_implemented'); $hook = new InterfaceGetsImplementedHook($handler, $this->pointer); $hook->install(); @@ -2711,7 +2711,7 @@ public function setInterfaceGetsImplementedHandler(Closure $handler): InterfaceG */ private function installObjectHook(string $hookClass, Closure $handler): AbstractHook { - $this->assertNotLazyLinkingCopy($hookClass); + $this->keepLazyLinkingCopyProcessLocal($hookClass); $handlers = self::getObjectHandlers($this->pointer); $hook = new $hookClass($handler, $handlers); @@ -2721,23 +2721,36 @@ private function installObjectHook(string $hookClass, Closure $handler): Abstrac } /** - * Rejects handler installation on a temporary lazy-linking class copy (issue #238) + * Makes handler installation on a temporary lazy-linking class copy stick (issue #241) * - * The handlers block is keyed to this entry's address; the temporary is discarded - * when opcache's inheritance cache persists the linked class, so the installation - * would silently do nothing. Probe with isLazyLinkingCopy() before installing from - * an interface_gets_implemented hook. Issue #241 (declining the inheritance cache - * for hooked classes) is the path to making this installation actually work. + * The handlers block is keyed to this entry's address; without intervention the + * temporary is discarded as soon as opcache's inheritance cache persists the linked + * class, silently losing every installed handler (issue #238). So the entry is + * recorded in the Core decline set: when its linking completes, the intercepted + * zend_inheritance_cache_add answers NULL (the engine's ordinary "not cached" + * outcome) and the temporary stays in the class table as a process-local class - + * the handlers remain valid for the request, the class is simply re-linked per + * process instead of reused from the cache, and no process-local trampoline + * address ever reaches shared memory. + * + * When the interception is unavailable (engine definitions generated before the + * zend_inheritance_cache_add symbol was exported), the loud guard of issue #238 + * remains: the installation throws instead of being silently lost. Probe with + * isLazyLinkingCopy() before installing from an interface_gets_implemented hook. * * @param string $handlerName Handler field or hook class named in the diagnostic * - * @throws SharedMemoryException + * @throws SharedMemoryException When declining is unavailable in this process */ - private function assertNotLazyLinkingCopy(string $handlerName): void + private function keepLazyLinkingCopyProcessLocal(string $handlerName): void { - if ($this->isLazyLinkingCopy()) { + if (!$this->isLazyLinkingCopy()) { + return; + } + if (!Core::canDeclineInheritanceCachePublication()) { throw SharedMemoryException::handlerInstallationDuringLazyLinking($this->getName(), $handlerName); } + Core::declineInheritanceCachePublication(Core::addressOf($this->pointer)); } /** diff --git a/src/System/Hook/InheritanceCacheAddHook.php b/src/System/Hook/InheritanceCacheAddHook.php new file mode 100644 index 00000000..b481e54c --- /dev/null +++ b/src/System/Hook/InheritanceCacheAddHook.php @@ -0,0 +1,103 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\System\Hook; + +use FFI\CData; +use ZEngine\Core; +use ZEngine\Hook\AbstractHook; + +/** + * Interceptor for the `zend_inheritance_cache_add` engine global callback (issue #241) + * + * Opcache publishes this pointer so `zend_do_link_class()`/`zend_try_early_bind()` can + * persist a freshly linked class into the shared-memory inheritance cache. The engine + * links such a class on a temporary mutable copy (`zend_lazy_class_load()`); when the + * publication succeeds, the caller swaps the class-table bucket to the returned + * shared-memory entry and the temporary is discarded - together with every z-engine + * handler keyed to the temporary's address (issue #238). + * + * This hook makes handler installation during lazy linking stick: for a class entry + * recorded in the Core decline set it returns NULL, which the engine treats as an + * ordinary "not cached" outcome (opcache itself returns NULL when SHM is full or a + * restart is pending) - the temporary stays in the class table as a process-local, + * request-lifetime class, so the address-keyed handlers remain valid and no + * process-local trampoline address is ever published into shared memory. Every other + * class is delegated to the saved opcache callback unchanged. + * + * The callback runs DURING class linking - and, through compile-time early binding, + * possibly while CG(in_compilation) is set, where the engine promotes EVERY thrown + * exception to an immediate fatal error before any catch runs (see AstProcessHook). + * So handle() is deliberately minimal AND throw-free on its hot path: no file + * inclusion, no engine mutation, and in particular no Core::addressOf()/cast(), + * whose array-decay probe throws-and-catches an FFI\Exception per call. Any + * unexpected internal failure degrades to declining the publication, which is + * always safe (the class simply stays process-local and is re-linked per request). + */ +final class InheritanceCacheAddHook extends AbstractHook +{ + protected const string HOOK_FIELD = 'zend_inheritance_cache_add'; + + /** + * zend_class_entry *(*zend_inheritance_cache_add)( + * zend_class_entry *ce, zend_class_entry *proto, zend_class_entry *parent, + * zend_class_entry **traits_and_interfaces, HashTable *dependencies); + * + * `ce` is the temporary linked copy (ZEND_ACC_LINKED set, ZEND_ACC_IMMUTABLE + * clear - see the asserts in opcache's zend_accel_inheritance_cache_add) and + * `proto` is the shared-memory original the temporary was loaded from, so the + * decline set is keyed by the address of `ce`: that is exactly the entry an + * interface_gets_implemented hook observed and installed handlers on. + * + * The user handler is the decline predicate `function (int $ceAddress): bool` + * (Core::takeInheritanceCacheDecline()); it must not throw and must not touch + * engine state. + * + * @inheritDoc + * @return CData|null zend_class_entry* of the published SHM entry, or null when + * the class was not cached (declined or refused by opcache) + */ + #[\Override] + public function handle(...$rawArguments): ?CData + { + [$classEntry, $prototype, $parent, $traitsAndInterfaces, $dependencies] = $rawArguments; + + try { + assert($classEntry instanceof CData); + // Throw-free pointer identity: a direct reinterpreting cast, unlike + // Core::addressOf(), whose array-decay probe throws internally - fatal + // when this callback fires during compile-time early binding + if (($this->userHandler)(Core::pointerAddressOf($classEntry)) === true) { + // Declined: the engine keeps the process-local temporary in the class table + return null; + } + if (!$this->hasOriginalHandler()) { + return null; + } + $publishedEntry = ($this->getOriginalCallable())( + $classEntry, + $prototype, + $parent, + $traitsAndInterfaces, + $dependencies, + ); + assert($publishedEntry === null || $publishedEntry instanceof CData); + + return $publishedEntry; + } catch (\Throwable) { + // Nothing may escape an engine callback that runs mid-linking (issue #50); + // declining the publication is the always-safe degradation + return null; + } + } +} diff --git a/tests/HotSwap/OpcacheSupportMatrixTest.php b/tests/HotSwap/OpcacheSupportMatrixTest.php index b285879e..f9c78246 100644 --- a/tests/HotSwap/OpcacheSupportMatrixTest.php +++ b/tests/HotSwap/OpcacheSupportMatrixTest.php @@ -51,13 +51,14 @@ public function testSharedMemorySupportMatrix(): void } /** - * The loud guard of issue #238: handlers installed from an interface_gets_implemented - * hook target the temporary class entry opcache links classes on, and the temporary is - * discarded once the inheritance cache persists the linked result - so the installation - * must throw instead of being silently lost (issue #241 tracks making it stick by - * declining the inheritance cache for hooked classes) + * The fix of issue #238 (via issue #241): handlers installed from an + * interface_gets_implemented hook target the temporary class entry opcache links + * classes on. z-engine records that entry and declines its publication into the + * inheritance cache (the intercepted zend_inheritance_cache_add answers NULL), so + * the temporary stays process-local, the handlers survive linking and actually fire + * - while an untouched sibling class is still published into the cache unchanged */ - public function testHandlerInstallationDuringLazyLinkingIsRejected(): void + public function testHandlersInstalledDuringLazyLinkingSurviveViaCacheDecline(): void { [$exitCode, $stdout, $report] = $this->runOpcacheChild( __DIR__ . '/scripts/opcache-interface-hook.php', @@ -68,7 +69,8 @@ public function testHandlerInstallationDuringLazyLinkingIsRejected(): void ); self::assertSame(0, $exitCode, "Interface-hook child exited with code {$exitCode}\n{$report}"); - self::assertStringContainsString('lazy-linking-guard: ok', $stdout, $report); + self::assertStringContainsString('lazy-linking-handlers: ok', $stdout, $report); + self::assertStringContainsString('sibling-cache-reuse: ok', $stdout, $report); self::assertStringContainsString('INTERFACE HOOK OK', $stdout, $report); } diff --git a/tests/HotSwap/scripts/opcache-interface-hook-fixture.php b/tests/HotSwap/scripts/opcache-interface-hook-fixture.php index 21451236..eab0a988 100644 --- a/tests/HotSwap/scripts/opcache-interface-hook-fixture.php +++ b/tests/HotSwap/scripts/opcache-interface-hook-fixture.php @@ -12,8 +12,15 @@ declare(strict_types=1); // Loaded by opcache-interface-hook.php in a child process with opcache enabled. -// Deliberately declares ONLY the interface: the implementor lives in its own -// cached file, so linking it against this interface goes through the opcache -// inheritance cache (the lazy-linking path of issue #238). +// Deliberately declares ONLY interfaces: the implementors live in their own +// cached files, so linking them against these interfaces goes through the +// opcache inheritance cache (the lazy-linking path of issue #238). +// The hooked interface: an interface_gets_implemented handler is installed on it, +// so its implementor receives handlers mid-linking and must be kept process-local +// by declining its inheritance-cache publication (issue #241) interface ZEngineShmHookInterface {} + +// The untouched control: no hook, no handlers - its implementor must still be +// published into the inheritance cache (the interception delegates to opcache) +interface ZEngineShmHookUntouchedInterface {} diff --git a/tests/HotSwap/scripts/opcache-interface-hook-sibling.php b/tests/HotSwap/scripts/opcache-interface-hook-sibling.php new file mode 100644 index 00000000..f5f73348 --- /dev/null +++ b/tests/HotSwap/scripts/opcache-interface-hook-sibling.php @@ -0,0 +1,23 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +// Loaded by opcache-interface-hook.php AFTER the hooked implementor: this class +// links against the UNTOUCHED cached interface, receives no handlers, and must +// therefore still be published into the opcache inheritance cache - proof that +// the zend_inheritance_cache_add interception (issue #241) declines publication +// only for the classes actually recorded in the decline set. + +class ZEngineShmHookSibling implements ZEngineShmHookUntouchedInterface +{ + public int $value = 1; +} diff --git a/tests/HotSwap/scripts/opcache-interface-hook.php b/tests/HotSwap/scripts/opcache-interface-hook.php index 922b2631..c42ca4ed 100644 --- a/tests/HotSwap/scripts/opcache-interface-hook.php +++ b/tests/HotSwap/scripts/opcache-interface-hook.php @@ -13,8 +13,8 @@ use ZEngine\ClassExtension\Hook\InterfaceGetsImplementedHook; use ZEngine\ClassExtension\Hook\WritePropertyHook; +use ZEngine\ClassExtension\ObjectCreateTrait; use ZEngine\Core; -use ZEngine\OpCache\SharedMemoryException; use ZEngine\Reflection\ReflectionClass; require __DIR__ . '/../../../vendor/autoload.php'; @@ -53,10 +53,16 @@ exit(2); } +if (!Core::canDeclineInheritanceCachePublication()) { + // The engine binding of this platform must export zend_inheritance_cache_add: + // without the interception the handlers installed below would be silently lost + $fail('inheritance-cache decline is unavailable (stale engine definitions? run composer gen-headers)'); +} + $observations = [ 'hook-fired' => false, 'lazy-copy' => false, - 'guard-thrown' => false, + 'lazy-address' => 0, 'unexpected' => '', ]; $refInterface->setInterfaceGetsImplementedHandler( @@ -65,13 +71,19 @@ static function (InterfaceGetsImplementedHook $hook) use (&$observations): int { // Nothing may throw OUT of an engine callback (issue #50): every outcome is // recorded and asserted after the linking completed try { - $implementor = $hook->getClass(); - $observations['lazy-copy'] = $implementor->isLazyLinkingCopy(); + $implementor = $hook->getClass(); + $observations['lazy-copy'] = $implementor->isLazyLinkingCopy(); + $observations['lazy-address'] = $implementor->getAddress(); + // The issue #238 reproducer: handlers installed mid-linking must actually + // stick - the decline of the inheritance-cache publication (issue #241) + // keeps this very class entry process-local, so both the create_object + // slot and the address-keyed handlers block stay valid after linking + $implementor->setCreateObjectHandler(Closure::fromCallable([ObjectCreateTrait::class, '__init'])); $implementor->setWritePropertyHandler(static function (WritePropertyHook $propertyHook): mixed { - return $propertyHook->getValue(); + $written = $propertyHook->getValue(); + + return is_int($written) ? $written * 2 : $written; }); - } catch (SharedMemoryException) { - $observations['guard-thrown'] = true; } catch (\Throwable $throwable) { $observations['unexpected'] = $throwable::class . ': ' . $throwable->getMessage(); } @@ -81,6 +93,7 @@ static function (InterfaceGetsImplementedHook $hook) use (&$observations): int { ); require __DIR__ . '/opcache-interface-hook-implementor.php'; +require __DIR__ . '/opcache-interface-hook-sibling.php'; if ($observations['unexpected'] !== '') { $fail("unexpected failure inside the interface hook: {$observations['unexpected']}"); @@ -90,14 +103,47 @@ static function (InterfaceGetsImplementedHook $hook) use (&$observations): int { } if (!$observations['lazy-copy']) { // The hook observed an ordinary entry: opcache's lazy-linking path (inheritance - // cache) did not engage, so the guard has nothing to reject here. The parent + // cache) did not engage, so the decline has nothing to keep alive here. The parent // passes opcache.file_update_protection=0 exactly to prevent the usual cause - // freshly checked-out files being refused by the cache fwrite(STDERR, "the implementor was not observed as a lazy-linking copy\n"); exit(2); } -if (!$observations['guard-thrown']) { - $fail('handler installation on the lazy-linking copy was not rejected (issue #238 would silently lose it)'); + +// The declined class must still be the very entry the hook installed handlers on: +// process-local (not swapped for a published shared-memory copy) and fully linked +$refImplementor = new ReflectionClass(ZEngineShmHookImplementor::class); +if ($refImplementor->getAddress() !== $observations['lazy-address']) { + $fail('the class table no longer publishes the entry the handlers were installed on (publication was not declined)'); +} +if ($refImplementor->isImmutable()) { + $fail('the implementor was published into shared memory although its publication should have been declined'); +} +if ($refImplementor->isLazyLinkingCopy()) { + $fail('the implementor entry never finished linking (ZEND_ACC_LINKED is still clear)'); } -echo "lazy-linking-guard: ok\n"; + +// ...and the handlers installed mid-linking actually fire: the write_property +// handler doubles every integer written to the instance (this exact interaction +// was silently lost before the decline - issue #238). The write runs behind an +// opaque boundary: the installed handler decides the stored value at runtime, +// which no analyser can see +$readBack = static fn(ZEngineShmHookImplementor $subject): int => $subject->value; +$instance = new ZEngineShmHookImplementor(); +$instance->value = 21; +if ($readBack($instance) !== 42) { + $fail('the write_property handler installed during lazy linking did not fire (value: ' . $readBack($instance) . ')'); +} +echo "lazy-linking-handlers: ok\n"; + +// The untouched sibling must NOT pay for the decline: its publication delegates to +// opcache unchanged, so its class-table entry is the immutable shared-memory copy +// the inheritance cache returned +$refSibling = new ReflectionClass(ZEngineShmHookSibling::class); +if ($refSibling->isImmutable()) { + echo "sibling-cache-reuse: ok\n"; +} else { + $fail('the untouched sibling class was not published into the inheritance cache (the interception over-declined)'); +} + echo "INTERFACE HOOK OK\n"; diff --git a/tests/Reflection/ReflectionClassTest.php b/tests/Reflection/ReflectionClassTest.php index 3a3d4ab9..78bbbc7c 100644 --- a/tests/Reflection/ReflectionClassTest.php +++ b/tests/Reflection/ReflectionClassTest.php @@ -292,9 +292,9 @@ public function testHandlersInstalledFromInterfaceHookFireWithoutOpcache(): void { if (function_exists('opcache_get_status') && opcache_get_status(false) !== false) { self::markTestSkipped( - 'With opcache active in the runner the implementor links on a lazy-linking copy and ' - . 'handler installation is rejected (issue #238): that shape is covered by ' - . 'OpcacheSupportMatrixTest::testHandlerInstallationDuringLazyLinkingIsRejected', + 'With opcache active in the runner the implementor links on a lazy-linking copy ' + . '(this test asserts the plain non-opcache shape): that shape is covered by ' + . 'OpcacheSupportMatrixTest::testHandlersInstalledDuringLazyLinkingSurviveViaCacheDecline', ); } From 22d1c48a037e53f28882718ef53fa7b13c938e83 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:20:18 +0000 Subject: [PATCH 06/28] Generate windows FFI engine definitions for PHP 8.4 (#59) --- include/8.4/windows-x64-nts/engine.h | 2 ++ include/8.4/windows-x64-zts/engine.h | 2 ++ 2 files changed, 4 insertions(+) diff --git a/include/8.4/windows-x64-nts/engine.h b/include/8.4/windows-x64-nts/engine.h index 679d8733..fa5d133c 100644 --- a/include/8.4/windows-x64-nts/engine.h +++ b/include/8.4/windows-x64-nts/engine.h @@ -1074,4 +1074,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/windows-x64-zts/engine.h b/include/8.4/windows-x64-zts/engine.h index 9cc19b64..b520220e 100644 --- a/include/8.4/windows-x64-zts/engine.h +++ b/include/8.4/windows-x64-zts/engine.h @@ -1077,4 +1077,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; From 13d5bab372e5ecd8a82dcca6f23c1d8c1892d43b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:23:45 +0000 Subject: [PATCH 07/28] fix(reflection): plateau redefine under opcache - inline caveat + flat SHM-donor swaps Root cause of the "first redefine() does not take effect" failure (the plateau child with opcache active): plateau_function()'s body was a single `return 'original';`, and when opcache caches a script its optimizer INLINES every same-file call to such a trivial constant-returning function (zend_try_inline_call, Zend/Optimizer/optimize_func_calls.c, optimizer pass 4 of the default opcache.optimization_level) - the dispatch call sites were replaced by the literal at cache time, so they did not exist at runtime and no redefine could ever reach them. The separate-file matrix leg stayed green because the optimizer cannot resolve a callee outside the script it compiles. This is a compile-time transformation, not a resolution path the copy-out could repoint: it is now a documented copy-out caveat in docs/hot-swap.md, together with its warm-cache twin - a caller whose run-time cache already resolved the shared-memory entry before the copy-out keeps dispatching it, because copy-out redirects name resolution only (the rule the class copy-out has always documented; the method leg additionally tripped over the "instances created before the copy-out keep the shared class entry" caveat). The library bug the same run exposed: destroyPreviousBody() returned early for a previous body without a refcount (a body shared with opcache SHM, e.g. any donor closure declared in a cached file), leaking the swap-minted HEAP_RT_CACHE run-time cache and a statics defaults duplicate on every swap - 16 bytes/cycle in the fixed-donor plateau series. destroy_op_array frees exactly those per-entry resources BEFORE its refcount check and returns without touching the shared arrays, so the destroy path now runs it for both lifetime classes instead of bailing out. Tests make the original failure shape a permanent regression test: - redefine-plateau.php returns a runtime-defined constant (no call site can be inlined at cache time) and instantiates PlateauClass inside the method dispatch (created after the first redefine's class copy-out); - RedefineLeakPlateauTest pins the child to opcache ON (jit off, file_update_protection=0), so every suite exercises the same-file first-redefine copy-out path; measured overheads stay 0 and the fixed-donor series is flat again (was +16000 bytes per 1000 cycles); - the opcache support matrix gains a same-file-redefine leg (a cold same-file call site must observe the writable copy through the repointed bucket) and an inlined-call-site-limitation leg pinning the documented pass-4 behavior, with the child's optimization_level pinned to the default pipeline. Fixes #242 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/hot-swap.md | 38 ++++++++++-- src/Reflection/FunctionBodySwap.php | 59 +++++++++++------- tests/HotSwap/OpcacheSupportMatrixTest.php | 17 +++++- tests/HotSwap/RedefineLeakPlateauTest.php | 24 ++++++-- tests/HotSwap/scripts/opcache-matrix.php | 70 ++++++++++++++++++++++ tests/HotSwap/scripts/redefine-plateau.php | 22 +++++-- 6 files changed, 191 insertions(+), 39 deletions(-) diff --git a/docs/hot-swap.md b/docs/hot-swap.md index afedaec4..893d1a8f 100644 --- a/docs/hot-swap.md +++ b/docs/hot-swap.md @@ -124,7 +124,7 @@ covered in [opcache-binary.md](opcache-binary.md). | Target | Behaviour | |--------|-----------| -| Immutable **global function** + `redefine()` | Supported via copy-out: the per-process function-table bucket is repointed at a writable `zend_function` copy; the SHM original stays untouched and allocated. The first swap does not destroy the previous (SHM) body; later swaps behave normally. | +| Immutable **global function** + `redefine()` | Supported via copy-out: the per-process function-table bucket is repointed at a writable `zend_function` copy; the SHM original stays untouched and allocated. The first swap does not destroy the previous (SHM) body; later swaps behave normally. Only *name resolution* is redirected - call sites that already resolved the function, and call sites the optimizer inlined at cache time, keep the original body (see the copy-out caveats). | | Immutable **class**: method `redefine()`, `addMethod()`, `removeMethods()`, trait configuration, `HotSwap::prepare()` | Supported via class copy-out: the class entry is deep-copied into request memory with the [class-specialization](class-specialization.md) copy model (own tables and property/constant blocks, method entries duplicated at the `zend_op_array` level with the compiled bodies still shared with SHM), the class-table bucket and the engine's fast class-name cache are repointed at the copy, and the mutation is applied to it. The copy is an ordinary userland class the engine dismantles at request end. | | Immutable **preloaded** class (`ZEND_ACC_PRELOADED`) | Rejected with `SharedMemoryException`: a preloaded class keeps its class-table bucket across the requests of a worker, while the copy lives in request memory - repointing the bucket would leave it dangling for the next request. | | Immutable class the copy machinery does not support (enum/interface/trait, property hooks, internal ancestor or internal methods) | Rejected with `SharedMemoryException` carrying the refusal reason. | @@ -138,9 +138,10 @@ modes stay distinguishable. ### Copy-out caveats -A copy-out changes which `zend_class_entry` the class name resolves to, and -only *resolution* is redirected - structures that captured the shared entry -earlier keep it. Copy out (or mutate) at bootstrap, before such state exists: +A copy-out changes which structure (`zend_class_entry`, `zend_function`) the +*name* resolves to, and only resolution is redirected - structures that +captured the shared entry earlier keep it. Copy out (or mutate) at bootstrap, +before such state exists: - **Instances created before the copy-out** keep the shared class entry in `obj->ce`: they dispatch the old method bodies and are not `instanceof` the @@ -155,6 +156,25 @@ earlier keep it. Copy out (or mutate) at bootstrap, before such state exists: call site spelling the class the way it was declared uses; a call site that already resolved the class through another spelling (`new foo\bar()` for `Foo\Bar`) in this request keeps the shared entry. +- **Call sites that already resolved the function**: the engine memoizes the + resolved `zend_function*` in the caller's run-time cache the first time a + call site executes. A caller that already called the function in this + request keeps dispatching the shared-memory entry after a function copy-out + (the function-side twin of the "instances created before the copy-out" + rule). Redefine at bootstrap, before the call sites warm up. +- **Optimizer-inlined call sites** + ([#242](https://github.com/lisachenko/z-engine/issues/242)): when opcache + caches a script, its optimizer *inlines* a same-file call to a function + whose body merely returns a literal - the call site is replaced by the + constant (`zend_try_inline_call`, optimizer pass 4, part of the default + `opcache.optimization_level`), and method calls can be folded the same way + when the optimizer proves the receiver's class. Such call sites do not exist + in the compiled code at all, so no `redefine()` - before or after they run - + can ever affect them; only truly dynamic calls (`$name()`, a runtime + callable) always resolve at runtime. Give a redefine target a body the + optimizer cannot pre-evaluate (for example, return a runtime-defined + constant), declare it in a different file than its callers, or mask the pass + out (`opcache.optimization_level=0x7FFEBFF7`). - **Per-request only**: the copy dies with the request, and the next request of the same worker starts from the shared-memory class again - apply the mutation on every request (bootstrap), exactly like for a runtime-declared @@ -164,6 +184,9 @@ earlier keep it. Copy out (or mutate) at bootstrap, before such state exists: - `redefine()` and `ClassDelta` **body swaps are memory-flat**: each swap releases the previous body, its heap run-time cache and its static tables. + This holds for donors declared in opcache-cached files too: their compiled + arrays live in shared memory and are never freed, but the per-entry heap + run-time cache and statics duplicate minted by the swap are released. - Each `HotSwap::prepare()` costs one class compilation. The engine allocates the op_array/class-entry **containers** from the request arena, which is only reclaimed at request end (~1 KiB per prepare for a small class, the @@ -176,8 +199,11 @@ earlier keep it. Copy out (or mutate) at bootstrap, before such state exists: ## Interactions to be aware of - **Run-time cache invalidation**: swapped entries always get a fresh cache; - caches of *other* functions that call the swapped one are untouched but safe, - because the entry pointer (what those caches store) is preserved. + caches of *other* functions that call the swapped one are untouched but safe + for in-place swaps, because the entry pointer (what those caches store) is + preserved. The shared-memory copy-out path publishes a *new* pointer instead, + so callers that already resolved the old one keep it - see the copy-out + caveats above. - **`Closure::fromCallable()` over a later-swapped method**: fake closures share the old body and keep it alive through its refcount; they continue to execute the old body (and its static variables) until released. diff --git a/src/Reflection/FunctionBodySwap.php b/src/Reflection/FunctionBodySwap.php index 9c91372f..0c543e33 100644 --- a/src/Reflection/FunctionBodySwap.php +++ b/src/Reflection/FunctionBodySwap.php @@ -41,8 +41,11 @@ * defaults; the live per-entry table materializes lazily on the first * ZEND_BIND_STATIC, exactly like a plain compiled function. * - The previous body is destroyed with engine semantics (destroy_op_array) when - * the swap is committed - unless it lives in opcache shared memory, which is - * never freed. The entry keeps its owned reference on the function name; + * the swap is committed. A previous body living in opcache shared memory keeps + * its compiled arrays (SHM is never freed), but the per-entry resources the + * swap minted for it (heap run-time cache, statics defaults duplicate) are + * still released, so swaps stay memory-flat with shared-memory donors too. + * The entry keeps its owned reference on the function name; * everything exclusively owned by the old body (opcodes, literals, vars, * arg_info, static variables, heap run-time cache) is released, and bodies still * shared with someone else (a template op_array, a fake closure) survive through @@ -436,37 +439,45 @@ public static function destroyPreviousBody(object $previousBody, int $entryAddre { $previousFunction = ReflectionFunction::fromCData(Core::cast('zend_function *', Core::addr($previousBody))); $previousOpArray = $previousFunction->getOpArrayPointer(); - $refCountPointer = $previousOpArray->refcount; - if ($refCountPointer === null) { - // No refcount means an opcache-shared body: never destroyed (and the swap - // paths never destroy SHM bodies in the first place) - return; - } if (self::hasLiveFrame($entryAddress)) { // A frame of this very entry is still executing the previous opcodes (the - // function redefined itself, directly or through a callee): freeing them - // would pull memory out from under the running VM frame. The previous body - // stays allocated instead - bounded to one body per such in-flight + // function redefined itself, directly or through a callee): freeing them - + // or the run-time cache the frame reads its inline caches from - would pull + // memory out from under the running VM frame. The previous body stays + // allocated instead - bounded to one body per such in-flight // redefinition, see docs/hot-swap.md. return; } - // All bucket shares move to the new body: drop every previous share except the - // one that destroy_op_array below releases itself - $referenceCount = self::counterValue($refCountPointer); - if ($releasedShares > 1) { - assert($referenceCount >= $releasedShares); - $referenceCount = $referenceCount - ($releasedShares - 1); - $refCountPointer[0] = $referenceCount; + // A body without a refcount is opcache-shared: its compiled arrays live in + // shared memory and are never freed (destroy_op_array below returns before + // touching them), so it can never be the "last holder". The per-entry + // resources the swap minted for it - the HEAP_RT_CACHE run-time cache and a + // statics defaults duplicate - are ordinary request memory though, and are + // released below exactly like for a refcounted body, or repeated swaps whose + // donors were declared in a cached file leak one cache per swap (the + // fixed-donor plateau of issue #242 under opcache). + $refCountPointer = $previousOpArray->refcount; + $isLastHolder = false; + if ($refCountPointer !== null) { + // All bucket shares move to the new body: drop every previous share except + // the one that destroy_op_array below releases itself + $referenceCount = self::counterValue($refCountPointer); + if ($releasedShares > 1) { + assert($referenceCount >= $releasedShares); + $referenceCount = $referenceCount - ($releasedShares - 1); + $refCountPointer[0] = $referenceCount; + } + $isLastHolder = $referenceCount <= 1; } - $isLastHolder = $referenceCount <= 1; $rawOpArray = Core::cast('zend_op_array *', Core::addr($previousBody)); if ($isLastHolder) { // Frees the materialized live static-variables table (if any) and clears - // the map slot; with other holders alive the table must survive - fake - // closures over the old body still reference it through their map slots + // the map slot; with other holders alive (or unaccountable, as for a + // refcount-less body) the table must survive - fake closures over the old + // body still reference it through their map slots Core::call('zend_destroy_static_vars', $rawOpArray); } @@ -485,7 +496,11 @@ public static function destroyPreviousBody(object $previousBody, int $entryAddre } // The entry keeps the single owned reference on the name - the snapshot must - // not release it + // not release it. destroy_op_array releases the HEAP_RT_CACHE run-time cache + // (and would release the name) BEFORE its refcount check, then frees the body + // arrays for the last holder of a refcounted body and returns without touching + // the arrays of a refcount-less (opcache-shared) one - engine-exact semantics + // for both lifetime classes. $previousOpArray->function_name = null; Core::call('destroy_op_array', $rawOpArray); } diff --git a/tests/HotSwap/OpcacheSupportMatrixTest.php b/tests/HotSwap/OpcacheSupportMatrixTest.php index b285879e..4ed604cf 100644 --- a/tests/HotSwap/OpcacheSupportMatrixTest.php +++ b/tests/HotSwap/OpcacheSupportMatrixTest.php @@ -28,6 +28,8 @@ * copied out of shared memory and the mutation applies to the writable copy, * while the shared-memory original stays byte-for-byte untouched * - runtime-declared classes keep the full mutation surface under opcache + * - same-file callers observe a redefine through the repointed bucket, and the + * optimizer-inlined trivial-constant call sites stay baked (issue #242) * * The child exit code doubles as the shutdown check of issue #41, whose original * symptom was a zend_function_dtor() assertion failure and a SIGABRT while the @@ -38,7 +40,18 @@ class OpcacheSupportMatrixTest extends TestCase { public function testSharedMemorySupportMatrix(): void { - [$exitCode, $stdout, $report] = $this->runOpcacheChild(__DIR__ . '/scripts/opcache-matrix.php'); + [$exitCode, $stdout, $report] = $this->runOpcacheChild( + __DIR__ . '/scripts/opcache-matrix.php', + [ + // The same-file legs (issue #242) need the matrix script itself in shared + // memory: the default opcache.file_update_protection=2 refuses files + // modified less than 2s before the request (a fresh checkout) + '-d', 'opcache.file_update_protection=0', + // The inlined-call-site leg asserts the behavior of the default optimizer + // pipeline (zend_try_inline_call runs in pass 4): pin it against php.ini + '-d', 'opcache.optimization_level=0x7FFEBFFF', + ], + ); self::assertSame(0, $exitCode, "Opcache matrix child exited with code {$exitCode}\n{$report}"); self::assertStringContainsString('function-copy-out: ok', $stdout, $report); @@ -47,6 +60,8 @@ public function testSharedMemorySupportMatrix(): void self::assertStringContainsString('hot-swap: ok', $stdout, $report); self::assertStringContainsString('runtime-class-swap: ok', $stdout, $report); self::assertStringContainsString('static-vars-live-table: ok', $stdout, $report); + self::assertStringContainsString('same-file-redefine: ok', $stdout, $report); + self::assertStringContainsString('inlined-call-site-limitation: ok', $stdout, $report); self::assertStringContainsString('MATRIX OK', $stdout, $report); } diff --git a/tests/HotSwap/RedefineLeakPlateauTest.php b/tests/HotSwap/RedefineLeakPlateauTest.php index a688cf07..7c822f17 100644 --- a/tests/HotSwap/RedefineLeakPlateauTest.php +++ b/tests/HotSwap/RedefineLeakPlateauTest.php @@ -29,6 +29,11 @@ * * Before the fix the function/method series grew by a full function body per cycle * over the baseline; with the previous body destroyed the redefine cost is zero. + * + * The child pins opcache ON (issue #242): the measured functions live in the same + * cached script as their dispatch call sites, so the run doubles as the regression + * test for the first-redefine-under-opcache failure, and the fixed-donor series + * guards the per-swap run-time-cache release for shared-memory donor bodies. */ class RedefineLeakPlateauTest extends TestCase { @@ -57,11 +62,20 @@ public function testThousandRedefineCyclesAreMemoryFlat(): void '-d', 'display_errors=on', '-d', 'error_reporting=-1', '-d', 'memory_limit=512M', - // Pinned off so the measurement is hermetic whatever php.ini says (an - // opcache-active runner, or Ubuntu's PHP 8.5 default opcache.enable_cli=On). - // TODO(#242): with opcache active in this child the very first redefine() - // does not take effect ("warm-up dispatch failed") - unpin once fixed - '-d', 'opcache.enable_cli=0', + // Pinned ON (never inherited from php.ini) so the child deterministically + // exercises the issue #242 regression shape: the measured functions are + // declared in the same cached script as their dispatch call sites, and the + // first redefine() runs the shared-memory copy-out path. Where the opcache + // extension is not loaded these switches are inert and the child measures + // the plain in-place path, as before. + '-d', 'opcache.enable=1', + '-d', 'opcache.enable_cli=1', + // The JIT rewrites the executor internals z-engine hooks into (AGENTS.md) + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + // A fresh checkout must still publish the script from shared memory (the + // default opcache.file_update_protection=2 refuses files modified <2s ago) + '-d', 'opcache.file_update_protection=0', __DIR__ . '/scripts/redefine-plateau.php', ]; $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); diff --git a/tests/HotSwap/scripts/opcache-matrix.php b/tests/HotSwap/scripts/opcache-matrix.php index 957c211d..80a61dd1 100644 --- a/tests/HotSwap/scripts/opcache-matrix.php +++ b/tests/HotSwap/scripts/opcache-matrix.php @@ -38,6 +38,28 @@ exit(2); } +// --- Same-file redefine targets (issue #242) -------------------------------------- +// Unlike everything in the fixture include, these two are declared in THIS cached +// script: their compiled call sites live in the very shared-memory script that is +// executing them, which is the shape the first-redefine failure of issue #242 came +// from. The seed constant is defined at runtime, so no call to +// zengine_samefile_function() can be pre-evaluated when the script is cached. +define('ZENGINE_SAMEFILE_SEED', 'same-file-original'); + +function zengine_samefile_function(): string +{ + return \ZENGINE_SAMEFILE_SEED; +} + +/** + * Deliberately the optimizer-inlinable shape: a single return of a literal + * (see leg 8 - its same-file call sites are replaced by the constant at cache time) + */ +function zengine_samefile_baked(): string +{ + return 'baked'; +} + /** * Fails the matrix with a diagnostic (exit code 1 = assertion failure) */ @@ -263,6 +285,54 @@ } echo "static-vars-live-table: ok\n"; +// 7. Same-file callers (issue #242): the redefine target is declared in THIS cached +// script. A compiled call site that did not execute before the redefine resolves +// the callee through the repointed function-table bucket on its first run and +// must observe the writable copy - the dispatch closure below was compiled (and +// its op_array persisted) long before the redefine, but runs only after it +$sameFileDispatches = static function (string $expected): bool { + return zengine_samefile_function() === $expected; +}; +$sameFileFunction = new ReflectionFunction('zengine_samefile_function'); +if (!$sameFileFunction->isImmutable()) { + // Without shared memory behind the main script the same-file branch under test + // would silently not be exercised + fwrite(STDERR, "zengine_samefile_function is not an immutable (shared-memory) function\n"); + exit(2); +} +$sameFileFunction->redefine(function (): string { + return 'redefined-same-file'; +}); +if (!$sameFileDispatches('redefined-same-file')) { + $fail('a same-file call site did not observe the redefined body'); +} +if (zengine_samefile_function() !== 'redefined-same-file') { + $fail('the top-level same-file call site did not observe the redefined body'); +} +echo "same-file-redefine: ok\n"; + +// 8. The documented hard limitation behind issue #242: a same-file call to a function +// whose body merely returns a literal is inlined when the script is cached +// (zend_try_inline_call, optimizer pass 4) - the call site below was replaced by +// the constant 'baked' at cache time, so it does not exist at runtime and CANNOT +// observe any redefine, while a dynamic call resolves at runtime and observes it. +// If this leg ever fails on a new PHP build, the engine stopped inlining: revisit +// the copy-out caveat in docs/hot-swap.md together with this assertion. +$bakedDispatches = static function (): string { + return zengine_samefile_baked(); +}; +(new ReflectionFunction('zengine_samefile_baked'))->redefine(function (): string { + return 'redefined-baked'; +}); +$dynamicCallee = 'zengine_samefile_baked'; +$assertSameString($dynamicCallee(), 'redefined-baked', 'a dynamic call does not observe the redefined body'); +$assertSameString( + $bakedDispatches(), + 'baked', + 'the optimizer-inlined call site changed behavior - engine inlining semantics moved', +); +echo "inlined-call-site-limitation: ok\n"; + // Reaching this point with exit code 0 also proves the request shuts down cleanly: // issue #41 crashed in zend_function_dtor()/destroy_zend_class() at request shutdown echo "MATRIX OK\n"; diff --git a/tests/HotSwap/scripts/redefine-plateau.php b/tests/HotSwap/scripts/redefine-plateau.php index 44b1b7d1..c4c9df59 100644 --- a/tests/HotSwap/scripts/redefine-plateau.php +++ b/tests/HotSwap/scripts/redefine-plateau.php @@ -19,31 +19,43 @@ Core::init(); +// Under opcache this whole script is cached and optimized before it runs, and the +// optimizer inlines a same-file call to a function whose body merely returns a +// literal (zend_try_inline_call, optimizer pass 4): such a call site is replaced by +// the constant at cache time, so no redefine() can ever reach it - the original +// failure of issue #242 (see the copy-out caveats in docs/hot-swap.md). The measured +// bodies return a runtime-defined constant instead, which keeps every dispatch below +// a real engine call under any optimization level. +define('PLATEAU_ORIGINAL', 'original'); + function plateau_function(): string { - return 'original'; + return \PLATEAU_ORIGINAL; } class PlateauClass { public function target(): string { - return 'original'; + return \PLATEAU_ORIGINAL; } } $cycles = 1000; $refFunction = new ReflectionFunction('plateau_function'); $refMethod = new ReflectionMethod(PlateauClass::class, 'target'); -$instance = new PlateauClass(); // Dispatch checks take the expected value as data: the bodies are replaced at // runtime, so no statically-known return value applies $functionDispatches = static function (string $expected): bool { return plateau_function() === $expected; }; -$methodDispatches = static function (string $expected) use ($instance): bool { - return $instance->target() === $expected; +// The instance is created inside the dispatch: under opcache the first redefine +// copies PlateauClass out of shared memory, and an instance created before that +// would keep the shared class entry and dispatch the original body forever +// (copy-out redirects name resolution only - docs/hot-swap.md caveats) +$methodDispatches = static function (string $expected): bool { + return (new PlateauClass())->target() === $expected; }; // A fixed rotation of equal-length payloads: every cycle compiles a brand-new From eae53116cc075db9b99dd0a79465fef7b4c0515d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:23:53 +0000 Subject: [PATCH 08/28] Generate darwin FFI engine definitions for PHP 8.4 (#58) --- include/8.4/darwin-arm64-nts/engine.h | 2 ++ include/8.4/darwin-arm64-zts/engine.h | 2 ++ include/8.4/darwin-x64-nts/engine.h | 2 ++ include/8.4/darwin-x64-zts/engine.h | 2 ++ 4 files changed, 8 insertions(+) diff --git a/include/8.4/darwin-arm64-nts/engine.h b/include/8.4/darwin-arm64-nts/engine.h index 472fede6..d6501a3f 100644 --- a/include/8.4/darwin-arm64-nts/engine.h +++ b/include/8.4/darwin-arm64-nts/engine.h @@ -1055,4 +1055,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/darwin-arm64-zts/engine.h b/include/8.4/darwin-arm64-zts/engine.h index 767b4f7c..a06dbe01 100644 --- a/include/8.4/darwin-arm64-zts/engine.h +++ b/include/8.4/darwin-arm64-zts/engine.h @@ -1058,4 +1058,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/darwin-x64-nts/engine.h b/include/8.4/darwin-x64-nts/engine.h index b3ecca16..0d117b32 100644 --- a/include/8.4/darwin-x64-nts/engine.h +++ b/include/8.4/darwin-x64-nts/engine.h @@ -1055,4 +1055,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/darwin-x64-zts/engine.h b/include/8.4/darwin-x64-zts/engine.h index def77df7..935f61c5 100644 --- a/include/8.4/darwin-x64-zts/engine.h +++ b/include/8.4/darwin-x64-zts/engine.h @@ -1058,4 +1058,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; From 79f7cbe05ba77e5e8f5a650e9495df661aab3bd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:55:13 +0000 Subject: [PATCH 09/28] feat(opcache): relocate intersection/union type lists in the file-cache payload Port the ZEND_TYPE_HAS_LIST branch of zend_file_cache_serialize_type / zend_file_cache_unserialize_type (ext/opcache/zend_file_cache.c, PHP-8.4.19): the zend_type_list pointer is relocated (SERIALIZE_PTR keeps walking through the still-real address, exactly like the C serialize/unserialize pair) and the walk recurses into every zend_type entry, so DNF sub-lists like (A&B)|C unfold naturally. The unsupportedPayload refusal for type lists is gone in both directions. New fixture tests/OpCache/fixtures/type-lists.php exercises a union parameter, a union return type, an intersection parameter and return type, and union/DNF property types (the DNF one nests an intersection list inside a union list). Acceptance evidence: - TypeListRelocationTest::testTypeListPayloadRoundTripsByteIdentical - the compiled fixture relocates and derelocates byte-for-byte - testUnmodifiedResaveStillExecutes / testPatchedTypeListFixtureExecutesFromCache - the re-serialized (and string-literal-patched) binary is executed by a fresh worker straight from the cache - full default suite, --group opcache --fail-on-skipped, phpstan level max and php-cs-fixer all green on PHP 8.4.19 NTS Fixes #112 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/opcache-binary.md | 11 ++- src/OpCache/PayloadRelocator.php | 61 ++++++++++-- tests/OpCache/TypeListRelocationTest.php | 114 +++++++++++++++++++++++ tests/OpCache/fixtures/type-lists.php | 47 ++++++++++ 4 files changed, 219 insertions(+), 14 deletions(-) create mode 100644 tests/OpCache/TypeListRelocationTest.php create mode 100644 tests/OpCache/fixtures/type-lists.php diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index 7b38d9e6..35663db2 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -121,11 +121,12 @@ function/method hot-swap API) is future work; see [hot-swap.md](hot-swap.md). macOS/arm64. ZTS payloads stay tracked in [#118](https://github.com/lisachenko/z-engine/issues/118). - **Strict, never silent.** Structures the port does not yet handle - (intersection/union type lists, property hooks, iterator/ArrayAccess funcs, - trait-using classes, compile warnings) raise `unsupportedPayload` rather than - writing a subtly corrupt binary. Global functions, classes with constants, - typed properties, attributes (including constant-expression arguments), static - variables, try/catch and enums are supported and round-trip byte-for-byte. + (property hooks, iterator/ArrayAccess funcs, trait-using classes, compile + warnings) raise `unsupportedPayload` rather than writing a subtly corrupt + binary. Global functions, classes with constants, typed properties + (union/intersection/DNF type lists included), attributes (including + constant-expression arguments), static variables, try/catch and enums are + supported and round-trip byte-for-byte. - **Deferred.** Loading patched binaries into shared memory (ZCSG), and applying a patched image to already-loaded classes via `redefine()` / `ClassDelta`. diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index b466fa0c..569e11e2 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -25,6 +25,8 @@ use ZEngine\Generated\zend_class_name; use ZEngine\Generated\zend_early_binding; use ZEngine\Generated\zend_string; +use ZEngine\Generated\zend_type; +use ZEngine\Generated\zend_type_list; use ZEngine\Generated\zval; /** @@ -582,26 +584,67 @@ private function serializeAttribute(object $zval): void private function unserializeType(object $owner, string $field): void { - $typeMask = $owner->$field->type_mask; + $this->unserializeTypeStruct($owner->$field); + } + /** + * @param \FFI\CData $owner + */ + + private function serializeType(object $owner, string $field): void + { + $this->serializeTypeStruct($owner->$field); + } + + /** + * One zend_type in place - the ZEND_TYPE_HAS_LIST branch of + * zend_file_cache_unserialize_type relocates the zend_type_list pointer and + * recurses into every entry, so DNF sub-lists like (A&B)|C unfold naturally. + * + * @param \FFI\CData $type a zend_type view (embedded field or list entry) + */ + private function unserializeTypeStruct(object $type): void + { + $typeMask = $type->type_mask; if (($typeMask & self::TYPE_LIST_BIT) !== 0) { - throw OpCacheException::unsupportedPayload('intersection/union type-list relocation'); + $listAddress = $this->unPtr($type, 'ptr'); + $list = Core::pointerAtAddress('zend_type_list *', $listAddress); + $typeSize = Core::sizeOfType(zend_type::class); + // ZEND_TYPE_LIST_FOREACH: entries start at list->types (the flexible member) + $entryBase = $listAddress + Core::sizeOfType(zend_type_list::class) - $typeSize; + for ($i = 0; $i < $list->num_types; $i++) { + $this->unserializeTypeStruct(Core::pointerAtAddress('zend_type *', $entryBase + $i * $typeSize)); + } + + return; } if (($typeMask & self::TYPE_NAME_BIT) !== 0) { - $this->unStr($owner->$field, 'ptr'); + $this->unStr($type, 'ptr'); } } + /** - * @param \FFI\CData $owner + * Mirror of {@see unserializeTypeStruct} - zend_file_cache_serialize_type + * stores the list pointer as an offset but keeps walking the entries through + * the still-real address (its SERIALIZE_PTR/UNSERIALIZE_PTR pair). + * + * @param \FFI\CData $type a zend_type view (embedded field or list entry) */ - - private function serializeType(object $owner, string $field): void + private function serializeTypeStruct(object $type): void { - $typeMask = $owner->$field->type_mask; + $typeMask = $type->type_mask; if (($typeMask & self::TYPE_LIST_BIT) !== 0) { - throw OpCacheException::unsupportedPayload('intersection/union type-list relocation'); + $listAddress = $this->serPtr($type, 'ptr'); + $list = Core::pointerAtAddress('zend_type_list *', $listAddress); + $typeSize = Core::sizeOfType(zend_type::class); + $entryBase = $listAddress + Core::sizeOfType(zend_type_list::class) - $typeSize; + for ($i = 0; $i < $list->num_types; $i++) { + $this->serializeTypeStruct(Core::pointerAtAddress('zend_type *', $entryBase + $i * $typeSize)); + } + + return; } if (($typeMask & self::TYPE_NAME_BIT) !== 0) { - $this->serStr($owner->$field, 'ptr'); + $this->serStr($type, 'ptr'); } } diff --git a/tests/OpCache/TypeListRelocationTest.php b/tests/OpCache/TypeListRelocationTest.php new file mode 100644 index 00000000..0f5aebe2 --- /dev/null +++ b/tests/OpCache/TypeListRelocationTest.php @@ -0,0 +1,114 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; + +/** + * Relocation coverage for zend_type_list payloads (issue #112): union, + * intersection and DNF types in parameters, return types and properties must + * round-trip byte-for-byte and still execute after a patch-and-save cycle. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class TypeListRelocationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX NTS payloads only' + . ' (ZTS is issue #118, Windows is issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testTypeListPayloadRoundTripsByteIdentical(): void + { + $binPath = self::compileFixture(self::typeListFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate(), 'A type-list round trip must reproduce the payload byte-for-byte'); + } + + public function testUnmodifiedResaveStillExecutes(): void + { + $fixture = self::typeListFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + $file->getReflection(); // relocate + $file->save(); // re-serialize unchanged + + self::assertTrue(BinaryCacheFile::read($file->binPath())->verifyChecksum()); + self::assertSame( + 'ZEngineTypeListImpl:tl-ok', + self::runFromCache($fixture, 'zengine_bin_typelist_run', self::$cacheDir), + ); + } + + public function testPatchedTypeListFixtureExecutesFromCache(): void + { + $fixture = self::typeListFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + self::patchStringLiteral($file, 'zengine_bin_typelist_run', ':tl-ok', ':tl-patched-through-the-relocator'); + $file->save(); + + self::assertSame( + 'ZEngineTypeListImpl:tl-patched-through-the-relocator', + self::runFromCache($fixture, 'zengine_bin_typelist_run', self::$cacheDir), + ); + } + + private static function typeListFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/type-lists.php'); + self::assertIsString($path); + + return $path; + } + + private static function patchStringLiteral(BinaryCacheFile $file, string $function, string $from, string $to): void + { + $functions = $file->getReflection()->getFunctions(); + self::assertArrayHasKey($function, $functions, "Function {$function} not found in the cached script"); + foreach ($functions[$function]->getLiterals() as $literal) { + if ($literal->getBaseType() !== ReflectionValue::IS_STRING) { + continue; + } + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + + return; + } + } + self::fail("String literal '{$from}' not found in {$function}"); + } +} diff --git a/tests/OpCache/fixtures/type-lists.php b/tests/OpCache/fixtures/type-lists.php new file mode 100644 index 00000000..c8185f0b --- /dev/null +++ b/tests/OpCache/fixtures/type-lists.php @@ -0,0 +1,47 @@ +union = zengine_bin_intersection($impl); + $impl->dnf = zengine_bin_union_return(false); + + return zengine_bin_union_param($impl->union ?? $impl) . ':tl-ok'; +} From 22a27f4dcdcf1b37b5d9035364ad5089612ccd06 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 22:26:36 +0000 Subject: [PATCH 10/28] feat(gen): export zend_deserialize_opcode_handler Add Zend/zend_vm.h to the generator's preprocess unit and the function to the manifest, and regenerate the linux targets (native pre-check against the committed manifest was clean; the zts artifacts come from the docker pipeline). The opcache file cache stores every opline handler as an index (zend_serialize_opcode_handler); this ZEND_API counterpart restores the callable handler pointer and is what lets the cache-image bridge make relocated image bodies executable in-process (issue #122). The darwin/windows artifacts cannot be generated on this machine and are refreshed by their native generate workflows, which trigger on pull requests touching tools/generator/**. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- include/8.4/linux-x64-nts/engine.h | 1 + include/8.4/linux-x64-zts/engine.h | 1 + tools/generator/emit.php | 1 + tools/generator/symbols.php | 4 ++++ 4 files changed, 7 insertions(+) diff --git a/include/8.4/linux-x64-nts/engine.h b/include/8.4/linux-x64-nts/engine.h index 5fa96e46..b2ef5bfe 100644 --- a/include/8.4/linux-x64-nts/engine.h +++ b/include/8.4/linux-x64-nts/engine.h @@ -1013,6 +1013,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); diff --git a/include/8.4/linux-x64-zts/engine.h b/include/8.4/linux-x64-zts/engine.h index aa2e5590..7b20685a 100644 --- a/include/8.4/linux-x64-zts/engine.h +++ b/include/8.4/linux-x64-zts/engine.h @@ -1105,6 +1105,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); diff --git a/tools/generator/emit.php b/tools/generator/emit.php index f59ba596..fefe0971 100644 --- a/tools/generator/emit.php +++ b/tools/generator/emit.php @@ -250,6 +250,7 @@ function sliceStructs(string $phpSrc, string $file, array $patterns): string #include "zend_arena.h" #include "zend_exceptions.h" #include "zend_system_id.h" + #include "zend_vm.h" #include "Optimizer/zend_optimizer.h" #include "supplement.h" C; diff --git a/tools/generator/symbols.php b/tools/generator/symbols.php index 6b952424..b8e1bb77 100644 --- a/tools/generator/symbols.php +++ b/tools/generator/symbols.php @@ -108,6 +108,10 @@ // Opcode API 'zend_set_user_opcode_handler', 'zend_get_user_opcode_handler', + // Restores an opline's handler pointer from the index form the opcache + // file-cache serializer stores (zend_file_cache.c); the CacheImageSync + // bridge uses it to make relocated image bodies executable in-process + 'zend_deserialize_opcode_handler', // Inheritance / object API 'zend_do_inheritance_ex', 'zend_objects_new', From 9394c05ed2e8a2dbc1a7e8bebcb661bb188ef4f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 22:26:55 +0000 Subject: [PATCH 11/28] feat(opcache): apply patched cache images to already-loaded code Wire the file-cache binary-patch pipeline to the runtime hot-swap API (CacheImageSync): a patched ReflectionOpcacheFile image is diffed against the live executor tables and every changed compiled BODY is swapped into the ALREADY-LOADED functions and methods in place, through the existing FunctionBodySwap machinery - no re-include, entry pointers preserved. Until now a patched binary only affected the next include (refresh()). - prepare() is a read-only diff; the equality basis (ImageFunctionDonor) compares body metrics, canonicalized opcodes (IS_CONST operands by literal index across the two storage forms, handlers and the uninitialized op1.num of implicit-$this receivers ignored), CV names, literals and static defaults by value - conservative where equality cannot be proven (array/AST literals re-apply, like ReflectionMethod). - Donor bodies are materialized per entry: opcodes+literals co-allocated into one process block, IS_CONST operands rewritten to the runtime relative form and handlers restored with the engine's own zend_deserialize_opcode_handler - the exact normalization zend_file_cache_unserialize performs. The image buffer is never written, so save()/refresh() stay valid after an apply. - apply() validates refusals first, copies opcache-shared targets out of SHM through the documented paths (redefine()'s function copy-out, extracted as FunctionLikeTrait::copyEntryOutOfSharedMemory(), and ReflectionClass::copyOutOfSharedMemory() for classes), then stages all swaps - functions before classes, alphabetical - and commits only when every swap staged; failures roll all staged bodies back. - Throw-or-work: changed enum/interface/trait methods, internal-name collisions and every SHM copy-out refusal throw; image-only entries are reported as not loaded in the explicit CacheImageSyncReport. - Lifetime: swapped-in bodies are refcount-less (engine never destroys them); the sync pins the materialized blocks and the image view now retains the relocated buffer's owner. - Seam for #121: prepare() is application-agnostic, an SHM publisher consumes the same prepared diff and replaces only the apply() target. The receiver-opcode constant stays untyped on purpose: a typed array constant holding a constant expression trips the debug-build assertion zend_update_class_constant:!EG(exception) under opcache.preload. Fixes #122 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/hot-swap.md | 7 + docs/opcache-binary.md | 77 +++- src/HotSwap/CacheImageSync.php | 424 ++++++++++++++++++ src/HotSwap/CacheImageSyncReport.php | 62 +++ src/HotSwap/HotSwapException.php | 34 ++ src/OpCache/BinaryCacheFile.php | 4 +- src/OpCache/ImageFunctionDonor.php | 412 +++++++++++++++++ src/OpCache/ReflectionOpcacheFile.php | 13 +- src/Reflection/FunctionLikeInterface.php | 7 + src/Reflection/FunctionLikeTrait.php | 48 +- tests/HotSwap/CacheImageSyncTest.php | 150 +++++++ .../scripts/cache-image-sync-refusals.php | 126 ++++++ .../HotSwap/scripts/cache-image-sync-shm.php | 171 +++++++ tests/HotSwap/scripts/cache-image-sync.php | 158 +++++++ .../scripts/image-sync-enum-fixture.php | 24 + 15 files changed, 1698 insertions(+), 19 deletions(-) create mode 100644 src/HotSwap/CacheImageSync.php create mode 100644 src/HotSwap/CacheImageSyncReport.php create mode 100644 src/OpCache/ImageFunctionDonor.php create mode 100644 tests/HotSwap/CacheImageSyncTest.php create mode 100644 tests/HotSwap/scripts/cache-image-sync-refusals.php create mode 100644 tests/HotSwap/scripts/cache-image-sync-shm.php create mode 100644 tests/HotSwap/scripts/cache-image-sync.php create mode 100644 tests/HotSwap/scripts/image-sync-enum-fixture.php diff --git a/docs/hot-swap.md b/docs/hot-swap.md index afedaec4..2bfcac8b 100644 --- a/docs/hot-swap.md +++ b/docs/hot-swap.md @@ -136,6 +136,13 @@ covered in [opcache-binary.md](opcache-binary.md). `ReflectionException`, so existing catch blocks keep working while the failure modes stay distinguishable. +The file-cache bridge `CacheImageSync` (see +[opcache-binary.md](opcache-binary.md)) drives this same machinery from a +patched cache image instead of a closure/source donor: changed image bodies +are swapped into the already-loaded entries through `FunctionBodySwap`, with +opcache-shared targets copied out of SHM by the exact paths above — every +row of this matrix, including the refusals, applies to it unchanged. + ### Copy-out caveats A copy-out changes which `zend_class_entry` the class name resolves to, and diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index 5bc9c2b9..ac033ec0 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -87,8 +87,75 @@ Under `opcache.file_cache_only=1` there is no shared-memory copy, so writing the binary is enough for the next worker to load it. When opcache also uses shared memory, a script already resident in SHM is **not** re-read until it is invalidated — which is exactly what `refresh()` does. Loading a patched binary -directly into shared memory (and wiring it to the function/method hot-swap API) -is future work; see [hot-swap.md](hot-swap.md). +directly into shared memory remains future work +([#121](https://github.com/lisachenko/z-engine/issues/121)); applying a patched +image to code **already loaded in the current process** is what +`CacheImageSync` does — see the next section and [hot-swap.md](hot-swap.md). + +## Applying a patched image to the live process (`CacheImageSync`) + +`refresh()` only affects the *next* include. `ZEngine\HotSwap\CacheImageSync` +closes the other half of the loop (issue #122): it diffs a (patched) image +against the functions and classes **already loaded** in this process and swaps +the changed compiled bodies in place, through the same runtime machinery +`redefine()`/`ClassDelta` use — no re-include, warmed-up call sites keep +dispatching the same entry pointers. + +```php +$image = $file->getReflection(); +// ... patch literals/opcodes through the wrappers ... +$sync = CacheImageSync::prepare($image); // read-only diff +$sync->getChangedFunctions(); // introspect the plan +$report = $sync->apply(); // swap the changed bodies, loudly +$report->appliedMethods; // what actually happened, per entry +``` + +- **Diff basis.** `prepare()` compares each image body with its live + counterpart: body metrics (opcode/literal/CV/temporary/argument counts), + fn_flags without the storage-only bits, CV names, every opline in + canonicalized form (IS_CONST operands by literal index — the image stores + the serialized index form, the live side the runtime offset form — with + handlers and the garbage `op1.num` of implicit-`$this` receivers ignored), + every literal and static-variable default by value. The comparison is + conservative where value equality cannot be proven: array and + constant-expression literals always count as changed (a safe re-apply, like + `ReflectionMethod::equals()`); declaration-surface-only edits (arg_info + types/names, doc comments) are not part of the basis and do not trigger a + swap on their own. +- **Execution normalization.** Donor bodies are materialized per entry + (`ImageFunctionDonor`): opcodes + literals are copied into one co-allocated + process block, IS_CONST operands are rewritten to the runtime form and the + handlers restored with the engine's own `zend_deserialize_opcode_handler()`. + The image buffer itself is never written, so `save()`/`refresh()` keep + producing valid binaries after an apply. +- **Ordering and atomicity.** `apply()` validates refusals first (nothing is + touched if the plan contains one), then copies every opcache-shared target + out of SHM, then stages all swaps — functions before classes, alphabetically + within each group — and commits only when every swap staged; a failure rolls + all staged bodies back (completed copy-outs stay, they are + behavior-preserving). +- **Scope.** Bodies of named global functions and of methods the live class + itself declares. Image-only entries (script never included here, methods or + functions only the patch added) are *reported* as not loaded — the next + include picks them up. The script's main op_array, class constants, property + defaults and attributes are out of scope. +- **Refusals (throw-or-work, never silent).** Changed methods of an + enum/interface/trait throw `HotSwapException::unsupportedKind`; an image + entry colliding with an internal function/class throws; opcache-shared + targets follow the [hot-swap.md](hot-swap.md) copy-out matrix, so preloaded + classes and copy-unsupported shapes (property hooks, internal ancestors) + throw `SharedMemoryException`. Unchanged entries of a refused kind are not + operations and pass. +- **Lifetime.** Swapped-in bodies execute out of the materialized blocks and + the relocated image buffer: the sync retains both (and the view retains the + buffer), all are request-lifetime allocations the engine provably never + frees through table teardown (the bodies carry no refcount, exactly like + shared-memory bodies). Apply per request, like every other runtime mutation. +- **Seam for [#121](https://github.com/lisachenko/z-engine/issues/121).** + `prepare()` is application-agnostic: a future SHM publisher consumes the + same prepared diff (`getChangedFunctions()`/`getChangedMethods()` plus the + image handle) and replaces only the apply() target — per-process tables + today, ZCSG then. ## Scope and limits (v1) @@ -107,8 +174,10 @@ is future work; see [hot-swap.md](hot-swap.md). writing a subtly corrupt binary. Global functions, classes with constants, typed properties, attributes (including constant-expression arguments), static variables, try/catch and enums are supported and round-trip byte-for-byte. -- **Deferred.** Loading patched binaries into shared memory (ZCSG), and applying - a patched image to already-loaded classes via `redefine()` / `ClassDelta`. +- **Deferred.** Loading patched binaries into shared memory (ZCSG, + [#121](https://github.com/lisachenko/z-engine/issues/121)). Applying a + patched image to already-loaded functions and classes landed as + `CacheImageSync` (see above). ## Failure modes diff --git a/src/HotSwap/CacheImageSync.php b/src/HotSwap/CacheImageSync.php new file mode 100644 index 00000000..89266dbb --- /dev/null +++ b/src/HotSwap/CacheImageSync.php @@ -0,0 +1,424 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\HotSwap; + +use ZEngine\Core; +use ZEngine\OpCache\ImageFunctionDonor; +use ZEngine\OpCache\ReflectionOpcacheFile; +use ZEngine\OpCache\SharedMemoryException; +use ZEngine\Reflection\FunctionBodySwap; +use ZEngine\Reflection\PendingBodySwap; +use ZEngine\Reflection\ReflectionClass; +use ZEngine\Reflection\ReflectionFunction; +use ZEngine\Reflection\ReflectionMethod; + +/** + * The bridge between the file-cache binary-patch pipeline and the runtime + * hot-swap machinery: takes a (patched) ReflectionOpcacheFile image and applies + * the changed compiled BODIES to the functions and classes ALREADY LOADED in + * this process, in place - without re-including the script. A patched binary + * alone only affects the next include (BinaryCacheFile::refresh()); this class + * closes the loop for code the process is already running. + * + * prepare() diffs the image against the live executor tables and is read-only; + * apply() drives the existing runtime machinery for every changed body: + * + * - global functions: FunctionBodySwap in-place swap, the redefine() contract + * (entry pointer, name, scope, prototype and declaration flags preserved; + * the body and its body-level flags follow the image); + * - methods: the same swap against the live class entry, ClassDelta-style - + * changed bodies only, propagating to subclasses that share the entry; + * - opcache-shared (ZEND_ACC_IMMUTABLE) live entries are first copied out of + * shared memory through the documented copy-out paths, with every refusal + * of that machinery surfacing loudly (docs/hot-swap.md support matrix). + * + * Scope: bodies of named global functions and of methods the live class itself + * declares. Entries only the image knows (script never included here, methods + * added only in the image) are REPORTED as not loaded, never invented at + * runtime; the script's main op_array and class-level data (constants, + * property defaults, attributes) are out of scope - see docs/opcache-binary.md. + * + * apply() is ordered (functions before classes, alphabetically within each + * group), staged (every swap can roll back until all succeeded) and loud: it + * either works or throws, returning a CacheImageSyncReport of what happened. + * Re-applying the same sync is refused; re-preparing against the same image + * diffs as all-unchanged, so the bridge is idempotent per image state. + * + * Seam for issue #121 (publishing a patched image into opcache shared memory): + * prepare() is application-agnostic - the SHM publisher consumes the same + * prepared diff (getChangedFunctions()/getChangedMethods() plus the image + * handle) and replaces only the apply() target, per-process tables today, ZCSG + * tomorrow. + */ +final class CacheImageSync +{ + /** @var array Lc name => live entry to swap */ + private array $changedFunctionEntries = []; + + /** @var array Lc name => image donor function */ + private array $changedFunctionImages = []; + + /** @var array Lc name => live entry was opcache-shared at prepare time */ + private array $functionWasShared = []; + + /** @var array Lc class => live class with changed methods */ + private array $changedClassEntries = []; + + /** @var array> Lc class => lc method => image method */ + private array $changedMethodImages = []; + + /** @var array Lc class => live class was opcache-shared at prepare time */ + private array $classWasShared = []; + + /** @var list<\ReflectionException> Refusals the diff detected; apply() throws the first */ + private array $refusals = []; + + /** @var list */ + private array $unchangedFunctions = []; + + /** @var list */ + private array $unchangedMethods = []; + + /** @var list */ + private array $notLoadedFunctions = []; + + /** @var list */ + private array $notLoadedClasses = []; + + /** @var list */ + private array $notLoadedMethods = []; + + private bool $isApplied = false; + + /** + * Materialized donors pinned for the lifetime of this sync: the swapped-in bodies + * execute out of the blocks these own (see ImageFunctionDonor and the retained + * $image, whose relocated buffer the bodies keep referencing) + * + * @var list + */ + // @phpstan-ignore property.onlyWritten (pure lifetime retention) + private array $materializedDonors = []; + + private function __construct(private readonly ReflectionOpcacheFile $image) {} + + /** + * Diffs a relocated cache image against the live process (read-only) + * + * The equality basis is ImageFunctionDonor::bodiesEqual(): body metrics, + * canonicalized opcodes, literal and static-default values. Refusals the diff + * detects (an image entry colliding with an internal function/class, changed + * methods of an enum/interface/trait) are recorded and thrown by apply() - + * preparing stays side-effect free so the plan can be introspected first. + */ + public static function prepare(ReflectionOpcacheFile $image): self + { + $sync = new self($image); + $sync->diffFunctions(); + $sync->diffClasses(); + + return $sync; + } + + /** + * @return list Lowercased names of global functions whose body will be swapped + */ + public function getChangedFunctions(): array + { + return array_keys($this->changedFunctionEntries); + } + + /** + * @return array> Lc class name => lc method names whose body will be swapped + */ + public function getChangedMethods(): array + { + return array_map(array_keys(...), $this->changedMethodImages); + } + + /** + * Human-readable reasons apply() will refuse this plan with, in throw order + * + * Empty when the plan is applicable. The first reason is what apply() throws + * (as its typed exception); listing them here keeps the refusal introspectable + * before anything is attempted. + * + * @return list + */ + public function getRefusalReasons(): array + { + return array_map( + static fn(\ReflectionException $refusal): string => $refusal->getMessage(), + $this->refusals, + ); + } + + /** + * Checks if applying this sync would perform no operation and refuse nothing + */ + public function isEmpty(): bool + { + return $this->changedFunctionEntries === [] + && $this->changedMethodImages === [] + && $this->refusals === []; + } + + /** + * Applies every changed body to the live process, atomically for the batch + * + * Order of operations: refusal validation first (nothing is touched when the plan + * contains a refused entry), then the copy-out of every opcache-shared target, + * then all body swaps - functions before classes, alphabetically within each + * group - staged so that a failing swap rolls every already-staged one back. + * A completed copy-out is NOT undone by that rollback: it is behavior-preserving + * on its own (the writable copy publishes the same bodies) and the documented + * copy-out caveats of docs/hot-swap.md apply from that moment on. + * + * @throws HotSwapException When the plan contains a refused entry, this sync was + * already applied, or a swap failed and was rolled back + * @throws SharedMemoryException When an opcache-shared target cannot be copied out of + * shared memory (preloaded class, unsupported class shape) + */ + public function apply(): CacheImageSyncReport + { + if ($this->isApplied) { + throw HotSwapException::imageAlreadyApplied($this->image->getFileName()); + } + if (Core::isShutdown()) { + throw HotSwapException::shutdown(); + } + if ($this->refusals !== []) { + throw $this->refusals[0]; + } + + // Copy-out pass: after this, every target entry is writable per-process memory. + // SharedMemoryException from here aborts the apply before any body changed. + foreach ($this->changedFunctionEntries as $liveFunction) { + $liveFunction->copyEntryOutOfSharedMemory(); + } + foreach ($this->changedClassEntries as $liveClass) { + $liveClass->copyOutOfSharedMemory(); + } + + // Materialization pass: allocation and normalization only, nothing published + $functionDonors = []; + foreach ($this->changedFunctionImages as $functionName => $imageFunction) { + $functionDonors[$functionName] = ImageFunctionDonor::materialize($imageFunction); + } + $methodDonors = []; + foreach ($this->changedMethodImages as $classKey => $imageMethods) { + foreach ($imageMethods as $methodName => $imageMethod) { + $methodDonors[$classKey][$methodName] = ImageFunctionDonor::materialize($imageMethod); + } + } + + // Staging pass: every entry dispatches the new body once its swap is staged, + // and any failure rolls all staged entries back to their previous bodies + /** @var list $pendingSwaps */ + $pendingSwaps = []; + try { + foreach ($functionDonors as $functionName => $donor) { + $entryFunction = $this->changedFunctionEntries[$functionName]; + $pendingSwaps[] = FunctionBodySwap::swapUserFunctionBody( + $entryFunction, + $donor->getDonor(), + // The entry keeps its declaration identity; only the body travels + preserveDeclaration: true, + // The image defaults table is pinned by the image buffer, not donor-owned + duplicateStatics: false, + // A shared-memory previous body is immortal and must not be freed + destroyPrevious: !$this->functionWasShared[$functionName], + publishedShares: FunctionBodySwap::countPublishedShares($entryFunction), + ); + } + foreach ($methodDonors as $classKey => $donors) { + $liveClass = $this->changedClassEntries[$classKey]; + $methodTable = $liveClass->getMethodTable(); + foreach ($donors as $methodName => $donor) { + // Re-resolved AFTER the copy-out pass: the published entry of a + // copied-out class is the writable duplicate, not the SHM original + $methodValue = $methodTable->find($methodName); + if ($methodValue === null) { + throw SharedMemoryException::methodMissingAfterCopyOut((string) $liveClass->getName(), $methodName); + } + $entryMethod = ReflectionMethod::fromRawEntry($methodValue->getRawFunction()); + $pendingSwaps[] = FunctionBodySwap::swapUserFunctionBody( + $entryMethod, + $donor->getDonor(), + preserveDeclaration: true, + duplicateStatics: false, + destroyPrevious: !$this->classWasShared[$classKey], + publishedShares: FunctionBodySwap::countPublishedShares($entryMethod), + ); + } + } + } catch (\Throwable $error) { + foreach (array_reverse($pendingSwaps) as $pending) { + $pending->rollback(); + } + if ($error instanceof HotSwapException || $error instanceof SharedMemoryException) { + throw $error; + } + throw HotSwapException::imageApplyFailedAndRolledBack($this->image->getFileName(), $error); + } + + // Commit: from here on nothing can fail - previous bodies are released + // (shared-memory ones stay allocated by contract) + foreach ($pendingSwaps as $pending) { + $pending->commit(); + } + $this->isApplied = true; + foreach ($functionDonors as $donor) { + $this->materializedDonors[] = $donor; + } + $appliedMethods = []; + foreach ($methodDonors as $classKey => $donors) { + foreach ($donors as $methodName => $donor) { + $this->materializedDonors[] = $donor; + $appliedMethods[] = "{$classKey}::{$methodName}"; + } + } + + return new CacheImageSyncReport( + $this->image->getFileName(), + array_keys($functionDonors), + $appliedMethods, + $this->unchangedFunctions, + $this->unchangedMethods, + $this->notLoadedFunctions, + $this->notLoadedClasses, + $this->notLoadedMethods, + ); + } + + /** + * Diffs every image function against the live function table + */ + private function diffFunctions(): void + { + $liveFunctionTable = Core::$executor->functionTable; + $imageFunctions = $this->image->getFunctions(); + ksort($imageFunctions); + foreach ($imageFunctions as $functionName => $imageFunction) { + $liveValue = $liveFunctionTable->find($functionName); + if ($liveValue === null) { + $this->notLoadedFunctions[] = $functionName; + continue; + } + $liveFunction = ReflectionFunction::fromCData($liveValue->getRawFunction()); + if (!$liveFunction->isUserDefined()) { + // An image body cannot replace a native handler, and the two are never + // "equal" - this is a refusal, not a skip (throw-or-work, never silent) + $this->refusals[] = HotSwapException::internalFunctionCollision($functionName); + continue; + } + if (ImageFunctionDonor::bodiesEqual($imageFunction, $liveFunction)) { + $this->unchangedFunctions[] = $functionName; + continue; + } + $this->changedFunctionEntries[$functionName] = $liveFunction; + $this->changedFunctionImages[$functionName] = $imageFunction; + $this->functionWasShared[$functionName] = $liveFunction->isImmutable(); + } + } + + /** + * Diffs every method an image class declares against the live class + */ + private function diffClasses(): void + { + $liveClassTable = Core::$executor->classTable; + $imageClasses = $this->image->getClasses(); + ksort($imageClasses); + foreach ($imageClasses as $classKey => $imageClass) { + $liveValue = $liveClassTable->find($classKey); + if ($liveValue === null) { + $this->notLoadedClasses[] = $classKey; + continue; + } + $liveClass = ReflectionClass::fromCData($liveValue->getRawClass()); + if (!$liveClass->isUserDefined()) { + $this->refusals[] = HotSwapException::internalClass((string) $liveClass->getName()); + continue; + } + + $changedMethods = $this->diffClassMethods($classKey, $imageClass, $liveClass); + if ($changedMethods === []) { + continue; + } + // Refusals gate MUTATION: an unchanged enum/interface/trait in the image is + // simply not an operation, only changed bodies of one are refused + $specialMask = Core::ZEND_ACC_INTERFACE | Core::ZEND_ACC_TRAIT | Core::ZEND_ACC_ENUM; + if ((($liveClass->getFlags() | $imageClass->getFlags()) & $specialMask) !== 0) { + $this->refusals[] = HotSwapException::unsupportedKind((string) $liveClass->getName()); + continue; + } + if (($liveClass->getFlags() & Core::ZEND_ACC_LINKED) === 0) { + $this->refusals[] = HotSwapException::notLinked((string) $liveClass->getName()); + continue; + } + $this->changedClassEntries[$classKey] = $liveClass; + $this->changedMethodImages[$classKey] = $changedMethods; + $this->classWasShared[$classKey] = $liveClass->isImmutable(); + } + } + + /** + * Diffs the methods one image class declares against the live class entry + * + * @return array Lc method name => image method with a changed body + */ + private function diffClassMethods(string $classKey, ReflectionClass $imageClass, ReflectionClass $liveClass): array + { + // A method-less class stores an UNINITIALIZED method table in the image + // (no bucket array to iterate) - and declares nothing to diff anyway + if (count($imageClass->getMethodTable()) === 0) { + return []; + } + $changedMethods = []; + $liveMethodTable = $liveClass->getMethodTable(); + $liveAddress = $liveClass->getAddress(); + $imageMethods = $imageClass->getDeclaredMethods(); + ksort($imageMethods); + foreach ($imageMethods as $methodName => $imageMethod) { + $liveMethodValue = $liveMethodTable->find($methodName); + if ($liveMethodValue === null) { + // Declared only in the image (a patch added it): out of the bridge's + // scope - the next include of the patched binary publishes it + $this->notLoadedMethods[] = "{$classKey}::{$methodName}"; + continue; + } + $liveMethod = ReflectionMethod::fromRawEntry($liveMethodValue->getRawFunction()); + if (!$liveMethod->isUserDefined()) { + $this->refusals[] = HotSwapException::internalMethodCollision($classKey, $methodName); + continue; + } + $liveScope = $liveMethod->getCommonPointer()->scope; + if ($liveScope === null || Core::addressOf($liveScope) !== $liveAddress) { + // The live table publishes an INHERITED entry under this name: swapping + // it would mutate the ancestor's method for every subclass. The image + // method is an override that only the next include can add. + $this->notLoadedMethods[] = "{$classKey}::{$methodName}"; + continue; + } + if (ImageFunctionDonor::bodiesEqual($imageMethod, $liveMethod)) { + $this->unchangedMethods[] = "{$classKey}::{$methodName}"; + continue; + } + $changedMethods[$methodName] = $imageMethod; + } + + return $changedMethods; + } +} diff --git a/src/HotSwap/CacheImageSyncReport.php b/src/HotSwap/CacheImageSyncReport.php new file mode 100644 index 00000000..73c33048 --- /dev/null +++ b/src/HotSwap/CacheImageSyncReport.php @@ -0,0 +1,62 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\HotSwap; + +/** + * What one CacheImageSync::apply() run did to the live process, entry by entry + * + * The bridge never no-ops silently (the fail policy of the opcache epic is + * throw-or-work): every entry of the image lands in exactly one of these + * buckets, and everything the bridge REFUSES to apply throws out of apply() + * instead of appearing here. Names are the canonical lowercase table keys; + * methods are reported as "class::method". + * + * The "not loaded" buckets are image entries the live process has no + * counterpart for (the script - or a method added only in the image - was + * never loaded here): they cannot be hot-swapped, only the next include of the + * patched binary picks them up (BinaryCacheFile::refresh()). + */ +final class CacheImageSyncReport +{ + /** + * @param string $scriptFile Source path the synced image caches + * @param list $appliedFunctions Global functions whose live body was swapped + * @param list $appliedMethods Methods whose live body was swapped + * @param list $unchangedFunctions Live bodies already equal to the image + * @param list $unchangedMethods Live method bodies already equal to the image + * @param list $notLoadedFunctions Image functions the live process never loaded + * @param list $notLoadedClasses Image classes the live process never loaded + * @param list $notLoadedMethods Image methods the live class does not declare + * + * @internal built by CacheImageSync::apply() + */ + public function __construct( + public readonly string $scriptFile, + public readonly array $appliedFunctions, + public readonly array $appliedMethods, + public readonly array $unchangedFunctions, + public readonly array $unchangedMethods, + public readonly array $notLoadedFunctions, + public readonly array $notLoadedClasses, + public readonly array $notLoadedMethods, + ) {} + + /** + * Checks if the run swapped nothing (every live counterpart already matched) + */ + public function isNoOp(): bool + { + return $this->appliedFunctions === [] && $this->appliedMethods === []; + } +} diff --git a/src/HotSwap/HotSwapException.php b/src/HotSwap/HotSwapException.php index d936c705..5cec83dd 100644 --- a/src/HotSwap/HotSwapException.php +++ b/src/HotSwap/HotSwapException.php @@ -120,6 +120,40 @@ public static function constantRemoved(string $className, string $constantName): ); } + public static function internalFunctionCollision(string $functionName): self + { + return new self( + "Cannot apply the cache image body of {$functionName}(): the live process publishes " + . 'an internal function under that name', + ); + } + + public static function internalMethodCollision(string $className, string $methodName): self + { + return new self( + "Cannot apply the cache image body of {$className}::{$methodName}(): the live class " + . 'publishes an internal function under that method name', + ); + } + + public static function imageAlreadyApplied(string $scriptFile): self + { + return new self( + "The cache image of {$scriptFile} has already been applied by this sync - " + . 'prepare a fresh CacheImageSync to re-diff the image against the live process', + ); + } + + public static function imageApplyFailedAndRolledBack(string $scriptFile, \Throwable $cause): self + { + return new self( + "Applying the cache image of {$scriptFile} failed and every staged body swap " + . "was rolled back: {$cause->getMessage()}", + 0, + $cause, + ); + } + public static function shutdown(): self { return new self('Cannot apply a class delta after Core::shutdown()'); diff --git a/src/OpCache/BinaryCacheFile.php b/src/OpCache/BinaryCacheFile.php index fe903376..81d42516 100644 --- a/src/OpCache/BinaryCacheFile.php +++ b/src/OpCache/BinaryCacheFile.php @@ -236,7 +236,9 @@ public function getReflection(): ReflectionOpcacheFile $this->relocator = new PayloadRelocator($buffer, $this->metaInfo); - return $this->view = new ReflectionOpcacheFile($this->relocator->relocate()); + // The relocator travels with the view as its owner, so the view alone keeps the + // buffer alive even when this BinaryCacheFile is released by the caller + return $this->view = new ReflectionOpcacheFile($this->relocator->relocate(), $this->relocator); } /** diff --git a/src/OpCache/ImageFunctionDonor.php b/src/OpCache/ImageFunctionDonor.php new file mode 100644 index 00000000..0a4f6b5d --- /dev/null +++ b/src/OpCache/ImageFunctionDonor.php @@ -0,0 +1,412 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use FFI\CData; +use ZEngine\Core; +use ZEngine\Generated\zend_function; +use ZEngine\Generated\zend_op; +use ZEngine\Generated\zend_op_array; +use ZEngine\Generated\znode_op; +use ZEngine\Generated\zval; +use ZEngine\Reflection\FunctionLikeInterface; +use ZEngine\Reflection\ReflectionFunction; +use ZEngine\Reflection\ReflectionValue; +use ZEngine\System\OpCode; +use ZEngine\Type\HashTable; +use ZEngine\Type\OpLine; +use ZEngine\Type\StructArray; + +/** + * Turns one function body of a relocated cache image into an executable donor + * for the in-place body-swap machinery (FunctionBodySwap), and provides the + * body-equality basis the cache-image bridge diffs with. + * + * A relocated image (PayloadRelocator) is structurally walkable but NOT + * executable: the relocator deliberately preserves the two execution-only + * encodings opcache stores in the file byte-for-byte, because the engine + * re-derives them when IT loads the binary (zend_file_cache_unserialize): + * + * - every opline's handler is the serialized INDEX into the VM handler table + * (zend_serialize_opcode_handler), not a callable handler pointer; + * - every IS_CONST operand is the literal's INDEX into op_array->literals, + * not the runtime relative-offset form RT_CONSTANT() dereferences on this + * platform (!ZEND_USE_ABS_CONST_ADDR). + * + * materialize() performs exactly the engine's own normalization, on a copy: + * the opcode array and the literal table are copied side by side into one + * process-owned block (the runtime constant form is a signed 32-bit offset + * from the opline, so both arrays must stay within one allocation, mirroring + * the engine's own co-allocation), IS_CONST operands are rewritten to that + * form and the handlers are restored through the engine's own + * zend_deserialize_opcode_handler(). The image buffer itself is never written, + * so PayloadRelocator::derelocate() (BinaryCacheFile::save()) keeps producing + * a valid binary after donors were materialized from the image. + * + * Everything else in the donor body - literal payloads (strings, immutable + * arrays), CV name table, arg_info, static-variable defaults - keeps pointing + * INTO the image buffer, exactly like the engine executes a file_cache_only + * script straight out of its load buffer. Two lifetime rules follow + * (docs/long-running.md): + * + * - the image buffer and the materialized block must outlive every entry the + * donor body was swapped into; both are request-lifetime allocations that + * are never explicitly freed, and this object pins the CData handles; + * - the donor op_array carries NO refcount (opcache persisted it that way), + * so the engine never destroys the swapped-in body: destroy_op_array + * releases only the entry's name and heap run-time cache, the same + * contract shared-memory bodies follow. + * + * @internal core-layer machinery of the cache-image bridge (CacheImageSync) + */ +final class ImageFunctionDonor +{ + /** + * fn_flags that describe how a body is STORED, not what it does: they legitimately + * differ between a serialized image body and the live entry compiled from the same + * source, so the equality basis masks them out. IMMUTABLE marks opcache-shared + * storage; HEAP_RT_CACHE marks a per-entry heap run-time cache (set by every swap). + */ + private const int STORAGE_ONLY_FLAGS = Core::ZEND_ACC_IMMUTABLE | Core::ZEND_ACC_HEAP_RT_CACHE; + + /** + * Opcodes whose op1 operand is the object RECEIVER: an IS_UNUSED op1 there means + * the implicit $this, and the compiler copies an UNINITIALIZED znode into the + * operand (zend_compile.c: zend_delayed_compile_prop() and the method-call path + * only set obj_node.op_type when this_guaranteed_exists()), so op1.num holds + * nondeterministic stack garbage. The VM never reads it for these opcodes, and the + * equality basis must ignore it - it differs between any two compilations. + * + * Deliberately UNTYPED (no `const array`): a typed array constant whose value is a + * constant expression trips the debug-build engine assertion + * `zend_update_class_constant: !EG(exception)` when this library is preloaded + * (opcache.preload evaluates the AST during preload linking) - the untyped form, + * like ClassDelta::MAGIC_METHOD_NAMES, preloads cleanly on release and debug alike. + * + * @var list + */ + private const THIS_RECEIVER_OPCODES = [ + OpCode::ASSIGN_OBJ, + OpCode::ASSIGN_OBJ_OP, + OpCode::ASSIGN_OBJ_REF, + OpCode::UNSET_OBJ, + OpCode::FETCH_OBJ_R, + OpCode::FETCH_OBJ_W, + OpCode::FETCH_OBJ_RW, + OpCode::FETCH_OBJ_IS, + OpCode::FETCH_OBJ_FUNC_ARG, + OpCode::FETCH_OBJ_UNSET, + OpCode::INIT_METHOD_CALL, + OpCode::PRE_INC_OBJ, + OpCode::PRE_DEC_OBJ, + OpCode::POST_INC_OBJ, + OpCode::POST_DEC_OBJ, + OpCode::ISSET_ISEMPTY_PROP_OBJ, + ]; + + /** + * @param ReflectionFunction $donorFunction Pointer-level view of the donor container + * @param CData|zend_function $container zend_function the swap machinery copies from + * @param CData $bodyBlock [opcodes][literals] block the donor points into + */ + private function __construct( + private readonly ReflectionFunction $donorFunction, + // @phpstan-ignore property.onlyWritten (pure lifetime retention until the swap commits) + private readonly object $container, + // @phpstan-ignore property.onlyWritten (pure lifetime retention: the swapped-in body executes out of it) + private readonly object $bodyBlock, + ) {} + + /** + * Materializes an executable donor from an image function (see class docblock) + * + * The returned object OWNS the donor: it must stay referenced at least until the + * body swap consuming the donor has committed (the zend_function container bytes + * are copied into the live entry by the swap), and the swapped-in body keeps + * executing out of the pinned block and the image buffer afterwards. + * + * @param FunctionLikeInterface $imageFunction User function of a relocated image + */ + public static function materialize(FunctionLikeInterface $imageFunction): self + { + $imageOpArray = $imageFunction->getOpArrayPointer(); + $opSize = Core::sizeOfType(zend_op::class); + $zvalSize = Core::sizeOfType(zval::class); + $opcodesBytes = $imageOpArray->last * $opSize; + $literalBytes = $imageOpArray->last_literal * $zvalSize; + + // One co-allocated [opcodes][literals] block: the runtime IS_CONST form is a + // signed 32-bit opline-relative offset, so the literal table must live next to + // the opcodes (the engine co-allocates them for the same reason). The block is + // request memory that is never explicitly freed - the refcount-less body stays + // published until request end, where table teardown provably never reads it + // (destroy_op_array returns before touching opcodes of a refcount-less body). + assert($imageOpArray->opcodes !== null && $opcodesBytes > 0); + $bodyBlock = Core::new('char[' . ($opcodesBytes + $literalBytes) . ']', false); + $blockAddress = Core::addressOf(Core::addr($bodyBlock)); + $literalsBase = $blockAddress + $opcodesBytes; + Core::memcpy($bodyBlock, $imageOpArray->opcodes, $opcodesBytes); + if ($literalBytes > 0) { + assert($imageOpArray->literals !== null); + $literalsTarget = Core::pointerAtAddress('char *', $literalsBase); + Core::memcpy($literalsTarget, $imageOpArray->literals, $literalBytes); + } + + for ($index = 0; $index < $imageOpArray->last; $index++) { + $oplineAddress = $blockAddress + $index * $opSize; + /** @var zend_op $opline */ + $opline = Core::pointerAtAddress('zend_op *', $oplineAddress); + if ($opline->op1_type === OpLine::IS_CONST) { + $opline->op1->constant = ($literalsBase + $opline->op1->constant * $zvalSize) - $oplineAddress; + } + if ($opline->op2_type === OpLine::IS_CONST) { + $opline->op2->constant = ($literalsBase + $opline->op2->constant * $zvalSize) - $oplineAddress; + } + // The engine's own index -> handler-pointer restoration (zend_vm.h), the + // exact call zend_file_cache_unserialize_op_array performs per opline + Core::call('zend_deserialize_opcode_handler', $opline); + } + + // The donor container is a writable copy of the image zend_function: the image + // struct itself is never written, so save()/derelocate() stays valid. The swap + // machinery copies the container bytes into the live entry wholesale. + $container = Core::new(zend_function::class); + Core::memcpy($container, $imageFunction->getEntryPointer(), Core::sizeOfType(zend_function::class)); + $donorFunction = ReflectionFunction::fromCData(Core::cast('zend_function *', Core::addr($container))); + $donorOpArray = $donorFunction->getOpArrayPointer(); + /** @var zend_op $blockOpcodes Narrowed at the boundary: the block starts with the opcode array */ + $blockOpcodes = Core::pointerAtAddress('zend_op *', $blockAddress); + $donorOpArray->opcodes = $blockOpcodes; + if ($literalBytes > 0) { + /** @var zval $blockLiterals Narrowed at the boundary: literals follow the opcodes */ + $blockLiterals = Core::pointerAtAddress('zval *', $literalsBase); + $donorOpArray->literals = $blockLiterals; + } + // The donor is a per-process body, not opcache-shared storage: without this the + // swapped-in entry would advertise ZEND_ACC_IMMUTABLE semantics (map-ptr offset + // statics slot, refusal of in-place mutation) it does not actually have + $donorFunction->getCommonPointer()->fn_flags &= ~Core::ZEND_ACC_IMMUTABLE; + + return new self($donorFunction, $container, $bodyBlock); + } + + /** + * The materialized donor, ready for FunctionBodySwap::swapUserFunctionBody() + */ + public function getDonor(): ReflectionFunction + { + return $this->donorFunction; + } + + /** + * Compares an image function body against a live entry's compiled body + * + * The equality basis is: the body metrics (opcode/literal/CV/temporary counts, + * argument counts), the fn_flags word without the storage-only bits, the CV name + * table, every opline in canonicalized form (IS_CONST operands compared by literal + * INDEX - derived from the runtime relative-offset form on the live side - and the + * handler ignored, since it is storage-form-specific), every literal by value + * (ReflectionValue::equals) and the static-variable defaults table by value. + * + * The comparison is deliberately conservative where value equality cannot be + * proven cheaply: array and constant-expression literals (and static defaults) + * always count as different, exactly like ReflectionMethod::equals(). A body that + * carries them is re-applied by every sync even when it did not change - a safe + * false POSITIVE. Known false NEGATIVES are declaration-surface-only edits the + * bridge does not model: arg_info type/name changes and doc comments do not enter + * the comparison (see docs/opcache-binary.md). + * + * @param FunctionLikeInterface $imageFunction User function of a relocated image (serialized opline form) + * @param FunctionLikeInterface $liveFunction Live user function published in an executor table (runtime form) + */ + public static function bodiesEqual(FunctionLikeInterface $imageFunction, FunctionLikeInterface $liveFunction): bool + { + $imageOpArray = $imageFunction->getOpArrayPointer(); + $liveOpArray = $liveFunction->getOpArrayPointer(); + + $metricsAgree = $imageOpArray->last === $liveOpArray->last + && $imageOpArray->last_var === $liveOpArray->last_var + && $imageOpArray->last_literal === $liveOpArray->last_literal + && $imageOpArray->T === $liveOpArray->T + && $imageOpArray->num_args === $liveOpArray->num_args + && $imageOpArray->required_num_args === $liveOpArray->required_num_args + && ($imageOpArray->fn_flags & ~self::STORAGE_ONLY_FLAGS) === ($liveOpArray->fn_flags & ~self::STORAGE_ONLY_FLAGS); + if (!$metricsAgree) { + return false; + } + if ($imageFunction->getVariableNames() !== $liveFunction->getVariableNames()) { + return false; + } + + return self::opcodesEqual($imageOpArray, $liveOpArray) + && self::literalsEqual($imageOpArray, $liveOpArray) + && self::staticDefaultsEqual($imageOpArray, $liveOpArray); + } + + /** + * Opline-by-opline comparison across the two storage forms (equal counts assumed) + * + * @param zend_op_array $imageOpArray Serialized form: IS_CONST operands hold literal indexes + * @param zend_op_array $liveOpArray Runtime form: IS_CONST operands hold opline-relative offsets + */ + private static function opcodesEqual(object $imageOpArray, object $liveOpArray): bool + { + $opSize = Core::sizeOfType(zend_op::class); + $zvalSize = Core::sizeOfType(zval::class); + assert($imageOpArray->opcodes !== null && $liveOpArray->opcodes !== null); + $imageBase = Core::addressOf($imageOpArray->opcodes); + $liveBase = Core::addressOf($liveOpArray->opcodes); + $liveLiteralsAddress = $liveOpArray->literals !== null ? Core::addressOf($liveOpArray->literals) : 0; + + for ($index = 0; $index < $imageOpArray->last; $index++) { + /** @var zend_op $imageOpline */ + $imageOpline = Core::pointerAtAddress('zend_op *', $imageBase + $index * $opSize); + /** @var zend_op $liveOpline */ + $liveOpline = Core::pointerAtAddress('zend_op *', $liveBase + $index * $opSize); + + $shapeAgrees = $imageOpline->opcode === $liveOpline->opcode + && $imageOpline->op1_type === $liveOpline->op1_type + && $imageOpline->op2_type === $liveOpline->op2_type + && $imageOpline->result_type === $liveOpline->result_type + && $imageOpline->extended_value === $liveOpline->extended_value + && $imageOpline->lineno === $liveOpline->lineno + && $imageOpline->result->num === $liveOpline->result->num; + if (!$shapeAgrees) { + return false; + } + + $liveOplineAddress = $liveBase + $index * $opSize; + $skipReceiverNoise = $imageOpline->op1_type === OpLine::IS_UNUSED + && in_array($imageOpline->opcode, self::THIS_RECEIVER_OPCODES, true); + if (!$skipReceiverNoise) { + $op1Agrees = self::operandsEqual( + $imageOpline->op1_type, + $imageOpline->op1, + $liveOpline->op1, + $liveOplineAddress, + $liveLiteralsAddress, + $zvalSize, + ); + if (!$op1Agrees) { + return false; + } + } + $op2Agrees = self::operandsEqual( + $imageOpline->op2_type, + $imageOpline->op2, + $liveOpline->op2, + $liveOplineAddress, + $liveLiteralsAddress, + $zvalSize, + ); + if (!$op2Agrees) { + return false; + } + } + + return true; + } + + /** + * Compares one operand across the two storage forms + * + * @param znode_op $imageOperand Serialized form: an IS_CONST operand holds the literal index + * @param znode_op $liveOperand Runtime form: an IS_CONST operand holds the opline-relative offset + */ + private static function operandsEqual( + int $operandType, + object $imageOperand, + object $liveOperand, + int $liveOplineAddress, + int $liveLiteralsAddress, + int $zvalSize, + ): bool { + if ($operandType === OpLine::IS_CONST) { + // Canonical form is the literal index: the live side stores the + // opline-relative byte offset of the literal (RT_CONSTANT form) + $liveOffset = self::toSignedInt32($liveOperand->constant); + $liveIndex = intdiv($liveOplineAddress + $liveOffset - $liveLiteralsAddress, $zvalSize); + + return $imageOperand->constant === $liveIndex; + } + + return $imageOperand->num === $liveOperand->num; + } + + /** + * Literal-table comparison by value (equal literal counts assumed) + * + * @param zend_op_array $imageOpArray + * @param zend_op_array $liveOpArray + */ + private static function literalsEqual(object $imageOpArray, object $liveOpArray): bool + { + $totalLiterals = $imageOpArray->last_literal; + if ($totalLiterals === 0) { + return true; + } + assert($imageOpArray->literals !== null && $liveOpArray->literals !== null); + $imageLiterals = new StructArray($imageOpArray->literals, $totalLiterals); + $liveLiterals = new StructArray($liveOpArray->literals, $totalLiterals); + for ($index = 0; $index < $totalLiterals; $index++) { + $imageValue = ReflectionValue::fromValueEntry($imageLiterals[$index]); + $liveValue = ReflectionValue::fromValueEntry($liveLiterals[$index]); + if (!$imageValue->equals($liveValue)) { + return false; + } + } + + return true; + } + + /** + * Static-variable DEFAULTS comparison by value (the declaration table, never the + * live per-process table a call may have materialized) + * + * @param zend_op_array $imageOpArray + * @param zend_op_array $liveOpArray + */ + private static function staticDefaultsEqual(object $imageOpArray, object $liveOpArray): bool + { + $imageDefaults = $imageOpArray->static_variables; + $liveDefaults = $liveOpArray->static_variables; + if (($imageDefaults === null) !== ($liveDefaults === null)) { + return false; + } + if ($imageDefaults === null || $liveDefaults === null) { + return true; + } + $imageTable = HashTable::fromCData($imageDefaults); + $liveTable = HashTable::fromCData($liveDefaults); + if (count($imageTable) !== count($liveTable)) { + return false; + } + foreach ($imageTable as $variableName => $imageValue) { + $liveValue = $liveTable->find((string) $variableName); + if ($liveValue === null || !$imageValue->equals($liveValue)) { + return false; + } + } + + return true; + } + + /** + * Reinterprets a uint32 field value as the signed 32-bit offset it stores + */ + private static function toSignedInt32(int $value): int + { + return $value >= 0x80000000 ? $value - 0x100000000 : $value; + } +} diff --git a/src/OpCache/ReflectionOpcacheFile.php b/src/OpCache/ReflectionOpcacheFile.php index cee2c121..8199698d 100644 --- a/src/OpCache/ReflectionOpcacheFile.php +++ b/src/OpCache/ReflectionOpcacheFile.php @@ -37,9 +37,18 @@ final class ReflectionOpcacheFile { /** - * @param \FFI\CData $script + * @param \FFI\CData $script Relocated zend_persistent_script inside the image buffer + * @param object|null $imageOwner Owner of the relocated buffer (the PayloadRelocator): + * retained so that holding this view alone provably keeps + * the buffer - which every wrapper this view hands out + * points into - alive (see CacheImageSync, whose swapped-in + * bodies keep executing out of that buffer) */ - public function __construct(private readonly object $script) {} + public function __construct( + private readonly object $script, + // @phpstan-ignore property.onlyWritten (lifetime pin: held, never read) + private readonly ?object $imageOwner = null, + ) {} /** * The cached script's source path (parity with ReflectionClass::getFileName()) diff --git a/src/Reflection/FunctionLikeInterface.php b/src/Reflection/FunctionLikeInterface.php index 7f1a5d1f..cd2058e9 100644 --- a/src/Reflection/FunctionLikeInterface.php +++ b/src/Reflection/FunctionLikeInterface.php @@ -51,4 +51,11 @@ public function getAddress(): int; public function getEntryPointer(): object; public function isUserDefined(): bool; + + /** + * Compiled-variable names by CV slot (see FunctionLikeTrait::getVariableNames()) + * + * @return array + */ + public function getVariableNames(): array; } diff --git a/src/Reflection/FunctionLikeTrait.php b/src/Reflection/FunctionLikeTrait.php index d9a21866..459e9df8 100644 --- a/src/Reflection/FunctionLikeTrait.php +++ b/src/Reflection/FunctionLikeTrait.php @@ -194,18 +194,7 @@ public function redefine(\Closure $newCode): void $closureEntry = ClosureEntry::fromCData(Core::cast('zend_closure *', $newCodeEntry)); $newFunction = $closureEntry->getRawFunction(); - $isSharedMemoryEntry = $this->isImmutable(); - if ($isSharedMemoryEntry) { - // Copy the entry out of SHM: the per-process bucket that publishes it is - // repointed at a writable container, the SHM original stays untouched - // (never written, never freed). A method entry lives inside the shared - // class entry, so the whole class is copied out and the swap targets the - // method entry of the writable copy. - $entryScope = $this->getCommonPointer()->scope; - $this->pointer = $entryScope !== null - ? $this->copyMethodOutOfSharedMemory($entryScope) - : $this->copyOutOfSharedMemory(); - } + $isSharedMemoryEntry = $this->copyEntryOutOfSharedMemory(); $entryFunction = ReflectionFunction::fromCData($this->pointer); $donorFunction = ReflectionFunction::fromCData(Core::addr($newFunction)); @@ -234,6 +223,41 @@ public function redefine(\Closure $newCode): void } } + /** + * Copies an opcache-shared (ZEND_ACC_IMMUTABLE) entry out of shared memory and + * rebinds this reflection to the writable entry now published in its table + * + * A no-op for entries that are not opcache-shared. For a global function the + * per-process function-table bucket is repointed at a writable container; for a + * method the whole declaring class is copied out (a method table lives inside the + * class entry) and the reflection rebinds to the method entry of the writable + * copy. In both cases the SHM original stays untouched - never written, never + * freed. See docs/hot-swap.md for the support matrix and the copy-out caveats. + * + * @return bool True when the entry was opcache-shared and is now copied out + * + * @throws SharedMemoryException When the entry cannot be copied out of shared memory + * + * @internal called by redefine(); shared with the cache-image bridge (CacheImageSync) + */ + public function copyEntryOutOfSharedMemory(): bool + { + if (!$this->isImmutable()) { + return false; + } + // Copy the entry out of SHM: the per-process bucket that publishes it is + // repointed at a writable container, the SHM original stays untouched + // (never written, never freed). A method entry lives inside the shared + // class entry, so the whole class is copied out and the swap targets the + // method entry of the writable copy. + $entryScope = $this->getCommonPointer()->scope; + $this->pointer = $entryScope !== null + ? $this->copyMethodOutOfSharedMemory($entryScope) + : $this->copyOutOfSharedMemory(); + + return true; + } + /** * Copies this opcache-shared global function out of shared memory into a writable * container and repoints its per-process function-table bucket at the copy diff --git a/tests/HotSwap/CacheImageSyncTest.php b/tests/HotSwap/CacheImageSyncTest.php new file mode 100644 index 00000000..a1905266 --- /dev/null +++ b/tests/HotSwap/CacheImageSyncTest.php @@ -0,0 +1,150 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\HotSwap; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\OpCache\FileCacheFixture; +use ZEngine\OpCache\PayloadRelocator; + +/** + * The file-cache -> runtime bridge end to end (issue #122): a patched + * ReflectionOpcacheFile image is applied to the functions and classes ALREADY + * LOADED in a live process, without re-including the script. + * + * Each probe runs in a child process (the bridge mutates executor tables and + * the child's clean exit doubles as the shutdown check), with the cache binary + * compiled into a per-test directory owned by this class: + * + * - plain child (opcache off): unchanged-noop diff, patched apply, live + * dispatch of the patched bodies, idempotent re-diff, single-use sync; + * - shared-memory child (opcache on): the same loop against immutable + * entries, proving the copy-out path and the untouched SHM originals; + * - refusal child: never-loaded images report not-loaded entries instead of + * crashing, unchanged enums pass, changed enum methods throw loudly. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +class CacheImageSyncTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX NTS payloads only' + . ' (ZTS is issue #118, Windows is issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testAppliesPatchedImageToLoadedEntries(): void + { + [$exitCode, $stdout, $report] = $this->runImageSyncChild(__DIR__ . '/scripts/cache-image-sync.php'); + + self::assertSame(0, $exitCode, "Image-sync child exited with code {$exitCode}\n{$report}"); + self::assertStringContainsString('noop-diff: ok', $stdout, $report); + self::assertStringContainsString('patched-apply: ok', $stdout, $report); + self::assertStringContainsString('live-dispatch: ok', $stdout, $report); + self::assertStringContainsString('idempotency: ok', $stdout, $report); + self::assertStringContainsString('IMAGE SYNC OK', $stdout, $report); + } + + public function testAppliesPatchedImageToSharedMemoryEntries(): void + { + [$exitCode, $stdout, $report] = $this->runImageSyncChild( + __DIR__ . '/scripts/cache-image-sync-shm.php', + [ + '-d', 'opcache.enable=1', + '-d', 'opcache.enable_cli=1', + // A freshly checked out fixture is younger than the default + // 2-second update protection and would silently not be cached + '-d', 'opcache.file_update_protection=0', + ], + ); + + self::assertSame(0, $exitCode, "SHM image-sync child exited with code {$exitCode}\n{$report}"); + self::assertStringContainsString('shm-noop-diff: ok', $stdout, $report); + self::assertStringContainsString('shm-apply: ok', $stdout, $report); + self::assertStringContainsString('shm-dispatch: ok', $stdout, $report); + self::assertStringContainsString('shm-copy-out: ok', $stdout, $report); + self::assertStringContainsString('shm-idempotency: ok', $stdout, $report); + self::assertStringContainsString('IMAGE SYNC SHM OK', $stdout, $report); + } + + public function testReportsNotLoadedEntriesAndRefusesEnumsLoudly(): void + { + [$exitCode, $stdout, $report] = $this->runImageSyncChild(__DIR__ . '/scripts/cache-image-sync-refusals.php'); + + self::assertSame(0, $exitCode, "Refusal child exited with code {$exitCode}\n{$report}"); + self::assertStringContainsString('not-loaded-report: ok', $stdout, $report); + self::assertStringContainsString('unchanged-enum: ok', $stdout, $report); + self::assertStringContainsString('refused-enum: ok', $stdout, $report); + self::assertStringContainsString('IMAGE SYNC REFUSALS OK', $stdout, $report); + } + + /** + * Runs one bridge probe in a child process with a fresh cache directory + * + * @param list $extraOptions Additional `php -d` options (the opcache leg) + * + * @return array{int, string, string} Exit code, stdout and a stdout+stderr report + */ + private function runImageSyncChild(string $scriptPath, array $extraOptions = []): array + { + if (!extension_loaded('Zend OPcache')) { + self::markTestSkipped('Zend OPcache extension is not loaded'); + } + self::$cacheDir = sys_get_temp_dir() . '/zengine-image-sync-' . bin2hex(random_bytes(6)); + self::assertTrue(mkdir(self::$cacheDir, 0o755, true), 'Cannot create the file-cache directory'); + + $command = [ + PHP_BINARY, + '-d', 'ffi.enable=1', + '-d', 'zend.assertions=1', + '-d', 'assert.exception=1', + '-d', 'display_errors=on', + '-d', 'error_reporting=-1', + '-d', 'memory_limit=512M', + // The JIT rewrites the executor internals z-engine hooks into (AGENTS.md) + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + ...$extraOptions, + $scriptPath, + self::$cacheDir, + ]; + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process, 'Unable to spawn the image-sync child process'); + + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + $report = "STDOUT:\n{$stdout}\nSTDERR:\n{$stderr}"; + if ($exitCode === 2) { + // Never a silent pass: the branch under test was not exercised at all + self::markTestSkipped("Opcache could not be activated in the child process\n{$report}"); + } + + return [$exitCode, $stdout, $report]; + } +} diff --git a/tests/HotSwap/scripts/cache-image-sync-refusals.php b/tests/HotSwap/scripts/cache-image-sync-refusals.php new file mode 100644 index 00000000..15eec141 --- /dev/null +++ b/tests/HotSwap/scripts/cache-image-sync-refusals.php @@ -0,0 +1,126 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +/** + * CacheImageSync refusal and not-loaded reporting probe (no opcache in this + * process): + * + * - an image whose script was never loaded here reports every entry as not + * loaded and applies as a loud no-op (never a crash, never an invention); + * - an UNCHANGED enum in a loaded image is not an operation and passes; + * - a CHANGED enum method is refused loudly (throw-or-work, never silent), + * and the live enum keeps executing its old body. + * + * argv: [1] = file-cache directory to compile the fixture into (parent-owned) + */ + +use ZEngine\Core; +use ZEngine\HotSwap\CacheImageSync; +use ZEngine\HotSwap\HotSwapException; +use ZEngine\OpCache\BinaryCacheFile; + +require __DIR__ . '/../../../vendor/autoload.php'; + +Core::init(); + +$fail = static function (string $message): never { + fwrite(STDERR, "{$message}\n"); + exit(1); +}; + +$cacheDir = $argv[1] ?? ''; +if ($cacheDir === '') { + $fail('cache directory argument missing'); +} +$fixture = realpath(__DIR__ . '/image-sync-enum-fixture.php'); +if ($fixture === false) { + $fail('enum fixture not found'); +} + +$file = BinaryCacheFile::compile($fixture, $cacheDir, PHP_BINARY, [ + 'opcache.optimization_level=0', + 'opcache.file_update_protection=0', +]); +$image = $file->getReflection(); + +// 1. Nothing of the image is loaded yet: everything lands in the not-loaded +// buckets and apply() is an explicit no-op report, not a crash +$orphan = CacheImageSync::prepare($image); +if (!$orphan->isEmpty()) { + $fail('image of a never-loaded script must diff as empty'); +} +$orphanReport = $orphan->apply(); +if (!$orphanReport->isNoOp()) { + $fail('applying an image of a never-loaded script must be a no-op'); +} +if ($orphanReport->notLoadedFunctions !== ['zengine_image_sync_enum_side']) { + $fail('the image-only function is not reported as not loaded'); +} +if ($orphanReport->notLoadedClasses !== ['zengineimagesyncchannel']) { + $fail('the image-only enum is not reported as not loaded'); +} +echo "not-loaded-report: ok\n"; + +// 2. Loaded and untouched: an enum in the image is not an operation, so +// nothing is refused and the apply stays a no-op +require $fixture; +$unchanged = CacheImageSync::prepare($image); +if (!$unchanged->isEmpty()) { + $fail('untouched enum image did not diff as empty'); +} +if (!$unchanged->apply()->isNoOp()) { + $fail('untouched enum image apply was not a no-op'); +} +echo "unchanged-enum: ok\n"; + +// 3. A patched enum METHOD is a refused operation: prepare() exposes the +// refusal, apply() throws it before touching anything +foreach ($image->getClasses()['zengineimagesyncchannel']->getDeclaredMethods()['describe']->getLiterals() as $literal) { + $value = null; + $literal->getNativeValue($value); + if ($value === 'channel-') { + $literal->setNativeValue('patched-'); + } +} +$refused = CacheImageSync::prepare($image); +if ($refused->isEmpty()) { + $fail('a changed enum method must not diff as empty'); +} +$reasons = $refused->getRefusalReasons(); +if ($reasons === [] || !str_contains($reasons[0], 'ZEngineImageSyncChannel')) { + $fail('the refusal reason does not name the enum'); +} +try { + $refused->apply(); + $fail('applying a changed enum method must throw'); +} catch (HotSwapException $exception) { + if (!str_contains($exception->getMessage(), 'enums are not supported')) { + $fail('unexpected refusal message: ' . $exception->getMessage()); + } +} +// The live enum still executes its previous body (runtime-name dispatch) +$callCase = static function (string $enum, string $method) use ($fail): string { + $case = constant("{$enum}::Stable"); + if (!is_object($case)) { + $fail("{$enum}::Stable is not a case object"); + } + $result = $case->{$method}(); + + return is_string($result) ? $result : ''; +}; +if ($callCase('ZEngineImageSyncChannel', 'describe') !== 'channel-stable') { + $fail('the refused enum body must stay untouched'); +} +echo "refused-enum: ok\n"; + +echo "IMAGE SYNC REFUSALS OK\n"; diff --git a/tests/HotSwap/scripts/cache-image-sync-shm.php b/tests/HotSwap/scripts/cache-image-sync-shm.php new file mode 100644 index 00000000..842b2e4f --- /dev/null +++ b/tests/HotSwap/scripts/cache-image-sync-shm.php @@ -0,0 +1,171 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +/** + * CacheImageSync against opcache SHARED MEMORY: this process runs with opcache + * enabled, so the fixture's entries are published immutable from shared + * memory. Applying a patched image must copy the targets out of SHM (the + * documented copy-out paths), swap the writable copies and leave the shared + * originals byte-for-byte untouched. + * + * argv: [1] = file-cache directory to compile the fixture into (parent-owned) + */ + +use FFI\CData; +use ZEngine\Core; +use ZEngine\HotSwap\CacheImageSync; +use ZEngine\OpCache\BinaryCacheFile; +use ZEngine\Reflection\ReflectionClass; +use ZEngine\Reflection\ReflectionFunction; +use ZEngine\Reflection\ReflectionMethod; + +require __DIR__ . '/../../../vendor/autoload.php'; + +if (!function_exists('opcache_get_status') || opcache_get_status(false) === false) { + fwrite(STDERR, "opcache is not active in the child process\n"); + exit(2); +} + +Core::init(); + +$fail = static function (string $message): never { + fwrite(STDERR, "{$message}\n"); + exit(1); +}; + +$cacheDir = $argv[1] ?? ''; +if ($cacheDir === '') { + $fail('cache directory argument missing'); +} +$fixture = realpath(__DIR__ . '/../../OpCache/fixtures/answer.php'); +if ($fixture === false) { + $fail('fixture not found'); +} + +// Default optimization on BOTH sides: the image compile child and this +// process's own require run the same opcache pipeline, so the untouched +// bodies must diff as equal +$file = BinaryCacheFile::compile($fixture, $cacheDir, PHP_BINARY, [ + 'opcache.file_update_protection=0', +]); +require $fixture; + +$liveFunction = new ReflectionFunction('zengine_bin_answer'); +if (!$liveFunction->isImmutable()) { + // Not published from shared memory: the copy-out branch under test would + // silently not be exercised + fwrite(STDERR, "zengine_bin_answer is not an immutable (shared-memory) function\n"); + exit(2); +} +$classValue = Core::$executor->classTable->find('zenginebinsubject'); +if ($classValue === null) { + $fail('fixture class is not published'); +} +$sharedEntry = $classValue->getRawClass(); +$sharedClass = ReflectionClass::fromCData($sharedEntry); +if (!$sharedClass->isImmutable()) { + fwrite(STDERR, "ZEngineBinSubject is not an immutable (shared-memory) class entry\n"); + exit(2); +} +$sharedMethodTable = $sharedEntry->function_table; +assert($sharedMethodTable instanceof CData); +$sharedFlagsBefore = $sharedEntry->ce_flags; +$sharedMethodsBefore = $sharedMethodTable->nNumOfElements; + +$image = $file->getReflection(); + +// 1. The untouched image diffs as equal against the SHM-published bodies +$untouched = CacheImageSync::prepare($image); +if (!$untouched->isEmpty()) { + $fail('untouched image did not diff as empty against shared memory'); +} +echo "shm-noop-diff: ok\n"; + +// 2. Patch and apply: the bridge copies the targets out of shared memory +$patchLiteral = static function (ReflectionFunction|ReflectionMethod $function, int|string $from, int|string $to): void { + foreach ($function->getLiterals() as $literal) { + $value = null; + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + } + } +}; +$patchLiteral($image->getFunctions()['zengine_bin_answer'], 41, 42); +$patchLiteral($image->getClasses()['zenginebinsubject']->getDeclaredMethods()['describe'], 1, 3); + +$report = CacheImageSync::prepare($image)->apply(); +if ($report->appliedFunctions !== ['zengine_bin_answer']) { + $fail('applied function report is wrong: ' . json_encode($report->appliedFunctions)); +} +if ($report->appliedMethods !== ['zenginebinsubject::describe']) { + $fail('applied method report is wrong: ' . json_encode($report->appliedMethods)); +} +echo "shm-apply: ok\n"; + +// 3. Live dispatch through runtime names executes the patched bodies +$callIt = static function (string $name) use ($fail): mixed { + if (!is_callable($name)) { + $fail("{$name} is not callable"); + } + + return $name(); +}; +$callStatic = static function (string $class, string $method, int $argument) use ($fail): string { + $callable = [$class, $method]; + if (!is_callable($callable)) { + $fail("{$class}::{$method} is not callable"); + } + $result = $callable($argument); + + return is_string($result) ? $result : ''; +}; +if ($callIt('zengine_bin_answer') !== 42) { + $fail('patched function body is not live'); +} +if ($callStatic('ZEngineBinSubject', 'describe', 0) !== 'stablestablestable') { + $fail('patched method body is not live'); +} +echo "shm-dispatch: ok\n"; + +// 4. The published entries are per-process copies now, and the shared-memory +// originals were neither written nor unpublished-and-freed +if ((new ReflectionFunction('zengine_bin_answer'))->isImmutable()) { + $fail('the swapped function entry is still marked immutable'); +} +$publishedValue = Core::$executor->classTable->find('zenginebinsubject'); +if ($publishedValue === null) { + $fail('the class lost its class-table bucket'); +} +$publishedEntry = $publishedValue->getRawClass(); +if (Core::addressOf($publishedEntry) === Core::addressOf($sharedEntry)) { + $fail('the class is still published from shared memory'); +} +if (ReflectionClass::fromCData($publishedEntry)->isImmutable()) { + $fail('the class copy is still marked immutable'); +} +if ($sharedEntry->ce_flags !== $sharedFlagsBefore) { + $fail('shared-memory ce_flags changed'); +} +if ($sharedMethodTable->nNumOfElements !== $sharedMethodsBefore) { + $fail('shared-memory method table changed'); +} +echo "shm-copy-out: ok\n"; + +// 5. Idempotency holds against the copied-out entries as well +if (!CacheImageSync::prepare($image)->isEmpty()) { + $fail('re-diff after apply is not empty'); +} +echo "shm-idempotency: ok\n"; + +echo "IMAGE SYNC SHM OK\n"; diff --git a/tests/HotSwap/scripts/cache-image-sync.php b/tests/HotSwap/scripts/cache-image-sync.php new file mode 100644 index 00000000..c7dba0be --- /dev/null +++ b/tests/HotSwap/scripts/cache-image-sync.php @@ -0,0 +1,158 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +/** + * CacheImageSync end-to-end probe WITHOUT opcache in this process: the fixture + * is loaded from source, the cache binary is compiled by a child with the + * optimizer off (so the compiled bodies provably match the source compile), + * then the image is patched and applied to the ALREADY-LOADED entries. + * + * argv: [1] = file-cache directory to compile the fixture into (parent-owned) + */ + +use ZEngine\Core; +use ZEngine\HotSwap\CacheImageSync; +use ZEngine\HotSwap\HotSwapException; +use ZEngine\OpCache\BinaryCacheFile; +use ZEngine\Reflection\ReflectionFunction; +use ZEngine\Reflection\ReflectionMethod; + +require __DIR__ . '/../../../vendor/autoload.php'; + +Core::init(); + +$fail = static function (string $message): never { + fwrite(STDERR, "{$message}\n"); + exit(1); +}; + +$cacheDir = $argv[1] ?? ''; +if ($cacheDir === '') { + $fail('cache directory argument missing'); +} +$fixture = realpath(__DIR__ . '/../../OpCache/fixtures/answer.php'); +if ($fixture === false) { + $fail('fixture not found'); +} + +// The optimizer is off for the image compile: this process loads the fixture +// from source (no opcache), and only unoptimized bodies are comparable with a +// plain source compile - which is exactly what the diff must report as equal +$file = BinaryCacheFile::compile($fixture, $cacheDir, PHP_BINARY, [ + 'opcache.optimization_level=0', + 'opcache.file_update_protection=0', +]); +require $fixture; + +$image = $file->getReflection(); + +// 1. An untouched image diffs as all-unchanged: applying is a loud no-op +$untouched = CacheImageSync::prepare($image); +if (!$untouched->isEmpty()) { + $fail('untouched image did not diff as empty'); +} +$noopReport = $untouched->apply(); +if (!$noopReport->isNoOp()) { + $fail('untouched image apply was not a no-op'); +} +if (!in_array('zengine_bin_answer', $noopReport->unchangedFunctions, true)) { + $fail('unchanged function is not reported'); +} +if (!in_array('zenginebinsubject::describe', $noopReport->unchangedMethods, true)) { + $fail('unchanged method is not reported'); +} +echo "noop-diff: ok\n"; + +// 2. Patch the image: a long literal, a size-changing string literal and a +// method literal (the method also carries statics and try/catch) +$patchLiteral = static function (ReflectionFunction|ReflectionMethod $function, int|string $from, int|string $to): void { + foreach ($function->getLiterals() as $literal) { + $value = null; + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + } + } +}; +$patchLiteral($image->getFunctions()['zengine_bin_answer'], 41, 42); +$patchLiteral($image->getFunctions()['zengine_bin_greeting'], 'hello', 'patched-hello'); +$patchLiteral($image->getClasses()['zenginebinsubject']->getDeclaredMethods()['describe'], 1, 3); + +$sync = CacheImageSync::prepare($image); +if ($sync->getChangedFunctions() !== ['zengine_bin_answer', 'zengine_bin_greeting']) { + $fail('changed function set is wrong: ' . json_encode($sync->getChangedFunctions())); +} +if ($sync->getChangedMethods() !== ['zenginebinsubject' => ['describe']]) { + $fail('changed method set is wrong: ' . json_encode($sync->getChangedMethods())); +} +$report = $sync->apply(); +if ($report->appliedFunctions !== ['zengine_bin_answer', 'zengine_bin_greeting']) { + $fail('applied function report is wrong'); +} +if ($report->appliedMethods !== ['zenginebinsubject::describe']) { + $fail('applied method report is wrong'); +} +if ($report->notLoadedFunctions !== [] || $report->notLoadedClasses !== [] || $report->notLoadedMethods !== []) { + $fail('nothing in this image should be reported as not loaded'); +} +echo "patched-apply: ok\n"; + +// 3. The LIVE, already-loaded entries now execute the patched bodies - no +// re-include happened. Runtime-name dispatch keeps every call site free of +// compile-time resolution and of statically-known results. +$callIt = static function (string $name) use ($fail): mixed { + if (!is_callable($name)) { + $fail("{$name} is not callable"); + } + + return $name(); +}; +$callStatic = static function (string $class, string $method, int $argument) use ($fail): string { + $callable = [$class, $method]; + if (!is_callable($callable)) { + $fail("{$class}::{$method} is not callable"); + } + $result = $callable($argument); + + return is_string($result) ? $result : ''; +}; +if ($callIt('zengine_bin_answer') !== 42) { + $fail('patched function body is not live'); +} +if ($callIt('zengine_bin_greeting') !== 'patched-hello') { + $fail('patched string-literal body is not live'); +} +if ($callStatic('ZEngineBinSubject', 'describe', 0) !== 'stablestablestable') { + $fail('patched method body is not live'); +} +// The method's static counter still works across calls on the swapped body +$callStatic('ZEngineBinSubject', 'describe', 0); +echo "live-dispatch: ok\n"; + +// 4. Idempotency: a fresh diff of the same image against the synced process is +// empty; re-applying the consumed sync is refused loudly +$again = CacheImageSync::prepare($image); +if (!$again->isEmpty()) { + $fail('re-diff after apply is not empty'); +} +try { + $sync->apply(); + $fail('double apply must throw'); +} catch (HotSwapException $exception) { + // expected: the sync is single-use +} +echo "idempotency: ok\n"; + +// Exit code 0 also proves the request shuts down cleanly with image-backed +// bodies still published in the executor tables +echo "IMAGE SYNC OK\n"; diff --git a/tests/HotSwap/scripts/image-sync-enum-fixture.php b/tests/HotSwap/scripts/image-sync-enum-fixture.php new file mode 100644 index 00000000..83fca68b --- /dev/null +++ b/tests/HotSwap/scripts/image-sync-enum-fixture.php @@ -0,0 +1,24 @@ +value; + } +} + +function zengine_image_sync_enum_side(): int +{ + return 11; +} From c191ed54efbdef6b45a7e05cfaa97bd170bbce87 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:34:44 +0000 Subject: [PATCH 12/28] Generate windows FFI engine definitions for PHP 8.4 (#59) --- include/8.4/windows-x64-nts/engine.h | 1 + include/8.4/windows-x64-zts/engine.h | 1 + 2 files changed, 2 insertions(+) diff --git a/include/8.4/windows-x64-nts/engine.h b/include/8.4/windows-x64-nts/engine.h index fa5d133c..dcd62bdf 100644 --- a/include/8.4/windows-x64-nts/engine.h +++ b/include/8.4/windows-x64-nts/engine.h @@ -1029,6 +1029,7 @@ extern zval * __vectorcall zend_hash_index_find(const HashTable *, zend_ulong); extern void __vectorcall zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void __vectorcall zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * __vectorcall zend_objects_new(zend_class_entry *); extern void __vectorcall zend_object_std_init(zend_object *, zend_class_entry *); diff --git a/include/8.4/windows-x64-zts/engine.h b/include/8.4/windows-x64-zts/engine.h index b520220e..71281700 100644 --- a/include/8.4/windows-x64-zts/engine.h +++ b/include/8.4/windows-x64-zts/engine.h @@ -1031,6 +1031,7 @@ extern zval * __vectorcall zend_hash_index_find(const HashTable *, zend_ulong); extern void __vectorcall zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void __vectorcall zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * __vectorcall zend_objects_new(zend_class_entry *); extern void __vectorcall zend_object_std_init(zend_object *, zend_class_entry *); From 4ef76c7025efe58b7b418377b2ee2ef6c31c7389 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:35:03 +0000 Subject: [PATCH 13/28] Generate darwin FFI engine definitions for PHP 8.4 (#58) --- include/8.4/darwin-arm64-nts/engine.h | 1 + include/8.4/darwin-arm64-zts/engine.h | 1 + include/8.4/darwin-x64-nts/engine.h | 1 + include/8.4/darwin-x64-zts/engine.h | 1 + 4 files changed, 4 insertions(+) diff --git a/include/8.4/darwin-arm64-nts/engine.h b/include/8.4/darwin-arm64-nts/engine.h index d6501a3f..442ab4ab 100644 --- a/include/8.4/darwin-arm64-nts/engine.h +++ b/include/8.4/darwin-arm64-nts/engine.h @@ -1009,6 +1009,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); diff --git a/include/8.4/darwin-arm64-zts/engine.h b/include/8.4/darwin-arm64-zts/engine.h index a06dbe01..740605b6 100644 --- a/include/8.4/darwin-arm64-zts/engine.h +++ b/include/8.4/darwin-arm64-zts/engine.h @@ -1011,6 +1011,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); diff --git a/include/8.4/darwin-x64-nts/engine.h b/include/8.4/darwin-x64-nts/engine.h index 0d117b32..ae2356be 100644 --- a/include/8.4/darwin-x64-nts/engine.h +++ b/include/8.4/darwin-x64-nts/engine.h @@ -1009,6 +1009,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); diff --git a/include/8.4/darwin-x64-zts/engine.h b/include/8.4/darwin-x64-zts/engine.h index 935f61c5..5a4f8066 100644 --- a/include/8.4/darwin-x64-zts/engine.h +++ b/include/8.4/darwin-x64-zts/engine.h @@ -1011,6 +1011,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); From 13e97541273ce61e9c62aa09de73fbb91f516738 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:59:59 +0000 Subject: [PATCH 14/28] feat(opcache): relocate trait names, aliases and precedences in class payloads Port the num_traits branch of zend_file_cache_serialize_class / zend_file_cache_unserialize_class (ext/opcache/zend_file_cache.c, PHP-8.4.19): trait_names walks through the existing zend_class_name helper, and the NULL-terminated trait_aliases / trait_precedences pointer arrays are relocated entry by entry - each entry's trait_method.method_name / trait_method.class_name (NULL-able), the alias name, and every exclude_class_names slot of an insteadof precedence, in both directions. New raw-slot primitives (unPtrAt/serPtrAt, unStrAt/serStrAt) port (UN)SERIALIZE_PTR/STR for slots that have no owning struct field. New fixture tests/OpCache/fixtures/traits.php uses two traits with an insteadof precedence (exclude list), an alias with an explicit trait name and an alias without one that also changes visibility. Acceptance evidence: - TraitRelocationTest::testTraitPayloadRoundTripsByteIdentical - byte-for-byte round trip of the compiled fixture - testUnmodifiedResaveStillExecutes / testPatchedTraitFixtureExecutesFromCache - the re-serialized (and patched) binary executes from the cache with the flattened trait behavior intact - full default suite, --group opcache --fail-on-skipped (host and z-engine-php:debug84 container), phpstan level max and php-cs-fixer all green Fixes #114 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/opcache-binary.md | 12 +-- src/OpCache/PayloadRelocator.php | 146 +++++++++++++++++++++++++- tests/OpCache/TraitRelocationTest.php | 115 ++++++++++++++++++++ tests/OpCache/fixtures/traits.php | 51 +++++++++ 4 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 tests/OpCache/TraitRelocationTest.php create mode 100644 tests/OpCache/fixtures/traits.php diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index 35663db2..12281674 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -121,12 +121,12 @@ function/method hot-swap API) is future work; see [hot-swap.md](hot-swap.md). macOS/arm64. ZTS payloads stay tracked in [#118](https://github.com/lisachenko/z-engine/issues/118). - **Strict, never silent.** Structures the port does not yet handle - (property hooks, iterator/ArrayAccess funcs, trait-using classes, compile - warnings) raise `unsupportedPayload` rather than writing a subtly corrupt - binary. Global functions, classes with constants, typed properties - (union/intersection/DNF type lists included), attributes (including - constant-expression arguments), static variables, try/catch and enums are - supported and round-trip byte-for-byte. + (property hooks, iterator/ArrayAccess funcs, compile warnings) raise + `unsupportedPayload` rather than writing a subtly corrupt binary. Global + functions, classes with constants, typed properties (union/intersection/DNF + type lists included), trait-using classes (aliases and insteadof precedences + included), attributes (including constant-expression arguments), static + variables, try/catch and enums are supported and round-trip byte-for-byte. - **Deferred.** Loading patched binaries into shared memory (ZCSG), and applying a patched image to already-loaded classes via `redefine()` / `ClassDelta`. diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index 569e11e2..f4173a68 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -253,6 +253,32 @@ private function serPtr(object $owner, string $field): int return $address; } + /** UNSERIALIZE_PTR on a raw pointer slot, returning the resolved address (0 for a NULL slot) */ + private function unPtrAt(int $slotAddress): int + { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); + $stored = (int) $slot[0]; + if ($stored === 0) { + return 0; + } + $slot[0] = $this->base + $stored; + + return $this->base + $stored; + } + + /** SERIALIZE_PTR on a raw pointer slot, returning the pre-serialization address (0 for a NULL slot) */ + private function serPtrAt(int $slotAddress): int + { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); + $address = (int) $slot[0]; + if ($address === 0) { + return 0; + } + $slot[0] = $address - $this->base; + + return $address; + } + // --- interned-string primitives (UNSERIALIZE_STR / SERIALIZE_STR) ------ /** * @param \FFI\CData $owner @@ -292,6 +318,36 @@ private function serStr(object $owner, string $field): void $this->writePtrField($owner, $field, $this->emitInterned($address)); } + /** UNSERIALIZE_STR on a raw zend_string* slot (no owning struct field) */ + private function unStrAt(int $slotAddress): void + { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); + $stored = (int) $slot[0]; + if ($stored === 0) { + return; + } + if (($stored & 1) !== 0) { + $slot[0] = $this->strSectionBase + ($stored & ~1); + } else { + $slot[0] = $this->base + $stored; + } + } + + /** SERIALIZE_STR on a raw zend_string* slot (no owning struct field) */ + private function serStrAt(int $slotAddress): void + { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); + $address = (int) $slot[0]; + if ($address === 0) { + return; + } + if ($address >= $this->base && $address < $this->base + $this->size) { + $slot[0] = $address - $this->base; + } else { + $slot[0] = $this->emitInterned($address); + } + } + /** * Copies an interned string into the rebuilt string section (deduplicated) * and returns its tagged offset - the port of zend_file_cache_serialize_interned. @@ -921,7 +977,9 @@ private function unserializeClass(object $zval): void $this->unserializeClassNames($ce, 'interface_names', $ce->num_interfaces); } if ($ce->num_traits !== 0) { - throw OpCacheException::unsupportedPayload('trait-using class relocation'); + $this->unserializeClassNames($ce, 'trait_names', $ce->num_traits); + $this->unserializeTraitAliases($ce); + $this->unserializeTraitPrecedences($ce); } foreach (self::MAGIC_METHOD_FIELDS as $field) { $this->unPtr($ce, $field); @@ -957,7 +1015,9 @@ private function serializeClass(object $zval): void $this->serializeClassNames($ce, 'interface_names', $ce->num_interfaces); } if ($ce->num_traits !== 0) { - throw OpCacheException::unsupportedPayload('trait-using class relocation'); + $this->serializeClassNames($ce, 'trait_names', $ce->num_traits); + $this->serializeTraitAliases($ce); + $this->serializeTraitPrecedences($ce); } foreach (self::MAGIC_METHOD_FIELDS as $field) { $this->serPtr($ce, $field); @@ -1063,6 +1123,88 @@ private function serializeClassNames(object $ce, string $field, int $count): voi $this->serStr($name, 'lc_name'); } } + + // --- traits (the num_traits branch of zend_file_cache_(un)serialize_class) + /** + * @param \FFI\CData $ce + */ + + private function unserializeTraitAliases(object $ce): void + { + if ($this->ptrValue($ce, 'trait_aliases') === 0) { + return; + } + // A NULL-terminated zend_trait_alias* array; each entry's strings follow + $slotAddress = $this->unPtr($ce, 'trait_aliases'); + while (($aliasAddress = $this->unPtrAt($slotAddress)) !== 0) { + $alias = Core::pointerAtAddress('zend_trait_alias *', $aliasAddress); + $this->unStr($alias->trait_method, 'method_name'); + $this->unStr($alias->trait_method, 'class_name'); + $this->unStr($alias, 'alias'); + $slotAddress += PHP_INT_SIZE; + } + } + /** + * @param \FFI\CData $ce + */ + + private function serializeTraitAliases(object $ce): void + { + if ($this->ptrValue($ce, 'trait_aliases') === 0) { + return; + } + $slotAddress = $this->serPtr($ce, 'trait_aliases'); + while (($aliasAddress = $this->serPtrAt($slotAddress)) !== 0) { + $alias = Core::pointerAtAddress('zend_trait_alias *', $aliasAddress); + $this->serStr($alias->trait_method, 'method_name'); + $this->serStr($alias->trait_method, 'class_name'); + $this->serStr($alias, 'alias'); + $slotAddress += PHP_INT_SIZE; + } + } + /** + * @param \FFI\CData $ce + */ + + private function unserializeTraitPrecedences(object $ce): void + { + if ($this->ptrValue($ce, 'trait_precedences') === 0) { + return; + } + // A NULL-terminated zend_trait_precedence* array with inline exclude names + $slotAddress = $this->unPtr($ce, 'trait_precedences'); + while (($precedenceAddress = $this->unPtrAt($slotAddress)) !== 0) { + $precedence = Core::pointerAtAddress('zend_trait_precedence *', $precedenceAddress); + $this->unStr($precedence->trait_method, 'method_name'); + $this->unStr($precedence->trait_method, 'class_name'); + $excludeBase = Core::addressOf($precedence->exclude_class_names); + for ($j = 0; $j < $precedence->num_excludes; $j++) { + $this->unStrAt($excludeBase + $j * PHP_INT_SIZE); + } + $slotAddress += PHP_INT_SIZE; + } + } + /** + * @param \FFI\CData $ce + */ + + private function serializeTraitPrecedences(object $ce): void + { + if ($this->ptrValue($ce, 'trait_precedences') === 0) { + return; + } + $slotAddress = $this->serPtr($ce, 'trait_precedences'); + while (($precedenceAddress = $this->serPtrAt($slotAddress)) !== 0) { + $precedence = Core::pointerAtAddress('zend_trait_precedence *', $precedenceAddress); + $this->serStr($precedence->trait_method, 'method_name'); + $this->serStr($precedence->trait_method, 'class_name'); + $excludeBase = Core::addressOf($precedence->exclude_class_names); + for ($j = 0; $j < $precedence->num_excludes; $j++) { + $this->serStrAt($excludeBase + $j * PHP_INT_SIZE); + } + $slotAddress += PHP_INT_SIZE; + } + } /** * @param \FFI\CData $zval */ diff --git a/tests/OpCache/TraitRelocationTest.php b/tests/OpCache/TraitRelocationTest.php new file mode 100644 index 00000000..171275c1 --- /dev/null +++ b/tests/OpCache/TraitRelocationTest.php @@ -0,0 +1,115 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; + +/** + * Relocation coverage for trait-using classes (issue #114): trait_names, + * trait_aliases (with and without an explicit trait name) and trait_precedences + * with exclude lists must round-trip byte-for-byte and execute after a + * patch-and-save cycle. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class TraitRelocationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX NTS payloads only' + . ' (ZTS is issue #118, Windows is issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testTraitPayloadRoundTripsByteIdentical(): void + { + $binPath = self::compileFixture(self::traitFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate(), 'A trait round trip must reproduce the payload byte-for-byte'); + } + + public function testUnmodifiedResaveStillExecutes(): void + { + $fixture = self::traitFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + $file->getReflection(); // relocate + $file->save(); // re-serialize unchanged + + self::assertTrue(BinaryCacheFile::read($file->binPath())->verifyChecksum()); + self::assertSame( + 'greeter-shared:shouter-shared:greeter:tr-ok', + self::runFromCache($fixture, 'zengine_bin_trait_run', self::$cacheDir), + ); + } + + public function testPatchedTraitFixtureExecutesFromCache(): void + { + $fixture = self::traitFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + self::patchStringLiteral($file, 'zengine_bin_trait_run', ':tr-ok', ':tr-patched-through-the-relocator'); + $file->save(); + + self::assertSame( + 'greeter-shared:shouter-shared:greeter:tr-patched-through-the-relocator', + self::runFromCache($fixture, 'zengine_bin_trait_run', self::$cacheDir), + ); + } + + private static function traitFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/traits.php'); + self::assertIsString($path); + + return $path; + } + + private static function patchStringLiteral(BinaryCacheFile $file, string $function, string $from, string $to): void + { + $functions = $file->getReflection()->getFunctions(); + self::assertArrayHasKey($function, $functions, "Function {$function} not found in the cached script"); + foreach ($functions[$function]->getLiterals() as $literal) { + if ($literal->getBaseType() !== ReflectionValue::IS_STRING) { + continue; + } + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + + return; + } + } + self::fail("String literal '{$from}' not found in {$function}"); + } +} diff --git a/tests/OpCache/fixtures/traits.php b/tests/OpCache/fixtures/traits.php new file mode 100644 index 00000000..ab3c65d9 --- /dev/null +++ b/tests/OpCache/fixtures/traits.php @@ -0,0 +1,51 @@ +shared(), $this->shoutedShared(), $this->whisper()]); + } +} + +function zengine_bin_trait_run(): string +{ + return (new ZEngineTraitUser())->report() . ':tr-ok'; +} From 53842e571a03a7c17454dd6d478c34a106af47c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 22:40:06 +0000 Subject: [PATCH 15/28] test(opcache): make the CacheImageSync plain legs hermetic The plain and refusal legs of CacheImageSyncTest pair an optimizer-OFF cache image (compiled by BinaryCacheFile::compile with opcache.optimization_level=0) with an unoptimized live side loaded from source, then assert an untouched image diffs as empty. That only holds when both are compiled at the SAME optimization level - the bridge's documented contract. The opcache-runner CI job sets opcache.enable_cli=1 in php.ini, which leaked into these children and ran the optimizer over their live-side require. The live entry then had a genuinely different compiled body (literal folding collapsed the 3-opcode source body to 1), so bodiesEqual() correctly reported a change and the untouched-diff assertion failed. Not a diff-basis gap: an optimizer-transformed body IS different machine code, and the diff is not meant to canonicalize across optimizer passes. Pin opcache.enable_cli=0 in the base child command so the plain leg is deterministic whatever the runner's php.ini says; the shared-memory leg re-enables it through $extraOptions, which come last and win. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- tests/HotSwap/CacheImageSyncTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/HotSwap/CacheImageSyncTest.php b/tests/HotSwap/CacheImageSyncTest.php index a1905266..51a1efb9 100644 --- a/tests/HotSwap/CacheImageSyncTest.php +++ b/tests/HotSwap/CacheImageSyncTest.php @@ -126,6 +126,17 @@ private function runImageSyncChild(string $scriptPath, array $extraOptions = []) // The JIT rewrites the executor internals z-engine hooks into (AGENTS.md) '-d', 'opcache.jit=off', '-d', 'opcache.jit_buffer_size=0', + // Hermeticity: the bridge diffs an image against a live entry compiled at + // the SAME optimization level, and the plain/refusal legs deliberately + // pair an optimizer-off image (compiled by BinaryCacheFile::compile with + // opcache.optimization_level=0) with an unoptimized live side loaded from + // source. The opcache-runner CI job sets opcache.enable_cli=1 in php.ini, + // which would otherwise leak into this child and optimize its live-side + // require - producing a genuinely different (spuriously non-empty) diff. + // Pin CLI opcache off here so the plain leg is deterministic whatever the + // runner's php.ini says; the shared-memory leg re-enables it via + // $extraOptions, which come last and win (php applies -d in order). + '-d', 'opcache.enable_cli=0', ...$extraOptions, $scriptPath, self::$cacheDir, From a756aadd13fa75fc7c170c553ef51f21af63c06a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:02:20 +0000 Subject: [PATCH 16/28] feat(opcache): relocate dynamic function definitions (closures/arrow fns) Port the num_dynamic_func_defs branch of zend_file_cache_serialize_op_array / zend_file_cache_unserialize_op_array (ext/opcache/zend_file_cache.c, PHP-8.4.19): the zend_op_array* array is relocated, each def slot is converted like the C SERIALIZE_PTR/UNSERIALIZE_PTR pair (offsets stored, walking continues through the still-real address) and the walk recurses into every nested op_array - so a closure defined inside another closure unfolds through its own dynamic_func_defs. Both directions; the unsupportedPayload refusal for closures is gone. New fixture tests/OpCache/fixtures/closures.php holds an arrow function and an anonymous function in a global function, a closure nested inside another closure, and a scoped arrow function inside a static method. Acceptance evidence: - ClosureRelocationTest::testClosurePayloadRoundTripsByteIdentical - byte-for-byte round trip of the compiled fixture - testUnmodifiedResaveStillExecutes / testPatchedClosureFixtureExecutesFromCache - the re-serialized (and patched) binary executes all closures from the cache ('cl:42:42:...' proves arrow, anonymous, nested and method-scoped defs) - full default suite, --group opcache --fail-on-skipped (host and z-engine-php:debug84 container), phpstan level max and php-cs-fixer all green Fixes #115 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/opcache-binary.md | 5 +- src/OpCache/PayloadRelocator.php | 15 +++- tests/OpCache/ClosureRelocationTest.php | 115 ++++++++++++++++++++++++ tests/OpCache/fixtures/closures.php | 34 +++++++ 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/OpCache/ClosureRelocationTest.php create mode 100644 tests/OpCache/fixtures/closures.php diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index 12281674..73d43dac 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -125,8 +125,9 @@ function/method hot-swap API) is future work; see [hot-swap.md](hot-swap.md). `unsupportedPayload` rather than writing a subtly corrupt binary. Global functions, classes with constants, typed properties (union/intersection/DNF type lists included), trait-using classes (aliases and insteadof precedences - included), attributes (including constant-expression arguments), static - variables, try/catch and enums are supported and round-trip byte-for-byte. + included), closures and arrow functions (nested dynamic_func_defs included), + attributes (including constant-expression arguments), static variables, + try/catch and enums are supported and round-trip byte-for-byte. - **Deferred.** Loading patched binaries into shared memory (ZCSG), and applying a patched image to already-loaded classes via `redefine()` / `ClassDelta`. diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index f4173a68..ccf7deb5 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -772,7 +772,12 @@ private function unserializeOpArray(object $opArray): void $this->unserializeArgInfo($opArray); $this->unserializeVars($opArray); if ($opArray->num_dynamic_func_defs !== 0) { - throw OpCacheException::unsupportedPayload('dynamic function definitions (closures/arrow fns) relocation'); + // zend_op_array* array: relocate it, then recurse into each nested body + $defsAddress = $this->unPtr($opArray, 'dynamic_func_defs'); + for ($i = 0; $i < $opArray->num_dynamic_func_defs; $i++) { + $defAddress = $this->unPtrAt($defsAddress + $i * PHP_INT_SIZE); + $this->unserializeOpArray(Core::pointerAtAddress('zend_op_array *', $defAddress)); + } } $this->unStr($opArray, 'function_name'); $this->unStr($opArray, 'filename'); @@ -831,7 +836,13 @@ private function serializeOpArray(object $opArray): void $this->serializeArgInfo($opArray); $this->serializeVars($opArray); if ($opArray->num_dynamic_func_defs !== 0) { - throw OpCacheException::unsupportedPayload('dynamic function definitions (closures/arrow fns) relocation'); + // Store offsets but keep walking through the still-real addresses, + // exactly like the C SERIALIZE_PTR/UNSERIALIZE_PTR pairs + $defsAddress = $this->serPtr($opArray, 'dynamic_func_defs'); + for ($i = 0; $i < $opArray->num_dynamic_func_defs; $i++) { + $defAddress = $this->serPtrAt($defsAddress + $i * PHP_INT_SIZE); + $this->serializeOpArray(Core::pointerAtAddress('zend_op_array *', $defAddress)); + } } $this->serStr($opArray, 'function_name'); $this->serStr($opArray, 'filename'); diff --git a/tests/OpCache/ClosureRelocationTest.php b/tests/OpCache/ClosureRelocationTest.php new file mode 100644 index 00000000..1e9e8d96 --- /dev/null +++ b/tests/OpCache/ClosureRelocationTest.php @@ -0,0 +1,115 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; + +/** + * Relocation coverage for dynamic function definitions (issue #115): arrow + * functions and anonymous closures - including a closure nested inside another + * closure and one inside a method - must round-trip byte-for-byte and execute + * after a patch-and-save cycle. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class ClosureRelocationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX NTS payloads only' + . ' (ZTS is issue #118, Windows is issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testClosurePayloadRoundTripsByteIdentical(): void + { + $binPath = self::compileFixture(self::closureFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate(), 'A closure round trip must reproduce the payload byte-for-byte'); + } + + public function testUnmodifiedResaveStillExecutes(): void + { + $fixture = self::closureFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + $file->getReflection(); // relocate + $file->save(); // re-serialize unchanged + + self::assertTrue(BinaryCacheFile::read($file->binPath())->verifyChecksum()); + self::assertSame( + 'cl:42:42:cl-ok', + self::runFromCache($fixture, 'zengine_bin_closures_run', self::$cacheDir), + ); + } + + public function testPatchedClosureFixtureExecutesFromCache(): void + { + $fixture = self::closureFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + self::patchStringLiteral($file, 'zengine_bin_closures_run', ':cl-ok', ':cl-patched-through-the-relocator'); + $file->save(); + + self::assertSame( + 'cl:42:42:cl-patched-through-the-relocator', + self::runFromCache($fixture, 'zengine_bin_closures_run', self::$cacheDir), + ); + } + + private static function closureFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/closures.php'); + self::assertIsString($path); + + return $path; + } + + private static function patchStringLiteral(BinaryCacheFile $file, string $function, string $from, string $to): void + { + $functions = $file->getReflection()->getFunctions(); + self::assertArrayHasKey($function, $functions, "Function {$function} not found in the cached script"); + foreach ($functions[$function]->getLiterals() as $literal) { + if ($literal->getBaseType() !== ReflectionValue::IS_STRING) { + continue; + } + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + + return; + } + } + self::fail("String literal '{$from}' not found in {$function}"); + } +} diff --git a/tests/OpCache/fixtures/closures.php b/tests/OpCache/fixtures/closures.php new file mode 100644 index 00000000..fff2310c --- /dev/null +++ b/tests/OpCache/fixtures/closures.php @@ -0,0 +1,34 @@ + $a + $b; + + return $add(40, 2); + } +} + +function zengine_bin_closures_run(): string +{ + $factor = 3; + $arrow = fn(int $value): int => $value * $factor; + $anon = function (string $word) use ($factor): string { + $inner = fn(): int => $factor + 1; + + return str_repeat($word, $inner() - $factor); + }; + + return $anon('cl') . ':' . $arrow(14) . ':' . ZEngineClosureHost::tally() . ':cl-ok'; +} From 2da4dad8c11531dd82516d4da7deee656c174010 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:11:55 +0000 Subject: [PATCH 17/28] feat(opcache): relocate iterator and ArrayAccess func structs in class payloads Port the iterator_funcs_ptr and arrayaccess_funcs_ptr branches of zend_file_cache_serialize_class / zend_file_cache_unserialize_class (ext/opcache/zend_file_cache.c, PHP-8.4.19): each zf_* zend_function pointer and the struct pointer itself are relocated in both directions, keeping the exact C ordering (serialize converts the members through the still-real struct pointer first and the struct pointer last; unserialize the reverse). NULL zf_* members stay NULL, as in the C macros. The get_iterator <-> HOOKED_ITERATOR_PLACEHOLDER swap of the 8.4 load path is deliberately not mirrored: the image is never executed in this process, so the placeholder is preserved verbatim like every other execution-only field and the written file keeps the exact bytes the engine expects (documented on unserializeIteratorFuncs). New fixture tests/OpCache/fixtures/iterators.php compiles classes implementing Iterator, IteratorAggregate and ArrayAccess. Plain compiles store classes unlinked (zend_compile does not early-bind classes that implement interfaces), so both pointers are NULL in such payloads; the crafted-buffer test drives the ported walk itself against an image whose class carries both structs with serialized offsets. Acceptance evidence: - IteratorFuncsRelocationTest::testIteratorPayloadRoundTripsByteIdentical - byte-for-byte round trip of the compiled fixture - testUnmodifiedResaveStillExecutes / testPatchedIteratorFixtureExecutesFromCache - the re-serialized (and patched) binary executes foreach over Iterator and IteratorAggregate plus ArrayAccess reads/writes from the cache - testCraftedIteratorFuncsRelocateAndSerializeBack - both structs and every zf_* slot (NULLs included) relocate to real addresses and serialize back to the exact original bytes - full default suite, --group opcache --fail-on-skipped (host and z-engine-php:debug84 container), phpstan level max and php-cs-fixer all green Fixes #116 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/opcache-binary.md | 16 +- src/OpCache/PayloadRelocator.php | 49 ++++- tests/OpCache/IteratorFuncsRelocationTest.php | 199 ++++++++++++++++++ tests/OpCache/fixtures/iterators.php | 103 +++++++++ 4 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 tests/OpCache/IteratorFuncsRelocationTest.php create mode 100644 tests/OpCache/fixtures/iterators.php diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index 73d43dac..15248fb3 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -121,13 +121,15 @@ function/method hot-swap API) is future work; see [hot-swap.md](hot-swap.md). macOS/arm64. ZTS payloads stay tracked in [#118](https://github.com/lisachenko/z-engine/issues/118). - **Strict, never silent.** Structures the port does not yet handle - (property hooks, iterator/ArrayAccess funcs, compile warnings) raise - `unsupportedPayload` rather than writing a subtly corrupt binary. Global - functions, classes with constants, typed properties (union/intersection/DNF - type lists included), trait-using classes (aliases and insteadof precedences - included), closures and arrow functions (nested dynamic_func_defs included), - attributes (including constant-expression arguments), static variables, - try/catch and enums are supported and round-trip byte-for-byte. + (property hooks, compile warnings) raise `unsupportedPayload` rather than + writing a subtly corrupt binary. Global functions, classes with constants, + typed properties (union/intersection/DNF type lists included), trait-using + classes (aliases and insteadof precedences included), closures and arrow + functions (nested dynamic_func_defs included), Iterator/IteratorAggregate/ + ArrayAccess classes (including the linked-class iterator_funcs_ptr / + arrayaccess_funcs_ptr structs), attributes (including constant-expression + arguments), static variables, try/catch and enums are supported and + round-trip byte-for-byte. - **Deferred.** Loading patched binaries into shared memory (ZCSG), and applying a patched image to already-loaded classes via `redefine()` / `ClassDelta`. diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index ccf7deb5..990a5c21 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -1312,13 +1312,38 @@ private function serializeClassConstant(object $zval): void * @param \FFI\CData $ce */ + /** + * zf_* field order matches the C walk (zend_file_cache.c), not the struct layout. + * Only linked classes carry these structs - a plain compile stores classes + * unlinked with both pointers NULL - but payloads from other producers (e.g. + * preload-era images) may hold them, and the walk must be faithful when they do. + */ + private const ITERATOR_FUNC_FIELDS = ['zf_new_iterator', 'zf_rewind', 'zf_valid', 'zf_key', 'zf_current', 'zf_next']; + private const ARRAYACCESS_FUNC_FIELDS = ['zf_offsetget', 'zf_offsetexists', 'zf_offsetset', 'zf_offsetunset']; + + /** + * The get_iterator <-> HOOKED_ITERATOR_PLACEHOLDER swap the C load path performs + * is deliberately NOT mirrored: the image is never executed in this process, so + * the placeholder is preserved verbatim like every other execution-only field + * and the written file keeps the exact bytes the engine expects. + * + * @param \FFI\CData $ce + */ private function unserializeIteratorFuncs(object $ce): void { if ($this->ptrValue($ce, 'iterator_funcs_ptr') !== 0) { - throw OpCacheException::unsupportedPayload('iterator-aware class relocation'); + $address = $this->unPtr($ce, 'iterator_funcs_ptr'); + $funcs = Core::pointerAtAddress('zend_class_iterator_funcs *', $address); + foreach (self::ITERATOR_FUNC_FIELDS as $field) { + $this->unPtr($funcs, $field); + } } if ($this->ptrValue($ce, 'arrayaccess_funcs_ptr') !== 0) { - throw OpCacheException::unsupportedPayload('ArrayAccess class relocation'); + $address = $this->unPtr($ce, 'arrayaccess_funcs_ptr'); + $funcs = Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $address); + foreach (self::ARRAYACCESS_FUNC_FIELDS as $field) { + $this->unPtr($funcs, $field); + } } } /** @@ -1327,11 +1352,23 @@ private function unserializeIteratorFuncs(object $ce): void private function serializeIteratorFuncs(object $ce): void { - if ($this->ptrValue($ce, 'iterator_funcs_ptr') !== 0) { - throw OpCacheException::unsupportedPayload('iterator-aware class relocation'); + // The C serialize converts the zf_* members through the still-real struct + // pointer first and the struct pointer itself last; mirrored exactly + $iteratorAddress = $this->ptrValue($ce, 'iterator_funcs_ptr'); + if ($iteratorAddress !== 0) { + $funcs = Core::pointerAtAddress('zend_class_iterator_funcs *', $iteratorAddress); + foreach (self::ITERATOR_FUNC_FIELDS as $field) { + $this->serPtr($funcs, $field); + } + $this->serPtr($ce, 'iterator_funcs_ptr'); } - if ($this->ptrValue($ce, 'arrayaccess_funcs_ptr') !== 0) { - throw OpCacheException::unsupportedPayload('ArrayAccess class relocation'); + $arrayAccessAddress = $this->ptrValue($ce, 'arrayaccess_funcs_ptr'); + if ($arrayAccessAddress !== 0) { + $funcs = Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $arrayAccessAddress); + foreach (self::ARRAYACCESS_FUNC_FIELDS as $field) { + $this->serPtr($funcs, $field); + } + $this->serPtr($ce, 'arrayaccess_funcs_ptr'); } } diff --git a/tests/OpCache/IteratorFuncsRelocationTest.php b/tests/OpCache/IteratorFuncsRelocationTest.php new file mode 100644 index 00000000..b06f90c9 --- /dev/null +++ b/tests/OpCache/IteratorFuncsRelocationTest.php @@ -0,0 +1,199 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; + +/** + * Relocation coverage for iterator_funcs_ptr / arrayaccess_funcs_ptr payloads + * (issue #116). Classes compiled into the file cache are stored unlinked (the + * compiler does not early-bind classes that implement interfaces), so the + * Iterator / IteratorAggregate / ArrayAccess fixture proves such classes + * round-trip byte-for-byte and execute after a patch-and-save cycle; the + * crafted-buffer test drives the pointer walk itself, which only fires for + * payloads whose classes were persisted linked. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class IteratorFuncsRelocationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX NTS payloads only' + . ' (ZTS is issue #118, Windows is issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testIteratorPayloadRoundTripsByteIdentical(): void + { + $binPath = self::compileFixture(self::iteratorFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate(), 'An iterator round trip must reproduce the payload byte-for-byte'); + } + + public function testUnmodifiedResaveStillExecutes(): void + { + $fixture = self::iteratorFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + $file->getReflection(); // relocate + $file->save(); // re-serialize unchanged + + self::assertTrue(BinaryCacheFile::read($file->binPath())->verifyChecksum()); + self::assertSame( + 'alpha:beta:gamma:delta:it-ok', + self::runFromCache($fixture, 'zengine_bin_iterators_run', self::$cacheDir), + ); + } + + public function testPatchedIteratorFixtureExecutesFromCache(): void + { + $fixture = self::iteratorFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + self::patchStringLiteral($file, 'zengine_bin_iterators_run', ':it-ok', ':it-patched-through-the-relocator'); + $file->save(); + + self::assertSame( + 'alpha:beta:gamma:delta:it-patched-through-the-relocator', + self::runFromCache($fixture, 'zengine_bin_iterators_run', self::$cacheDir), + ); + } + + /** + * Drives the ported iterator_funcs_ptr / arrayaccess_funcs_ptr walk against a + * crafted image: a zend_class_entry whose struct pointers and zf_* members hold + * serialized offsets (NULL slots included) must relocate to real addresses and + * serialize back to the exact original bytes. + */ + public function testCraftedIteratorFuncsRelocateAndSerializeBack(): void + { + $memSize = 4096; + $buffer = Core::new("char[{$memSize}]", false); + $meta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: $memSize, + strSize: 0, + scriptOffset: 0, + timestamp: 0, + checksum: 0, + ); + $relocator = new PayloadRelocator($buffer, $meta); + $base = Core::addressOf(Core::addr($buffer)); + + $ceOffset = 512; + $iteratorOffset = 1024; + $arrayAccessOffset = 1280; + $iteratorSlots = [ + 'zf_new_iterator' => 2048, + 'zf_rewind' => 2112, + 'zf_valid' => 2176, + 'zf_key' => 0, // NULL member must stay NULL through both directions + 'zf_current' => 2240, + 'zf_next' => 2304, + ]; + $arrayAccessSlots = [ + 'zf_offsetget' => 2368, + 'zf_offsetexists' => 2432, + 'zf_offsetset' => 0, + 'zf_offsetunset' => 2496, + ]; + + $ce = Core::pointerAtAddress('zend_class_entry *', $base + $ceOffset); + $ce->iterator_funcs_ptr = Core::pointerAtAddress('zend_class_iterator_funcs *', $iteratorOffset); + $ce->arrayaccess_funcs_ptr = Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $arrayAccessOffset); + $iteratorFuncs = Core::pointerAtAddress('zend_class_iterator_funcs *', $base + $iteratorOffset); + foreach ($iteratorSlots as $field => $offset) { + $iteratorFuncs->$field = $offset === 0 ? null : Core::pointerAtAddress('zend_function *', $offset); + } + $arrayAccessFuncs = Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $base + $arrayAccessOffset); + foreach ($arrayAccessSlots as $field => $offset) { + $arrayAccessFuncs->$field = $offset === 0 ? null : Core::pointerAtAddress('zend_function *', $offset); + } + $serializedBytes = \FFI::string($buffer, $memSize); + + $unserialize = new \ReflectionMethod(PayloadRelocator::class, 'unserializeIteratorFuncs'); + $unserialize->invoke($relocator, $ce); + + self::assertSame($base + $iteratorOffset, Core::addressOf($ce->iterator_funcs_ptr)); + self::assertSame($base + $arrayAccessOffset, Core::addressOf($ce->arrayaccess_funcs_ptr)); + foreach ($iteratorSlots as $field => $offset) { + self::assertMemberRelocated($iteratorFuncs->$field, $base, $offset, $field); + } + foreach ($arrayAccessSlots as $field => $offset) { + self::assertMemberRelocated($arrayAccessFuncs->$field, $base, $offset, $field); + } + + $serialize = new \ReflectionMethod(PayloadRelocator::class, 'serializeIteratorFuncs'); + $serialize->invoke($relocator, $ce); + + self::assertSame($serializedBytes, \FFI::string($buffer, $memSize), 'Serializing back must restore the exact original bytes'); + } + + private static function assertMemberRelocated(mixed $member, int $base, int $offset, string $field): void + { + if ($offset === 0) { + self::assertNull($member, "{$field} must stay NULL"); + + return; + } + self::assertInstanceOf(\FFI\CData::class, $member, "{$field} must still be a pointer"); + self::assertSame($base + $offset, Core::addressOf($member), "{$field} must be relocated"); + } + + private static function iteratorFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/iterators.php'); + self::assertIsString($path); + + return $path; + } + + private static function patchStringLiteral(BinaryCacheFile $file, string $function, string $from, string $to): void + { + $functions = $file->getReflection()->getFunctions(); + self::assertArrayHasKey($function, $functions, "Function {$function} not found in the cached script"); + foreach ($functions[$function]->getLiterals() as $literal) { + if ($literal->getBaseType() !== ReflectionValue::IS_STRING) { + continue; + } + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + + return; + } + } + self::fail("String literal '{$from}' not found in {$function}"); + } +} diff --git a/tests/OpCache/fixtures/iterators.php b/tests/OpCache/fixtures/iterators.php new file mode 100644 index 00000000..f4d10884 --- /dev/null +++ b/tests/OpCache/fixtures/iterators.php @@ -0,0 +1,103 @@ + */ +class ZEngineIteratorSteps implements Iterator +{ + /** @var list */ + private array $steps = ['alpha', 'beta']; + private int $index = 0; + + public function current(): string + { + return $this->steps[$this->index]; + } + + public function key(): int + { + return $this->index; + } + + public function next(): void + { + ++$this->index; + } + + public function rewind(): void + { + $this->index = 0; + } + + public function valid(): bool + { + return isset($this->steps[$this->index]); + } +} + +/** @implements IteratorAggregate */ +class ZEngineIteratorBag implements IteratorAggregate +{ + /** @return ArrayIterator */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator(['gamma']); + } +} + +/** @implements ArrayAccess */ +class ZEngineArrayShelf implements ArrayAccess +{ + /** @var array */ + private array $items = []; + + public function offsetExists(mixed $offset): bool + { + return is_string($offset) && isset($this->items[$offset]); + } + + public function offsetGet(mixed $offset): ?string + { + return is_string($offset) ? ($this->items[$offset] ?? null) : null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if (is_string($offset) && is_string($value)) { + $this->items[$offset] = $value; + } + } + + public function offsetUnset(mixed $offset): void + { + if (is_string($offset)) { + unset($this->items[$offset]); + } + } +} + +function zengine_bin_iterators_run(): string +{ + $collected = []; + foreach (new ZEngineIteratorSteps() as $step) { + $collected[] = $step; + } + foreach (new ZEngineIteratorBag() as $extra) { + $collected[] = $extra; + } + + $shelf = new ZEngineArrayShelf(); + $shelf['delta'] = 'delta'; + $collected[] = $shelf['delta'] ?? 'missing'; + unset($shelf['delta']); + + return implode(':', $collected) . ':it-ok'; +} From 040e736e854c31b1866fc9acc090553f4b2be9fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:16:00 +0000 Subject: [PATCH 18/28] feat(opcache): relocate property-hook op_arrays in the file-cache payload Port the prop->hooks branch of zend_file_cache_serialize_prop_info / zend_file_cache_unserialize_prop_info (ext/opcache/zend_file_cache.c, PHP-8.4.19): the zend_function*[ZEND_PROPERTY_HOOK_COUNT] array is relocated, and each non-NULL hook slot is converted and its op_array walked in both directions (hook bodies shared with the class function_table return early through the existing opcodes guard, as in C). NULL get/set slots stay NULL. The last payload-shape refusal is gone; only the platform refusals (Windows/32-bit, ZTS - issues #119/#118) remain. The hooked-class get_iterator field holds HOOKED_ITERATOR_PLACEHOLDER in the file; it is execution-only and preserved verbatim (see the #116 notes on unserializeIteratorFuncs), so the placeholder round-trips untouched. New fixture tests/OpCache/fixtures/property-hooks.php compiles a property with both get and set hooks, a get-only virtual property and a set-only backed property. Acceptance evidence: - PropertyHookRelocationTest::testPropertyHookPayloadRoundTripsByteIdentical - byte-for-byte round trip of the compiled fixture - testUnmodifiedResaveStillExecutes / testPatchedPropertyHookFixtureExecutesFromCache - the re-serialized (and patched) binary executes get/set hooks from the cache ('0:40:gauge-40:0:...' proves both hooks, the virtual getter and the set-only clamp ran) - full default suite, --group opcache --fail-on-skipped (host and z-engine-php:debug84 container), opcache-runner mode (opcache.enable_cli=1, --exclude-group performance/internal/opcache-incompatible), phpstan level max and php-cs-fixer all green Fixes #113 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/opcache-binary.md | 22 ++-- src/OpCache/PayloadRelocator.php | 23 +++- tests/OpCache/PropertyHookRelocationTest.php | 115 +++++++++++++++++++ tests/OpCache/fixtures/property-hooks.php | 40 +++++++ 4 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 tests/OpCache/PropertyHookRelocationTest.php create mode 100644 tests/OpCache/fixtures/property-hooks.php diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index 15248fb3..bf719480 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -120,16 +120,18 @@ function/method hot-swap API) is future work; see [hot-swap.md](hot-swap.md). [#119](https://github.com/lisachenko/z-engine/issues/119) was rescoped to macOS/arm64. ZTS payloads stay tracked in [#118](https://github.com/lisachenko/z-engine/issues/118). -- **Strict, never silent.** Structures the port does not yet handle - (property hooks, compile warnings) raise `unsupportedPayload` rather than - writing a subtly corrupt binary. Global functions, classes with constants, - typed properties (union/intersection/DNF type lists included), trait-using - classes (aliases and insteadof precedences included), closures and arrow - functions (nested dynamic_func_defs included), Iterator/IteratorAggregate/ - ArrayAccess classes (including the linked-class iterator_funcs_ptr / - arrayaccess_funcs_ptr structs), attributes (including constant-expression - arguments), static variables, try/catch and enums are supported and - round-trip byte-for-byte. +- **Strict, never silent.** Anything the port cannot handle raises + `unsupportedPayload` rather than writing a subtly corrupt binary; with every + payload shape of the 8.4 walker now ported, that guard covers the platform + predicates above (Windows/32-bit, and ZTS until #118). Global functions, + classes with constants, typed properties (union/intersection/DNF type lists + included), trait-using classes (aliases and insteadof precedences included), + closures and arrow functions (nested dynamic_func_defs included), + Iterator/IteratorAggregate/ArrayAccess classes (including the linked-class + iterator_funcs_ptr / arrayaccess_funcs_ptr structs), property hooks, + attributes (including constant-expression arguments), static variables, + compile warnings, try/catch and enums are supported and round-trip + byte-for-byte. - **Deferred.** Loading patched binaries into shared memory (ZCSG), and applying a patched image to already-loaded classes via `redefine()` / `ClassDelta`. diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index 990a5c21..3ed67d11 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -77,6 +77,9 @@ final class PayloadRelocator private const int TYPE_LIST_BIT = 4194304; // _ZEND_TYPE_LIST_BIT private const int TYPE_NAME_BIT = 16777216; // _ZEND_TYPE_NAME_BIT + /** ZEND_PROPERTY_HOOK_COUNT (zend_property_hooks.h) - get + set slots */ + private const int PROPERTY_HOOK_COUNT = 2; + private readonly int $base; private readonly int $size; private readonly int $strSectionBase; @@ -1237,7 +1240,15 @@ private function unserializePropInfo(object $zval): void $this->unserializeAttributes($prop, 'attributes'); $this->unPtr($prop, 'prototype'); if ($this->ptrValue($prop, 'hooks') !== 0) { - throw OpCacheException::unsupportedPayload('property-hook relocation'); + // zend_function*[ZEND_PROPERTY_HOOK_COUNT]: relocate the array, then + // each non-NULL hook and its op_array (a shared body returns early) + $hooksAddress = $this->unPtr($prop, 'hooks'); + for ($i = 0; $i < self::PROPERTY_HOOK_COUNT; $i++) { + $hookAddress = $this->unPtrAt($hooksAddress + $i * PHP_INT_SIZE); + if ($hookAddress !== 0) { + $this->unserializeOpArray(Core::pointerAtAddress('zend_function *', $hookAddress)->op_array); + } + } } $this->unserializeType($prop, 'type'); } @@ -1262,7 +1273,15 @@ private function serializePropInfo(object $zval): void $this->serializeAttributes($prop, 'attributes'); $this->serPtr($prop, 'prototype'); if ($this->ptrValue($prop, 'hooks') !== 0) { - throw OpCacheException::unsupportedPayload('property-hook relocation'); + // Offsets are stored while the walk continues through the still-real + // addresses, mirroring the C SERIALIZE_PTR/UNSERIALIZE_PTR pairs + $hooksAddress = $this->serPtr($prop, 'hooks'); + for ($i = 0; $i < self::PROPERTY_HOOK_COUNT; $i++) { + $hookAddress = $this->serPtrAt($hooksAddress + $i * PHP_INT_SIZE); + if ($hookAddress !== 0) { + $this->serializeOpArray(Core::pointerAtAddress('zend_function *', $hookAddress)->op_array); + } + } } $this->serializeType($prop, 'type'); } diff --git a/tests/OpCache/PropertyHookRelocationTest.php b/tests/OpCache/PropertyHookRelocationTest.php new file mode 100644 index 00000000..02460b7c --- /dev/null +++ b/tests/OpCache/PropertyHookRelocationTest.php @@ -0,0 +1,115 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; + +/** + * Relocation coverage for property hooks (issue #113): get/set hook op_arrays + * hanging off zend_property_info - including single-hook properties with a + * NULL slot - must round-trip byte-for-byte and execute after a patch-and-save + * cycle. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class PropertyHookRelocationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX NTS payloads only' + . ' (ZTS is issue #118, Windows is issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testPropertyHookPayloadRoundTripsByteIdentical(): void + { + $binPath = self::compileFixture(self::hookFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate(), 'A property-hook round trip must reproduce the payload byte-for-byte'); + } + + public function testUnmodifiedResaveStillExecutes(): void + { + $fixture = self::hookFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + $file->getReflection(); // relocate + $file->save(); // re-serialize unchanged + + self::assertTrue(BinaryCacheFile::read($file->binPath())->verifyChecksum()); + self::assertSame( + '0:40:gauge-40:0:ph-ok', + self::runFromCache($fixture, 'zengine_bin_hooks_run', self::$cacheDir), + ); + } + + public function testPatchedPropertyHookFixtureExecutesFromCache(): void + { + $fixture = self::hookFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + self::patchStringLiteral($file, 'zengine_bin_hooks_run', ':ph-ok', ':ph-patched-through-the-relocator'); + $file->save(); + + self::assertSame( + '0:40:gauge-40:0:ph-patched-through-the-relocator', + self::runFromCache($fixture, 'zengine_bin_hooks_run', self::$cacheDir), + ); + } + + private static function hookFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/property-hooks.php'); + self::assertIsString($path); + + return $path; + } + + private static function patchStringLiteral(BinaryCacheFile $file, string $function, string $from, string $to): void + { + $functions = $file->getReflection()->getFunctions(); + self::assertArrayHasKey($function, $functions, "Function {$function} not found in the cached script"); + foreach ($functions[$function]->getLiterals() as $literal) { + if ($literal->getBaseType() !== ReflectionValue::IS_STRING) { + continue; + } + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + + return; + } + } + self::fail("String literal '{$from}' not found in {$function}"); + } +} diff --git a/tests/OpCache/fixtures/property-hooks.php b/tests/OpCache/fixtures/property-hooks.php new file mode 100644 index 00000000..9d809ba5 --- /dev/null +++ b/tests/OpCache/fixtures/property-hooks.php @@ -0,0 +1,40 @@ +hooks walk of zend_file_cache_(un)serialize_prop_info: a + * property with both get and set hooks, a get-only virtual property (NULL set + * slot) and a set-only backed property (NULL get slot). + */ +declare(strict_types=1); + +class ZEngineHookedGauge +{ + public int $level = 1 { + get => $this->level * 10; + set(int $value) { + $this->level = max(0, $value); + } + } + + public string $label { + get => 'gauge-' . $this->level; + } + + public int $floor = 0 { + set => max(0, $value); + } +} + +function zengine_bin_hooks_run(): string +{ + $gauge = new ZEngineHookedGauge(); + $gauge->level = -5; + $before = $gauge->level; + $gauge->level = 4; + $gauge->floor = -9; + + return implode(':', [$before, $gauge->level, $gauge->label, $gauge->floor]) . ':ph-ok'; +} From 68dddd5b673df9d4a1d651666de1abb4613eedae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 23:57:06 +0000 Subject: [PATCH 19/28] ci: skip Homebrew auto-update/cleanup on the macOS setup-php jobs setup-php installs PHP through Homebrew on the macOS runners, and brew by default runs a full `brew update` before every install plus a cleanup pass after it. On these jobs that is tens of seconds of unrelated formula churn wrapped around a single PHP install - "Set up PHP" was measured at 36-53s on macOS arm64 and 78-101s on macOS x64, dominated by that churn rather than the install itself. Set HOMEBREW_NO_AUTO_UPDATE=1 and HOMEBREW_NO_INSTALL_CLEANUP=1 at job level on every macOS runner: tests-macos and header-drift-darwin in ci.yml, and the generate job of the darwin header workflow. The runner images already ship a recent brew and these jobs install nothing but PHP, so both passes are pure overhead. The Windows jobs are left untouched - setup-php uses Chocolatey there, not Homebrew, so these variables would be inert. No caching action is added (extension caching was considered and declined): this is env-only tuning that changes nothing about what the jobs build or test. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- .github/workflows/ci.yml | 13 +++++++++++++ .github/workflows/generate-darwin-headers.yml | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 810810ca..68498310 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,14 @@ jobs: tests-macos: name: Tests (macOS ${{ matrix.runner-arch.arch }}, ${{ matrix.ts }}) runs-on: ${{ matrix.runner-arch.runner }} + # setup-php installs PHP through Homebrew on macOS, and by default brew runs + # a full `brew update` before every install and a cleanup after it - tens of + # seconds of unrelated formula churn on top of the PHP install this job + # needs. Both are pure overhead here (the runner image already ships a recent + # brew and nothing else is installed), so switch them off for the whole job. + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_CLEANUP: '1' strategy: fail-fast: false matrix: @@ -603,6 +611,11 @@ jobs: header-drift-darwin: name: Generated darwin headers up to date (${{ matrix.runner-arch.arch }}, ${{ matrix.ts }}) runs-on: ${{ matrix.runner-arch.runner }} + # See tests-macos: skip brew's auto-update/cleanup so setup-php only pays for + # the PHP install it actually needs. + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_CLEANUP: '1' strategy: fail-fast: false matrix: diff --git a/.github/workflows/generate-darwin-headers.yml b/.github/workflows/generate-darwin-headers.yml index 75f7bd49..54840c92 100644 --- a/.github/workflows/generate-darwin-headers.yml +++ b/.github/workflows/generate-darwin-headers.yml @@ -40,6 +40,12 @@ env: jobs: generate: name: Generate darwin-${{ matrix.runner-arch.arch }}-${{ matrix.ts }} + # setup-php installs PHP through Homebrew on macOS; brew's default + # pre-install update and post-install cleanup are tens of seconds of + # unrelated churn this generate step does not need. Switch them off. + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_CLEANUP: '1' strategy: fail-fast: false matrix: From fb8bda9493cfe513cf75341b1382bd8d93b4c689 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:27:19 +0000 Subject: [PATCH 20/28] feat(opcache): support ZTS file-cache payloads in the relocator Lift the ZTS refusal in PayloadRelocator (isSupported() and the constructor throw). The refusal was precautionary, not structural: zend_file_cache.c (PHP-8.4.19) contains no thread-safety conditionals, and a field-by-field diff of the generated layouts.json for linux-x64-nts vs linux-x64-zts shows every struct the walker dereferences (zend_persistent_script, zend_file_cache_metainfo, zend_op_array, zend_class_entry, zend_string, Bucket, zval, ...) is byte-identical - only zend_executor_globals, zend_compiler_globals and zend_module_entry differ, none of which appear in a payload. No layout-dependent walking needed adapting. Config/docs follow: composer.json's test:opcache-zts drops the --exclude-group opcache-relocator exclusion (name kept as the alias CI's ZTS legs call), ci.yml's ZTS matrix comments/gates and the debug-job ZTS opcache_args now cover the full opcache group, AGENTS.md and docs/opcache-binary.md describe ZTS as supported, and the relocator tests' skip message no longer names ZTS. Acceptance evidence: - z-engine-php:debug84-zts container (PHP 8.4.24 ZTS DEBUG, built from tools/docker/php-debug.Dockerfile with PHP_TS=zts): `phpunit --group opcache --fail-on-skipped` with NO relocator exclusion - OK (47 tests, 318 assertions), zero skips: every relocator test ran and passed on ZTS, byte-identical round trips included - host NTS: full default suite (519 tests, skip/incomplete counts unchanged), --group opcache --fail-on-skipped OK (47), z-engine-php:debug84 NTS container OK (47), phpstan level max clean, php-cs-fixer clean Fixes #118 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- .github/workflows/ci.yml | 16 +++++++-------- AGENTS.md | 14 +++++++------ composer.json | 4 ++-- docs/opcache-binary.md | 10 +++++++--- src/OpCache/PayloadRelocator.php | 20 +++++++++---------- tests/OpCache/ClosureRelocationTest.php | 4 ++-- tests/OpCache/IteratorFuncsRelocationTest.php | 4 ++-- tests/OpCache/PropertyHookRelocationTest.php | 4 ++-- tests/OpCache/ReflectionOpcacheFileTest.php | 4 ++-- tests/OpCache/RefreshWorkflowTest.php | 4 ++-- tests/OpCache/SerializerRoundTripTest.php | 4 ++-- tests/OpCache/TraitRelocationTest.php | 4 ++-- tests/OpCache/TypeListRelocationTest.php | 4 ++-- 13 files changed, 50 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68498310..b446f79b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,9 +35,9 @@ jobs: opcache_gate: composer test:opcache - ts: zts ts_label: ZTS - # The file-cache relocator does not support ZTS payloads yet - # (issue #118): its tests self-skip on ZTS, so the non-skip gate - # excludes that group while still proving the SHM tests ran + # Since issue #118 the file-cache relocator supports ZTS payloads, + # so this gate covers the full opcache group (the script is the + # named alias the ZTS legs call; it no longer excludes anything) opcache_gate: composer test:opcache-zts steps: - uses: actions/checkout@v7 @@ -203,8 +203,8 @@ jobs: run: composer test # Same gates as the Linux legs (issue #124): the opcache/SHM tests must - # have RUN. As on Linux, the ZTS leg excludes the file-cache relocator - # group (no ZTS payload support yet, issue #118). + # have RUN. Since issue #118 the relocator group runs on ZTS too, so + # both legs cover the full opcache group. - name: Opcache/SHM coverage must not silently skip if: steps.artifacts.outputs.present == 'true' run: ${{ matrix.ts == 'zts' && 'composer test:opcache-zts' || 'composer test:opcache' }} @@ -384,12 +384,12 @@ jobs: include: - ts: nts ts_label: NTS - # ZTS excludes the relocator tests (no ZTS payload support yet, - # issue #118) but still gates the SHM coverage against silent skips opcache_args: --group opcache --fail-on-skipped - ts: zts ts_label: ZTS - opcache_args: --group opcache --exclude-group opcache-relocator --fail-on-skipped + # Since issue #118 the relocator tests run on ZTS too - both legs + # gate the full opcache group against silent skips + opcache_args: --group opcache --fail-on-skipped steps: - uses: actions/checkout@v7 diff --git a/AGENTS.md b/AGENTS.md index a1a3691e..341aefb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,8 +131,8 @@ PRs), or manually via `workflow_dispatch` against any branch. Darwin covers **NTS and ZTS**: the workflow's matrix crosses both architectures with both thread-safety modes (setup-php builds the ZTS PHP via `phpts: ts`). As on Linux, the ZTS artifacts reach EG/CG through the TSRM -offsets, and the opcache file-cache relocator stays unsupported on ZTS -(issue #118). After changing the generator on this branch, remember the 8.5 +offsets; the opcache file-cache relocator runs on ZTS since issue #118. +After changing the generator on this branch, remember the 8.5 line on `master` needs its own `workflow_dispatch` run to refresh its darwin artifacts. @@ -192,10 +192,12 @@ composer test:internal # destructive/segfault-prone group, process-isolated - `ZENGINE_STRICT_LAYOUT_CHECK=1` (set in the test bootstrap) makes `Core::init()` verify every struct layout against `layouts.json` before touching engine memory — the anti-segfault airbag. Keep it on in development. -- On **ZTS** builds the file-cache relocator tests (`opcache-relocator` group) - self-skip — ZTS payloads are not supported yet (issue #118). The non-skip - gate for the remaining opcache/SHM coverage is `composer test:opcache-zts`; - CI runs both release and debug test legs on NTS **and** ZTS. +- The file-cache relocator supports **ZTS** payloads since issue #118 (the + binary layout is thread-safety-agnostic: zend_file_cache.c has no ZTS + conditionals and every walked struct is layout-identical across the modes). + `composer test:opcache-zts` stays as the named alias CI's ZTS legs call; it + now runs the full opcache group, relocator tests included. CI runs both + release and debug test legs on NTS **and** ZTS. - `composer test:opcache-runner` runs the suite the way an opcache-enabled consumer does — `opcache.enable_cli=1` in the **runner process itself**, so every test file is compiled into shared memory (CI has a dedicated Linux job diff --git a/composer.json b/composer.json index f19d428b..b161e14c 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "test": "phpunit", "test:internal": "phpunit --group internal --process-isolation", "test:opcache": "phpunit --group opcache --fail-on-skipped", - "test:opcache-zts": "phpunit --group opcache --exclude-group opcache-relocator --fail-on-skipped", + "test:opcache-zts": "phpunit --group opcache --fail-on-skipped", "test:opcache-runner": "phpunit --exclude-group performance --exclude-group internal --exclude-group opcache-incompatible", "test:performance": "phpunit --group performance --fail-on-skipped", "phpstan": "phpstan analyse", @@ -53,7 +53,7 @@ "test": "Run the test suite (segfault-prone internal group excluded)", "test:internal": "Run the segfault-prone internal test group with process isolation (use a debug PHP build)", "test:opcache": "Run the opcache/shared-memory tests and FAIL if any of them skipped (they are self-skipping when opcache is unavailable)", - "test:opcache-zts": "Same non-skip gate for ZTS builds: excludes the file-cache relocator tests, which do not support ZTS payloads yet (issue #118)", + "test:opcache-zts": "Same non-skip gate on ZTS builds - kept as the named alias CI's ZTS legs call; since issue #118 the file-cache relocator tests run there too", "test:opcache-runner": "Run the suite for an opcache-ACTIVE runner (opcache.enable_cli=1): excludes the usual performance/internal groups plus opcache-incompatible, the issue-linked tests that cannot hold when the runner's own files live in shared memory", "test:performance": "Run the excluded performance group (the 'zero FFI at call time' benchmark) and FAIL if it silently skipped - its verdict is timing-based, so the CI leg that runs it is informational", "phpstan": "Run static analysis at the maximum level", diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index 2aeb5a30..c121a365 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -191,12 +191,16 @@ $report->appliedMethods; // what actually happened, per entry `opcache.preload`-based features) keep rejecting Windows loudly, and the Windows half of the original platform ticket was retired when [#119](https://github.com/lisachenko/z-engine/issues/119) was rescoped to - macOS/arm64. ZTS payloads stay tracked in - [#118](https://github.com/lisachenko/z-engine/issues/118). + macOS/arm64. ZTS payloads are supported since + [#118](https://github.com/lisachenko/z-engine/issues/118): the file-cache + binary layout is thread-safety-agnostic (zend_file_cache.c has no ZTS + conditionals, and every struct the walker dereferences is layout-identical + across the modes — only EG/CG/module_entry differ, none of which appear in + a payload). - **Strict, never silent.** Anything the port cannot handle raises `unsupportedPayload` rather than writing a subtly corrupt binary; with every payload shape of the 8.4 walker now ported, that guard covers the platform - predicates above (Windows/32-bit, and ZTS until #118). Global functions, + predicates above (Windows/32-bit). Global functions, classes with constants, typed properties (union/intersection/DNF type lists included), trait-using classes (aliases and insteadof precedences included), closures and arrow functions (nested dynamic_func_defs included), diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index 3ed67d11..21b16cb1 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -32,9 +32,12 @@ /** * Turns the position-independent file-cache payload into a live in-memory * image and back, a faithful port of ext/opcache/zend_file_cache.c - * (unserialize = {@see relocate}, serialize = {@see derelocate}) for the - * linux-x64 non-thread-safe build. Thread-safe (ZTS) payloads use a different - * binary layout and are rejected until issue #118 lands ZTS-specific walking. + * (unserialize = {@see relocate}, serialize = {@see derelocate}) for 64-bit + * POSIX builds, NTS and ZTS alike: zend_file_cache.c has no thread-safety + * conditionals, and every struct the walker dereferences is layout-identical + * across the two modes (only EG/CG/module_entry differ on ZTS, none of which + * appear in a payload) - verified against the generated layouts.json of both + * targets (issue #118). * * In the file every interior pointer is stored as a byte offset from the * buffer start (SERIALIZE_PTR) and every interned string as a tagged offset @@ -97,12 +100,12 @@ final class PayloadRelocator * Whether the relocator can handle payloads of the running build at all * * The exact predicate the constructor enforces, exposed so callers (and the tests - * covering them) can skip cleanly instead of provoking the throw. Windows payloads - * are tracked in issue #119, ZTS ones in issue #118. + * covering them) can skip cleanly instead of provoking the throw. Windows opcache + * support is an intentional non-goal (issue #119 was rescoped to macOS/arm64). */ public static function isSupported(): bool { - return PHP_INT_SIZE === 8 && \DIRECTORY_SEPARATOR === '/' && !\ZEND_THREAD_SAFE; + return PHP_INT_SIZE === 8 && \DIRECTORY_SEPARATOR === '/'; } /** @@ -114,11 +117,6 @@ public function __construct(private readonly object $buffer, private readonly Ca if (PHP_INT_SIZE !== 8 || \DIRECTORY_SEPARATOR !== '/') { throw OpCacheException::unsupportedPayload('the relocator supports 64-bit non-Windows builds only'); } - if (\ZEND_THREAD_SAFE) { - throw OpCacheException::unsupportedPayload( - 'ZTS file-cache payloads use a different binary layout - tracked in issue #118', - ); - } $this->base = Core::addressOf(Core::addr($buffer)); $this->size = $metaInfo->memSize(); $this->strSectionBase = $this->base + $this->size; diff --git a/tests/OpCache/ClosureRelocationTest.php b/tests/OpCache/ClosureRelocationTest.php index 1e9e8d96..f7046fcc 100644 --- a/tests/OpCache/ClosureRelocationTest.php +++ b/tests/OpCache/ClosureRelocationTest.php @@ -34,8 +34,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } diff --git a/tests/OpCache/IteratorFuncsRelocationTest.php b/tests/OpCache/IteratorFuncsRelocationTest.php index b06f90c9..f85a2c21 100644 --- a/tests/OpCache/IteratorFuncsRelocationTest.php +++ b/tests/OpCache/IteratorFuncsRelocationTest.php @@ -37,8 +37,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } diff --git a/tests/OpCache/PropertyHookRelocationTest.php b/tests/OpCache/PropertyHookRelocationTest.php index 02460b7c..8755cee0 100644 --- a/tests/OpCache/PropertyHookRelocationTest.php +++ b/tests/OpCache/PropertyHookRelocationTest.php @@ -34,8 +34,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } diff --git a/tests/OpCache/ReflectionOpcacheFileTest.php b/tests/OpCache/ReflectionOpcacheFileTest.php index 362da386..16a7ad15 100644 --- a/tests/OpCache/ReflectionOpcacheFileTest.php +++ b/tests/OpCache/ReflectionOpcacheFileTest.php @@ -26,8 +26,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } diff --git a/tests/OpCache/RefreshWorkflowTest.php b/tests/OpCache/RefreshWorkflowTest.php index 1653d175..0b760db9 100644 --- a/tests/OpCache/RefreshWorkflowTest.php +++ b/tests/OpCache/RefreshWorkflowTest.php @@ -32,8 +32,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } diff --git a/tests/OpCache/SerializerRoundTripTest.php b/tests/OpCache/SerializerRoundTripTest.php index ef48234e..91140a69 100644 --- a/tests/OpCache/SerializerRoundTripTest.php +++ b/tests/OpCache/SerializerRoundTripTest.php @@ -32,8 +32,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } diff --git a/tests/OpCache/TraitRelocationTest.php b/tests/OpCache/TraitRelocationTest.php index 171275c1..6d274907 100644 --- a/tests/OpCache/TraitRelocationTest.php +++ b/tests/OpCache/TraitRelocationTest.php @@ -34,8 +34,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } diff --git a/tests/OpCache/TypeListRelocationTest.php b/tests/OpCache/TypeListRelocationTest.php index 0f5aebe2..ecd5c938 100644 --- a/tests/OpCache/TypeListRelocationTest.php +++ b/tests/OpCache/TypeListRelocationTest.php @@ -33,8 +33,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } From a20e806fa3c2afe2b9aae939b11bbe09d28ac2fa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:35:23 +0000 Subject: [PATCH 21/28] feat(opcache): pin down the darwin opline addressing model with a tripwire Scope finding for issue #119 (macOS/arm64 relocator support): the per-opline absolute-address branches of zend_file_cache_(un)serialize_op_array (op1/op2.zv SERIALIZE_PTR and the jmp_addr switch) are compiled in only when ZEND_USE_ABS_CONST_ADDR / ZEND_USE_ABS_JMP_ADDR are 1, and zend_compile.h (PHP-8.4.19) defines both as 1 exactly when SIZEOF_SIZE_T == 4. Darwin x64 and arm64 are 64-bit builds and use the same relative addressing as linux - there is no darwin-specific opline walking to port. Implementing those branches would be dead code on every build the relocator supports, so they are deliberately NOT implemented; 32-bit builds (the only ones that use absolute addressing) stay refused by the PHP_INT_SIZE === 8 predicate. What lands instead: - OpcodeAddressingModelTest: proves the relative model on a real compiled payload - every IS_CONST operand in the file is a literal-table index and every JMP-family operand lands on an opline of its own op_array. The test runs (does not skip) on every supported build, darwin CI legs included, and FAILS loudly if a build ever produces absolute operands. - fixtures/addressing-probe.php: top-level code built around getenv() so SCCP cannot fold away the ?: and !== branches - IS_CONST operands and conditional jumps are guaranteed in the main op_array. - The opcodes comment in PayloadRelocator and docs/opcache-binary.md now state the invariant and its source precisely. Darwin execution of these tests happens on the PR's tests-macos CI legs (the relocator group already gates there since #118 removed the ZTS exclusion); no local darwin validation is possible from this environment. Acceptance evidence: - host: full default suite (520 tests, baseline skip counts), --group opcache --fail-on-skipped OK (48 tests, 338 assertions) - z-engine-php:debug84 and z-engine-php:debug84-zts containers: same opcache gate OK (48/338 each) - phpstan level max clean, php-cs-fixer clean Fixes #119 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/opcache-binary.md | 17 ++- src/OpCache/PayloadRelocator.php | 12 +- tests/OpCache/OpcodeAddressingModelTest.php | 147 ++++++++++++++++++++ tests/OpCache/fixtures/addressing-probe.php | 20 +++ 4 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 tests/OpCache/OpcodeAddressingModelTest.php create mode 100644 tests/OpCache/fixtures/addressing-probe.php diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index c121a365..d1471321 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -184,9 +184,20 @@ $report->appliedMethods; // what actually happened, per entry ## Scope and limits (v1) -- **Platform.** The relocator targets the bundled 64-bit non-Windows build; it - asserts `PHP_INT_SIZE === 8` and a `/` path separator and throws - `OpCacheException::unsupportedPayload` otherwise. **Windows opcache support +- **Platform.** The relocator targets 64-bit POSIX builds - linux and macOS + (x64 and arm64) alike; it asserts `PHP_INT_SIZE === 8` and a `/` path + separator and throws `OpCacheException::unsupportedPayload` otherwise. + Darwin needs no per-opline walking of its own + ([#119](https://github.com/lisachenko/z-engine/issues/119)): the + absolute-address opline branches of zend_file_cache.c + (`ZEND_USE_ABS_CONST_ADDR`/`ZEND_USE_ABS_JMP_ADDR`) are compiled in only + when `SIZEOF_SIZE_T == 4` (zend_compile.h), so every 64-bit build - darwin + included - stores IS_CONST operands as literal-table indexes and jumps as + opline-relative byte offsets, both position-independent and preserved + verbatim. `OpcodeAddressingModelTest` proves that on a real payload and + fails loudly if a build ever diverges; the 32-bit builds that do use + absolute addressing are refused by the `PHP_INT_SIZE` predicate. + **Windows opcache support is an intentional non-goal**, not pending work: the relocator (and `opcache.preload`-based features) keep rejecting Windows loudly, and the Windows half of the original platform ticket was retired when diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index 21b16cb1..ccb505a0 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -765,9 +765,15 @@ private function unserializeOpArray(object $opArray): void $this->unserializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); } } - // opcodes: only the array pointer is relocated; per-opline operands and - // handlers are literal indexes/relative jumps on this platform and are - // preserved verbatim (see class docblock) + // opcodes: only the array pointer is relocated. Per-opline operands are + // preserved verbatim because every 64-bit build uses relative addressing: + // ZEND_USE_ABS_CONST_ADDR / ZEND_USE_ABS_JMP_ADDR are 1 only when + // SIZEOF_SIZE_T == 4 (zend_compile.h), so in these payloads IS_CONST + // operands are literal-table indexes and jump operands are opline-relative + // byte offsets - position-independent on linux and darwin alike. The + // absolute-address per-opline walk of zend_file_cache.c is a 32-bit-only + // shape, excluded with the 32-bit refusal (issue #119; + // OpcodeAddressingModelTest is the tripwire should a build ever diverge). $this->unPtr($opArray, 'opcodes'); $this->unPtr($opArray, 'scope'); $this->unserializeArgInfo($opArray); diff --git a/tests/OpCache/OpcodeAddressingModelTest.php b/tests/OpCache/OpcodeAddressingModelTest.php new file mode 100644 index 00000000..d245f913 --- /dev/null +++ b/tests/OpCache/OpcodeAddressingModelTest.php @@ -0,0 +1,147 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Generated\zend_op; +use ZEngine\Generated\zend_persistent_script; +use ZEngine\System\OpCode; +use ZEngine\Type\OpLine; + +/** + * Tripwire for the relocator's opline-opaque contract (issue #119). + * + * PayloadRelocator never rewrites per-opline operands because every 64-bit + * build compiles with relative addressing (ZEND_USE_ABS_CONST_ADDR / + * ZEND_USE_ABS_JMP_ADDR are 1 only when SIZEOF_SIZE_T == 4, zend_compile.h) - + * darwin x64/arm64 included, they are not special. In such payloads IS_CONST + * operands are literal-table indexes and jump operands are opline-relative + * byte offsets, both position-independent. This test proves that on a real + * compiled payload and FAILS - it does not skip - if a supported build ever + * produced absolute (buffer-offset) operands, which would need the + * per-opline walk of zend_file_cache.c that only 32-bit builds compile in. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class OpcodeAddressingModelTest extends TestCase +{ + use FileCacheFixture; + + /** Opcodes whose op1/op2 is a jump target in zend_file_cache.c's ABS-addr switch (CATCH/FE_FETCH/SWITCH use extended_value and are not probed) */ + private const array OP1_JUMPS = [OpCode::JMP]; + private const array OP2_JUMPS = [ + OpCode::JMPZ, OpCode::JMPNZ, OpCode::JMPZ_EX, OpCode::JMPNZ_EX, + OpCode::JMP_SET, OpCode::COALESCE, OpCode::FE_RESET_R, OpCode::FE_RESET_RW, + OpCode::ASSERT_CHECK, OpCode::JMP_NULL, OpCode::BIND_INIT_STATIC_OR_JMP, + OpCode::JMP_FRAMELESS, + ]; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testPayloadOplinesUseRelativeAddressing(): void + { + $binPath = self::compileFixture(self::probeFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + $base = Core::addressOf(Core::addr($buffer)); + $script = Core::pointerAtAddress(zend_persistent_script::class, $base + $meta->scriptOffset()); + $mainOpArray = $script->script->main_op_array; + $lastLiteral = $mainOpArray->last_literal; + $lastOpline = $mainOpArray->last; + $opSize = Core::sizeOfType(zend_op::class); + $opcodes = $mainOpArray->opcodes; + self::assertNotNull($opcodes); + $opcodesAddr = Core::addressOf($opcodes); + $opcodesBytes = $lastOpline * $opSize; + + $constOperands = 0; + $jumpOperands = 0; + for ($i = 0; $i < $lastOpline; $i++) { + $opline = Core::pointerAtAddress(zend_op::class, $opcodesAddr + $i * $opSize); + foreach ([ + 'op1' => [$opline->op1_type, $opline->op1->constant], + 'op2' => [$opline->op2_type, $opline->op2->constant], + ] as $node => [$type, $index]) { + if ($type !== OpLine::IS_CONST) { + continue; + } + $constOperands++; + self::assertLessThan( + $lastLiteral, + $index, + "Opline #{$i} {$node} IS_CONST operand is not a literal-table index - " + . 'this build stores absolute constant addresses (ZEND_USE_ABS_CONST_ADDR), ' + . 'which the relocator does not walk (issue #119)', + ); + } + $rawOffset = null; + $jumpNode = ''; + if (\in_array($opline->opcode, self::OP1_JUMPS, true)) { + [$rawOffset, $jumpNode] = [$opline->op1->jmp_offset, 'op1']; + } elseif (\in_array($opline->opcode, self::OP2_JUMPS, true)) { + [$rawOffset, $jumpNode] = [$opline->op2->jmp_offset, 'op2']; + } + if ($rawOffset !== null) { + $jumpOperands++; + $unpacked = unpack('l', pack('V', $rawOffset)); + self::assertIsArray($unpacked); + $signedOffset = $unpacked[1]; + self::assertIsInt($signedOffset); + $targetPosition = $i * $opSize + $signedOffset; + self::assertTrue( + $targetPosition >= 0 && $targetPosition < $opcodesBytes && $targetPosition % $opSize === 0, + "Opline #{$i} {$jumpNode} jump does not land on an opline of this op_array - " + . 'this build stores absolute jump addresses (ZEND_USE_ABS_JMP_ADDR), ' + . 'which the relocator does not walk (issue #119)', + ); + } + } + + self::assertGreaterThan(0, $constOperands, 'The probe fixture must yield at least one IS_CONST operand'); + self::assertGreaterThan(0, $jumpOperands, 'The probe fixture must yield at least one conditional jump'); + + // And the invariant holds through the writer: untouched round trip stays exact + self::assertSame($payload, $relocator->derelocate()); + } + + private static function probeFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/addressing-probe.php'); + self::assertIsString($path); + + return $path; + } +} diff --git a/tests/OpCache/fixtures/addressing-probe.php b/tests/OpCache/fixtures/addressing-probe.php new file mode 100644 index 00000000..00e420db --- /dev/null +++ b/tests/OpCache/fixtures/addressing-probe.php @@ -0,0 +1,20 @@ + Date: Wed, 19 Aug 2026 22:19:23 +0000 Subject: [PATCH 22/28] feat(opcache): persist-from-graph serializer for graph-growing mutations The from-scratch writer sketched in issue #117 as ScriptSerializer: a two-pass port of zend_persist_calc -> zend_persist (ext/opcache/zend_persist.c, PHP-8.4.19) fused with the offset-encoding stage of zend_file_cache_serialize. Pass 1 walks the (possibly mutated) live graph from the zend_persistent_script, deduplicating every reachable allocation unit through an xlat table (the zend_shared_alloc_*_xlat_entry port) and summing ZEND_MM_ALIGNED sizes; pass 2 emits a fresh contiguous region - units copied byte-verbatim, every pointer field rewritten, late references (scopes, prototypes, hook prop_info back-references, magic-method slots, IS_INDIRECT interior pointers) resolved against the finished xlat like zend_persist.c's late lookups. The emitted region is a valid relocated image whose on-disk offset encoding is delegated to the existing PayloadRelocator serialize stage, so the offset format has exactly one implementation. Walkers cover the full 8.4 payload surface: op_arrays (static vars, literals, opcodes, arg_info incl. the arg_info[-1] return slot, vars, live ranges, try/catch, attributes, dynamic_func_defs), classes (unlinked and linked-parent, constants, properties incl. hooks, interface/trait names, aliases, precedences, iterator/arrayaccess funcs), zvals/arrays/constant ASTs, warnings and early bindings. script->size is re-stamped (it is the loader's IS_SERIALIZED bound); zend_hash_persist's sparse-table compaction is deliberately skipped (optimization only) and every string is region-copied exactly like a file_cache_only child (nothing is accel-interned there), stamping zend_set_str_gc_flags' interned bits on sources that lack them. The API seam: ReflectionOpcacheFile::addFunctionFrom()/addMethodFrom() graft op_arrays from DONOR cache binaries (compiled by a real opcache child, so their oplines are already file-form - handler-table indexes and literal-index operands are not derivable in-process without unexported engine helpers), regrowing the target hashtable outside the buffer with a faithful re-implementation of the persisted-table insert (hash slots ahead of arData, bucket-index chains, HT_SIZE_TO_MASK = -(2*size); persisted data blocks must never be touched by zend_hash_add). BinaryCacheFile::save() routes grown graphs through the serializer automatically and keeps the byte-exact derelocate() path for in-place edits. Whole added classes and in-process compiled op_arrays remain out of scope and are refused loudly. Also fixes a latent relocator bug found by the serializer's byte checks: _ZSTR_HEADER_SIZE is XtOffsetOf(zend_string, val) = sizeof - 8, not sizeof - 1, which made emitInterned over-copy 7 bytes per emission. Acceptance evidence (GraphGrowingSerializerTest): - issue #117 acceptance: a brand-new function AND a new method grafted into the cached answer.php execute from the file cache in fresh workers ('added-fn', 'added-method-ok'), alongside the original entries; the grown binary passes checksum and round-trips byte-identically through the relocator - rebuild coverage: all seven fixture payloads (attributes/statics, type lists, traits, closures, property hooks, iterators, jump/const probe) re-emitted from scratch execute from the cache and round-trip byte-identical - refusal paths: unknown donor entries and duplicate keys throw dedicated OpCacheException factories - full default suite (530 tests, baseline skips), --group opcache --fail-on-skipped OK (58 tests, 437 assertions) on host, debug84 and debug84-zts containers; phpstan level max clean (the two new pointer-surgery zones carry the same scoped ignores as PayloadRelocator); php-cs-fixer clean Fixes #117 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/opcache-binary.md | 41 + phpstan.dist.neon | 29 +- src/OpCache/BinaryCacheFile.php | 17 +- src/OpCache/OpCacheException.php | 25 + src/OpCache/PayloadRelocator.php | 7 +- src/OpCache/ReflectionOpcacheFile.php | 235 ++++ src/OpCache/ScriptSerializer.php | 1142 ++++++++++++++++++ tests/OpCache/GraphGrowingSerializerTest.php | 187 +++ tests/OpCache/fixtures/graft-donor.php | 23 + 9 files changed, 1702 insertions(+), 4 deletions(-) create mode 100644 src/OpCache/ScriptSerializer.php create mode 100644 tests/OpCache/GraphGrowingSerializerTest.php create mode 100644 tests/OpCache/fixtures/graft-donor.php diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index d1471321..e272959d 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -81,6 +81,43 @@ string literal, a new constant value — are written correctly, not just in-plac byte pokes. `refresh()` is `save()` plus `opcache_invalidate()` on the source script, so the next include picks up the patched binary. +## Growing the graph: added functions and methods + +In-place edits go out through `PayloadRelocator::derelocate()` — the exact +inverse of the read-time relocation. Mutations that outgrow the original +buffer take a different writer +([#117](https://github.com/lisachenko/z-engine/issues/117)): +`ScriptSerializer`, a two-pass port of `zend_persist_calc` → `zend_persist` +(pass 1 walks the graph, deduplicating every reachable allocation unit +through an xlat table and summing aligned sizes; pass 2 emits a fresh +contiguous region and rewrites every pointer), which then delegates the +on-disk offset encoding to the same `PayloadRelocator` serialize stage — one +implementation for the offset format. `save()` picks the writer +automatically: it re-emits from scratch once the reflection view reports the +graph as grown, and keeps the byte-exact derelocate path otherwise. + +New code enters the image as **grafts from donor binaries**: + +```php +$file = BinaryCacheFile::read($binPath, $scriptPath); +$donor = BinaryCacheFile::compile($donorScript, $donorCacheDir); + +$view = $file->getReflection(); +$view->addFunctionFrom($donor->getReflection(), 'my_new_function'); +$view->addMethodFrom($donor->getReflection(), 'DonorClass', 'newMethod', 'CachedClass'); +$file->save(); // a fresh worker now executes the added function and method +``` + +Donors are compiled by a real opcache child, so their op_arrays are already +in file form (opline handlers are handler-table indexes, IS_CONST operands +are literal-table indexes — neither is derivable in-process without engine +helpers that are not exported); the serializer copies those units verbatim. +Grafting regrows the target hashtable outside the buffer — persisted tables +must never be touched by `zend_hash_add`, their data block is not an +emalloc'd allocation — and the donor image stays referenced (and, for +methods, mutated: the op_array's scope is re-pointed at the adopting class) +until `save()` re-emits everything into one fresh region. + ## Refresh and shared memory Under `opcache.file_cache_only=1` there is no shared-memory copy, so writing the @@ -220,6 +257,10 @@ $report->appliedMethods; // what actually happened, per entry attributes (including constant-expression arguments), static variables, compile warnings, try/catch and enums are supported and round-trip byte-for-byte. +- **Graph growth.** Added functions and methods are supported through donor + grafts and the from-scratch `ScriptSerializer` (see "Growing the graph" + above, issue #117); whole added classes and freshly in-process compiled + op_arrays (no file-form oplines) remain out of scope and are refused loudly. - **Deferred.** Loading patched binaries into shared memory (ZCSG, [#121](https://github.com/lisachenko/z-engine/issues/121)). Applying a patched image to already-loaded functions and classes landed as diff --git a/phpstan.dist.neon b/phpstan.dist.neon index a14488c3..36942d97 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -86,15 +86,42 @@ parameters: - identifier: argument.type path: src/OpCache/PayloadRelocator.php + # ScriptSerializer is the second audited pointer-surgery file (the + # persist-from-graph writer, issue #117): the same CData field walking + # as PayloadRelocator, covered by the rebuild/graft execute-from-cache + # tests and the relocator round-trip identity checks. + - + identifier: property.nonObject + path: src/OpCache/ScriptSerializer.php + - + identifier: binaryOp.invalid + path: src/OpCache/ScriptSerializer.php + - + identifier: cast.int + path: src/OpCache/ScriptSerializer.php + - + identifier: argument.type + path: src/OpCache/ScriptSerializer.php # ReflectionOpcacheFile is the CData facade over the relocated script: # it reads embedded engine structs (script.filename, function/class - # tables) that resolve to `mixed` after the first CData hop. + # tables) that resolve to `mixed` after the first CData hop; since + # issue #117 it also carries the graft plumbing (image hashtable + # regrowth), which does the same CData arithmetic. - identifier: property.nonObject path: src/OpCache/ReflectionOpcacheFile.php - identifier: argument.type path: src/OpCache/ReflectionOpcacheFile.php + - + identifier: binaryOp.invalid + path: src/OpCache/ReflectionOpcacheFile.php + - + identifier: cast.int + path: src/OpCache/ReflectionOpcacheFile.php + - + identifier: assignOp.invalid + path: src/OpCache/ReflectionOpcacheFile.php # The dimension tests exist to prove that a plain `count($object)` reaches the engine's # count_elements handler on a class that never declared the count itself. Rewriting them # as assertCount() would measure PHPUnit's Count constraint instead of the language diff --git a/src/OpCache/BinaryCacheFile.php b/src/OpCache/BinaryCacheFile.php index 750da130..8341ce7a 100644 --- a/src/OpCache/BinaryCacheFile.php +++ b/src/OpCache/BinaryCacheFile.php @@ -252,7 +252,22 @@ public function getReflection(): ReflectionOpcacheFile public function save(?string $binPath = null, ?int $timestamp = null, ?int $directoryPermissions = 0o755): void { $target = $binPath ?? $this->binPath; - if ($this->relocator !== null) { + if ($this->view !== null && $this->view->isGraphGrown()) { + // A mutation outgrew the original buffer (added function/method, + // regrown hashtable): re-emit the whole graph from scratch through + // the two-pass persist serializer (issue #117). In-place edits keep + // taking the exact-inverse derelocate() path below. + $serializer = new ScriptSerializer($this->view->getRawScript()); + $this->payload = $serializer->serialize(); + $this->metaInfo = CacheMetaInfo::forPayload( + systemId: $this->metaInfo->systemId(), + memSize: $serializer->memSize(), + strSize: strlen($this->payload) - $serializer->memSize(), + scriptOffset: $serializer->scriptOffset(), + timestamp: $this->metaInfo->timestamp(), + checksum: 0, // recomputed below + ); + } elseif ($this->relocator !== null) { // Re-serialize the (possibly mutated) live image, updating the // interned-string section size in the header $this->payload = $this->relocator->derelocate(); diff --git a/src/OpCache/OpCacheException.php b/src/OpCache/OpCacheException.php index c2daf78e..f744e6fb 100644 --- a/src/OpCache/OpCacheException.php +++ b/src/OpCache/OpCacheException.php @@ -136,4 +136,29 @@ public static function payloadNotRelocated(): self { return new self('The payload is not relocated: call script() before accessing structures'); } + + /** + * The graph serializer met a pointer whose target no persisted unit covers - + * the graph references memory the serialization pass never absorbed + */ + public static function unresolvedGraphReference(string $what): self + { + return new self("Graph serialization failed, {$what}: the referenced structure was not persisted"); + } + + /** + * A graft donor does not contain the requested function/class/method + */ + public static function graftEntryNotFound(string $kind, string $name): self + { + return new self("Cannot graft {$kind} '{$name}': the donor cache image does not contain it"); + } + + /** + * The graft target hashtable already holds an entry under this key + */ + public static function duplicateHashTableKey(string $key): self + { + return new self("Cannot graft '{$key}': the target table already holds an entry under that key"); + } } diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index ccb505a0..172a5b4f 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -120,8 +120,11 @@ public function __construct(private readonly object $buffer, private readonly Ca $this->base = Core::addressOf(Core::addr($buffer)); $this->size = $metaInfo->memSize(); $this->strSectionBase = $this->base + $this->size; - // _ZSTR_HEADER_SIZE = sizeof(zend_string) - sizeof(char) (the flexible val[1] member) - $this->zendStringHeaderSize = Core::sizeOfType(zend_string::class) - 1; + // _ZSTR_HEADER_SIZE = XtOffsetOf(zend_string, val): the flexible val[1] + // member starts at the last 8-byte slot of the (padded) struct, so the + // header is sizeof - 8, NOT sizeof - 1 (which over-copied 7 bytes per + // interned emission and diverged from _ZSTR_STRUCT_SIZE) + $this->zendStringHeaderSize = Core::sizeOfType(zend_string::class) - PHP_INT_SIZE; } /** diff --git a/src/OpCache/ReflectionOpcacheFile.php b/src/OpCache/ReflectionOpcacheFile.php index 8199698d..510988d7 100644 --- a/src/OpCache/ReflectionOpcacheFile.php +++ b/src/OpCache/ReflectionOpcacheFile.php @@ -16,6 +16,7 @@ use FFI; use FFI\CData; use ZEngine\Core; +use ZEngine\Generated\Bucket; use ZEngine\Reflection\ReflectionClass; use ZEngine\Reflection\ReflectionFunction; use ZEngine\Type\HashTable; @@ -36,6 +37,12 @@ */ final class ReflectionOpcacheFile { + /** Set once a mutation outgrew the original buffer; routes save() to ScriptSerializer */ + private bool $graphGrown = false; + + /** @var list donor images whose units this image now references */ + private array $donors = []; + /** * @param \FFI\CData $script Relocated zend_persistent_script inside the image buffer * @param object|null $imageOwner Owner of the relocated buffer (the PayloadRelocator): @@ -50,6 +57,34 @@ public function __construct( private readonly ?object $imageOwner = null, ) {} + /** + * The relocated zend_persistent_script this handle wraps + * + * @internal core-layer escape hatch for BinaryCacheFile/ScriptSerializer + * @return \FFI\CData + */ + public function getRawScript(): object + { + return $this->script; + } + + /** Whether a mutation grew the graph beyond the original buffer */ + public function isGraphGrown(): bool + { + return $this->graphGrown; + } + + /** + * The donor images this image references after grafting; their buffers must + * stay materialized until save() re-emits the graph into one fresh region + * + * @return list + */ + public function donorImages(): array + { + return $this->donors; + } + /** * The cached script's source path (parity with ReflectionClass::getFileName()) */ @@ -84,6 +119,60 @@ public function classTable(): HashTable return HashTable::fromCData(FFI::addr($this->script->script->class_table)); } + /** + * Grafts a function from another cache image into this script (issue #117). + * + * The donor must come from a cache binary compiled by a real opcache child, + * so its op_array is already in file form (handler indexes, literal-index + * operands) - the graph serializer copies such units verbatim. The donor + * image is referenced, not copied, until {@see BinaryCacheFile::save()} + * re-emits the whole graph through {@see ScriptSerializer}. + */ + public function addFunctionFrom(self $donor, string $functionName): void + { + $key = strtolower($functionName); + $entry = self::findKeyedEntry($donor->script->script->function_table, $key); + if ($entry === null) { + throw OpCacheException::graftEntryNotFound('function', $functionName); + } + [$keyAddress, $functionAddress] = $entry; + $this->insertPtrEntry($this->script->script->function_table, $keyAddress, $functionAddress); + $this->donors[] = $donor; + $this->graphGrown = true; + } + + /** + * Grafts a method from a donor image's class into a class of this script. + * + * The donor method's scope is re-pointed at the target class (the donor + * image is mutated - it is tied to this image from here on), exactly what + * zend_persist expects of a method hanging off that class's function table. + */ + public function addMethodFrom(self $donor, string $donorClassName, string $methodName, string $targetClassName): void + { + $donorClass = $donor->findClassByName($donorClassName); + if ($donorClass === null) { + throw OpCacheException::graftEntryNotFound('class', $donorClassName); + } + $targetClass = $this->findClassByName($targetClassName); + if ($targetClass === null) { + throw OpCacheException::graftEntryNotFound('class', $targetClassName); + } + $entry = self::findKeyedEntry($donorClass->function_table, strtolower($methodName)); + if ($entry === null) { + throw OpCacheException::graftEntryNotFound('method', "{$donorClassName}::{$methodName}"); + } + [$keyAddress, $methodAddress] = $entry; + + // Re-point the method's scope at the adopting class + $method = Core::pointerAtAddress('zend_op_array *', $methodAddress); + Core::cast('uintptr_t *', FFI::addr($method->scope))[0] = Core::addressOf($targetClass); + + $this->insertPtrEntry($targetClass->function_table, $keyAddress, $methodAddress); + $this->donors[] = $donor; + $this->graphGrown = true; + } + /** * Every user function compiled into the script, keyed by lowercase name * (parity with ReflectionExtension::getFunctions()) @@ -121,4 +210,150 @@ public function getClasses(): array return $classes; } + + // --- graft plumbing (issue #117) ---------------------------------------- + + /** + * Finds a class entry by its own name, case-insensitively; class-table + * bucket keys can be opcache rtd keys, so match on ce->name instead. + * + * @return \FFI\CData|null a zend_class_entry* into the image + */ + private function findClassByName(string $className): ?object + { + $ht = $this->script->script->class_table; + $bucketSize = Core::sizeOfType(Bucket::class); + $dataAddress = Core::addressOf($ht->arData); + for ($i = 0; $i < $ht->nNumUsed; $i++) { + $bucket = Core::pointerAtAddress('Bucket *', $dataAddress + $i * $bucketSize); + if ($bucket->val->u1->v->type === 0) { + continue; + } + $classEntry = Core::pointerAtAddress( + 'zend_class_entry *', + (int) Core::cast('uintptr_t *', FFI::addr($bucket->val->value))[0], + ); + $name = StringEntry::fromCData($classEntry->name)->getStringValue(); + if (strcasecmp($name, $className) === 0) { + return $classEntry; + } + } + + return null; + } + + /** + * Finds a bucket by exact key in a keyed image table. + * + * @param \FFI\CData $ht HashTable view + * @return array{int, int}|null [key zend_string address, value pointer address] + */ + private static function findKeyedEntry(object $ht, string $key): ?array + { + if (($ht->u->flags & Core::engineConstant('HASH_FLAG_UNINITIALIZED')) !== 0) { + return null; + } + $bucketSize = Core::sizeOfType(Bucket::class); + $dataAddress = Core::addressOf($ht->arData); + for ($i = 0; $i < $ht->nNumUsed; $i++) { + $bucket = Core::pointerAtAddress('Bucket *', $dataAddress + $i * $bucketSize); + if ($bucket->val->u1->v->type === 0 || $bucket->key === null) { + continue; + } + if (StringEntry::fromCData($bucket->key)->getStringValue() === $key) { + return [ + Core::addressOf($bucket->key), + (int) Core::cast('uintptr_t *', FFI::addr($bucket->val->value))[0], + ]; + } + } + + return null; + } + + /** + * Inserts an IS_PTR entry into an image hashtable, regrowing its data block + * outside the buffer (issue #117). Image tables were laid out by + * zend_hash_persist - their data is NOT an emalloc'd block, so the engine's + * zend_hash_add must never touch them; this reimplements the insert the way + * the persisted format expects it (hash slots ahead of arData, bucket-index + * chains via Z_NEXT, HT_SIZE_TO_MASK = -(2 * nTableSize)). + * + * @param \FFI\CData $ht HashTable view (embedded in the image) + */ + private function insertPtrEntry(object $ht, int $keyAddress, int $valueAddress): void + { + $flags = $ht->u->flags; + if (($flags & Core::engineConstant('HASH_FLAG_PACKED')) !== 0) { + throw OpCacheException::unsupportedPayload('grafting into a packed hashtable'); + } + $key = Core::pointerAtAddress('zend_string *', $keyAddress); + $hash = $key->h; + if ($hash === 0) { + throw OpCacheException::unsupportedPayload('graft key string carries no precomputed hash'); + } + + $bucketSize = Core::sizeOfType(Bucket::class); + $uninitialized = ($flags & Core::engineConstant('HASH_FLAG_UNINITIALIZED')) !== 0; + $used = $uninitialized ? 0 : $ht->nNumUsed; + $tableSize = $uninitialized ? 8 : $ht->nTableSize; + $oldData = $uninitialized ? 0 : Core::addressOf($ht->arData); + + if (!$uninitialized && self::findKeyedEntry($ht, StringEntry::fromCData($key)->getStringValue()) !== null) { + throw OpCacheException::duplicateHashTableKey(StringEntry::fromCData($key)->getStringValue()); + } + + $newUsed = $used + 1; + while ($newUsed > $tableSize) { + $tableSize <<= 1; + } + // HT_SIZE_TO_MASK(nTableSize) = (uint32)(-(nTableSize + nTableSize)) + $newMask = (0x100000000 - 2 * $tableSize) & 0xFFFFFFFF; + $hashBytes = 2 * $tableSize * 4; + $capacity = $hashBytes + $tableSize * $bucketSize; + $block = Core::new("char[{$capacity}]", false); + $blockBase = Core::addressOf(Core::addr($block)); + $newData = $blockBase + $hashBytes; + + if ($used > 0) { + FFI::memcpy( + Core::cast('char *', Core::pointerAtAddress('void *', $newData)), + Core::cast('char *', Core::pointerAtAddress('void *', $oldData)), + $used * $bucketSize, + ); + } + + // The appended bucket: an IS_PTR zval, hash and key + $bucket = Core::pointerAtAddress('Bucket *', $newData + $used * $bucketSize); + $bucket->val->u1->type_info = Core::engineConstant('IS_PTR'); + Core::cast('uintptr_t *', FFI::addr($bucket->val->value))[0] = $valueAddress; + $bucket->h = $hash; + $bucket->key = $key; + + // HT_HASH_RESET + full rehash (bucket-index chains, like zend_hash_persist) + for ($i = 0; $i < 2 * $tableSize; $i++) { + Core::cast('uint32_t *', Core::pointerAtAddress('void *', $blockBase + $i * 4))[0] = 0xFFFFFFFF; // HT_INVALID_IDX + } + for ($idx = 0; $idx < $newUsed; $idx++) { + $entry = Core::pointerAtAddress('Bucket *', $newData + $idx * $bucketSize); + if ($entry->val->u1->v->type === 0) { + continue; + } + $nIndex = ($entry->h | $newMask) & 0xFFFFFFFF; + $slot = $nIndex - 0x100000000; // (int32_t)nIndex, always negative + $slotAddr = $newData + $slot * 4; + $entry->val->u2->next = (int) Core::cast('uint32_t *', Core::pointerAtAddress('void *', $slotAddr))[0]; + Core::cast('uint32_t *', Core::pointerAtAddress('void *', $slotAddr))[0] = $idx; + } + + $ht->arData = Core::pointerAtAddress('Bucket *', $newData); + $ht->nNumUsed = $newUsed; + $ht->nNumOfElements = ($uninitialized ? 0 : $ht->nNumOfElements) + 1; + $ht->nTableSize = $tableSize; + $ht->nTableMask = $newMask; + $ht->nInternalPointer = 0; + if ($uninitialized) { + $ht->u->flags = $flags & ~Core::engineConstant('HASH_FLAG_UNINITIALIZED'); + } + } } diff --git a/src/OpCache/ScriptSerializer.php b/src/OpCache/ScriptSerializer.php new file mode 100644 index 00000000..fa2e4a7c --- /dev/null +++ b/src/OpCache/ScriptSerializer.php @@ -0,0 +1,1142 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use FFI; +use FFI\CData; +use ZEngine\Core; +use ZEngine\Generated\Bucket; +use ZEngine\Generated\zend_arg_info; +use ZEngine\Generated\zend_ast; +use ZEngine\Generated\zend_ast_list; +use ZEngine\Generated\zend_ast_ref; +use ZEngine\Generated\zend_attribute; +use ZEngine\Generated\zend_attribute_arg; +use ZEngine\Generated\zend_class_arrayaccess_funcs; +use ZEngine\Generated\zend_class_constant; +use ZEngine\Generated\zend_class_entry; +use ZEngine\Generated\zend_class_iterator_funcs; +use ZEngine\Generated\zend_class_name; +use ZEngine\Generated\zend_early_binding; +use ZEngine\Generated\zend_error_info; +use ZEngine\Generated\zend_live_range; +use ZEngine\Generated\zend_op_array; +use ZEngine\Generated\zend_persistent_script; +use ZEngine\Generated\zend_property_info; +use ZEngine\Generated\zend_string; +use ZEngine\Generated\zend_trait_alias; +use ZEngine\Generated\zend_trait_precedence; +use ZEngine\Generated\zend_try_catch_element; +use ZEngine\Generated\zend_type; +use ZEngine\Generated\zend_type_list; +use ZEngine\Generated\zval; + +/** + * The from-scratch persist-from-graph serializer (issue #117): a two-pass port + * of zend_persist_calc -> zend_persist fused with the offset-encoding stage of + * zend_file_cache_serialize, for graphs that grew beyond the original buffer + * (added functions/methods, regrown hashtables, replaced sub-arrays). + * + * Pass 1 walks the (possibly mutated) live graph rooted at the + * zend_persistent_script, deduplicating every reachable allocation unit through + * an xlat table (the port of zend_shared_alloc_get/register_xlat_entry) and + * computing the total ZEND_MM_ALIGNED size, exactly like zend_persist_calc. + * Pass 2 emits a fresh contiguous buffer: every unit is copied byte-verbatim to + * its assigned offset and every pointer field is rewritten to the copy's new + * address - producing a valid RELOCATED image, whose conversion to the on-disk + * offset form is then delegated to the proven {@see PayloadRelocator} + * (serialize = derelocate), so the offset/interning encoding has exactly one + * implementation. + * + * Two deliberate simplifications against zend_persist.c, both valid per the + * file-cache format: + * + * - every zend_string is copied into the mem region (the compile child in + * file_cache_only mode does the same: nothing is accel-interned there, so + * zend_accel_store_interned_string region-copies every string). The emitted + * image therefore carries an empty interned-string section, and any string + * whose source lacks the interned GC bits gets them stamped on the copy - + * the port of zend_set_str_gc_flags' file_cache_only branch; + * - sparse hashtables are copied as-is instead of compacted (zend_hash_persist + * compacts as an optimization only; mask/index invariants are preserved + * either way). + * + * Inputs must be persisted images: the walkers copy payload bytes verbatim, so + * every op_array reachable from the graph must already be in file form (opline + * handlers as table indexes, IS_CONST operands as literal indexes) - which is + * true for anything that came out of a cache binary, including grafts pulled + * from a donor binary compiled by a real opcache child. Freshly in-process + * compiled op_arrays are NOT accepted implicitly; grafting goes through + * {@see ReflectionOpcacheFile::addFunctionFrom()} / addMethodFrom(), which only + * take donors from other cache binaries. + * + * @internal core-layer machinery, constructed by BinaryCacheFile::save() + */ +final class ScriptSerializer +{ + private const int IS_STRING = 6; + private const int IS_ARRAY = 7; + private const int IS_CONSTANT_AST = 11; + private const int IS_INDIRECT = 12; + + private const int ZEND_AST_ZVAL = 64; + private const int ZEND_AST_CONSTANT = 65; + private const int ZEND_AST_IS_LIST_SHIFT = 7; + private const int ZEND_AST_CHILDREN_SHIFT = 8; + + /** zend_type bit layout (zend_types.h) - list/name discriminators */ + private const int TYPE_LIST_BIT = 4194304; // _ZEND_TYPE_LIST_BIT + private const int TYPE_NAME_BIT = 16777216; // _ZEND_TYPE_NAME_BIT + + /** ZEND_PROPERTY_HOOK_COUNT (zend_property_hooks.h) - get + set slots */ + private const int PROPERTY_HOOK_COUNT = 2; + + private const MAGIC_METHOD_FIELDS = [ + 'constructor', 'destructor', 'clone', '__get', '__set', '__call', + '__serialize', '__unserialize', '__isset', '__unset', '__tostring', + '__callstatic', '__debugInfo', + ]; + private const ITERATOR_FUNC_FIELDS = ['zf_new_iterator', 'zf_rewind', 'zf_valid', 'zf_key', 'zf_current', 'zf_next']; + private const ARRAYACCESS_FUNC_FIELDS = ['zf_offsetget', 'zf_offsetexists', 'zf_offsetset', 'zf_offsetunset']; + + /** 1 = measure (zend_persist_calc), 2 = emit (zend_persist + file-cache encode) */ + private int $phase = 1; + + /** @var array source unit address => offset in the new region (the xlat table) */ + private array $xlat = []; + /** @var list sorted source unit start addresses (interior-pointer resolution) */ + private array $unitStarts = []; + /** @var array source unit start => byte size */ + private array $unitSizes = []; + /** @var array source unit address => copied guard (pass 2) */ + private array $copied = []; + /** + * Pointer fields whose target unit may not be translated yet at emit time + * (prototypes, scopes, prop_info back-references, magic-method slots ...): + * resolved against the finished xlat after the walk, like the late + * zend_shared_alloc_get_xlat_entry lookups in zend_persist.c. + * + * @var list [slot address in the copy, source target, description] + */ + private array $deferred = []; + + private int $total = 0; + /** @var CData|null the emitted buffer (kept alive by the instance) */ + private ?CData $out = null; + private int $newBase = 0; + + private readonly int $zendStringHeaderSize; + + /** + * @param CData $script the relocated zend_persistent_script* of the live image + */ + public function __construct(private readonly object $script) + { + if (!PayloadRelocator::isSupported()) { + throw OpCacheException::unsupportedPayload('the graph serializer supports 64-bit POSIX builds only'); + } + // _ZSTR_HEADER_SIZE = XtOffsetOf(zend_string, val): the flexible val[1] + // member starts at the last 8-byte slot of the (padded) struct + $this->zendStringHeaderSize = Core::sizeOfType(zend_string::class) - PHP_INT_SIZE; + } + + /** + * Emits a fresh payload (mem region + empty string section) from the graph. + * The source image is never written to, so the live view stays valid and + * serialize() can be called again after further mutations. + */ + public function serialize(): string + { + $scriptAddress = Core::addressOf($this->script); + + $this->phase = 1; + $this->xlat = []; + $this->unitSizes = []; + $this->total = 0; + $this->persistScript($scriptAddress); + + $this->unitStarts = array_keys($this->unitSizes); + sort($this->unitStarts); + + $this->phase = 2; + $this->copied = []; + $this->deferred = []; + $this->out = Core::new("char[{$this->total}]", false); + $this->newBase = Core::addressOf(Core::addr($this->out)); + $this->persistScript($scriptAddress); + $this->resolveDeferred(); + + // The emitted region is a valid relocated image; the on-disk offset + // encoding is the relocator's serialize - byte-tested machinery + $meta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: $this->total, + strSize: 0, + scriptOffset: $this->xlat[$scriptAddress], + timestamp: 0, + checksum: 0, + ); + $relocator = new PayloadRelocator($this->out, $meta); + + return $relocator->derelocate(); + } + + /** Size of the emitted mem region; only meaningful after serialize() */ + public function memSize(): int + { + return $this->total; + } + + /** Offset of the zend_persistent_script inside the emitted region */ + public function scriptOffset(): int + { + return $this->xlat[Core::addressOf($this->script)] ?? 0; + } + + // --- unit / pointer primitives ------------------------------------------ + + /** + * Registers (pass 1) or copies (pass 2) one allocation unit. + * + * @return array{int, bool} [address of the copy (0 in pass 1), first visit?] + */ + private function unit(int $source, int $size): array + { + if ($this->phase === 1) { + if (isset($this->xlat[$source])) { + return [0, false]; + } + $this->xlat[$source] = $this->total; + $this->unitSizes[$source] = $size; + $this->total += Core::getAlignedSize($size); + + return [0, true]; + } + if (!isset($this->xlat[$source])) { + throw OpCacheException::unresolvedGraphReference(sprintf('unit 0x%x reached only in the emit pass', $source)); + } + $new = $this->newBase + $this->xlat[$source]; + if (isset($this->copied[$source])) { + return [$new, false]; + } + $this->copied[$source] = true; + FFI::memcpy( + Core::cast('char *', Core::pointerAtAddress('void *', $new)), + Core::cast('char *', Core::pointerAtAddress('void *', $source)), + $size, + ); + + return [$new, true]; + } + + /** Translates a source address to its copy, resolving interior pointers */ + private function mapAddress(int $source): int + { + if (isset($this->xlat[$source])) { + return $this->newBase + $this->xlat[$source]; + } + // Binary search for the unit containing the address + $low = 0; + $high = \count($this->unitStarts) - 1; + while ($low <= $high) { + $mid = ($low + $high) >> 1; + $start = $this->unitStarts[$mid]; + if ($source < $start) { + $high = $mid - 1; + continue; + } + if ($source < $start + $this->unitSizes[$start]) { + return $this->newBase + $this->xlat[$start] + ($source - $start); + } + $low = $mid + 1; + } + + throw OpCacheException::unresolvedGraphReference(sprintf('pointer to 0x%x targets no persisted unit', $source)); + } + + /** + * Reads a pointer field's stored value as an integer (0 for C NULL). + * + * @param \FFI\CData $owner + */ + private function ptrValue(object $owner, string $field): int + { + if ($owner->$field === null) { + return 0; + } + + return (int) Core::cast('uintptr_t *', FFI::addr($owner->$field))[0]; + } + + /** + * Writes a pointer field in the emit pass (no-op while measuring). The + * field always holds its non-null source value at this point. + * + * @param \FFI\CData $owner a view into the COPY + */ + private function put(object $owner, string $field, int $address): void + { + if ($this->phase !== 2) { + return; + } + $slot = Core::cast('uintptr_t *', FFI::addr($owner->$field)); + $slot[0] = $address; + } + + /** Raw pointer-slot write in the emit pass */ + private function putAt(int $slotAddress, int $value): void + { + if ($this->phase !== 2) { + return; + } + Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress))[0] = $value; + } + + /** Reads a raw pointer slot */ + private function slotValue(int $slotAddress): int + { + return (int) Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress))[0]; + } + + /** Defers a copy-slot rewrite until the xlat table is complete */ + private function deferAt(int $slotAddress, int $sourceTarget, string $what): void + { + if ($this->phase !== 2) { + return; + } + $this->deferred[] = [$slotAddress, $sourceTarget, $what]; + } + + /** + * @param \FFI\CData $owner a view into the COPY, field currently non-null + */ + private function defer(object $owner, string $field, int $sourceTarget, string $what): void + { + if ($this->phase !== 2) { + return; + } + $this->deferred[] = [Core::addressOf(FFI::addr($owner->$field)), $sourceTarget, $what]; + } + + private function resolveDeferred(): void + { + foreach ($this->deferred as [$slotAddress, $sourceTarget, $what]) { + $this->putAt($slotAddress, $this->mapAddress($sourceTarget)); + } + $this->deferred = []; + } + + // --- strings -------------------------------------------------------------- + + /** + * Region-copies one zend_string (zend_accel_store_interned_string for the + * file_cache_only case: nothing is accel-interned, everything is memdup'd + * and stamped with the interned GC bits via zend_set_str_gc_flags). + */ + private function persistString(int $source): int + { + $string = Core::pointerAtAddress('zend_string *', $source); + $size = $this->zendStringHeaderSize + $string->len + 1; + [$new, $first] = $this->unit($source, $size); + if ($first && $this->phase === 2) { + $copy = Core::pointerAtAddress('zend_string *', $new); + $typeInfo = $copy->gc->u->type_info; + if (($typeInfo & Core::engineConstant('IS_STR_INTERNED')) === 0) { + // zend_set_str_gc_flags, file_cache_only branch + $copy->gc->refcount = 2; + $copy->gc->u->type_info = Core::engineConstant('GC_STRING') + | Core::engineConstant('IS_STR_INTERNED') + | ($typeInfo & Core::engineConstant('IS_STR_VALID_UTF8')); + } + } + + return $new; + } + + // --- hashtables (zend_hash_persist) --------------------------------------- + + /** + * Persists the DATA block of a hashtable and walks its live entries; the + * HashTable struct itself lives in its owner (embedded) or in its own unit + * (zend_array). $entry receives [source zval address, copy zval address]. + * + * @param \FFI\CData $ht source HashTable view + * @param \FFI\CData $htCopy copy HashTable view (same as $ht while measuring) + */ + private function persistHashData(object $ht, object $htCopy, callable $entry): void + { + if (($ht->u->flags & Core::engineConstant('HASH_FLAG_UNINITIALIZED')) !== 0) { + return; // arData is written as 0 by the relocator's serialize stage + } + $dataAddress = $this->ptrValue($ht, 'arData'); + if ($dataAddress === 0) { + return; + } + $used = $ht->nNumUsed; + $packed = ($ht->u->flags & Core::engineConstant('HASH_FLAG_PACKED')) !== 0; + if ($packed) { + // Packed tables reserve HT_HASH_SIZE(HT_MIN_MASK) bytes before arData + $hashBytes = (0x100000000 - Core::engineConstant('HT_MIN_MASK')) * 4; + $entrySize = Core::sizeOfType(zval::class); + } else { + $hashBytes = (0x100000000 - $ht->nTableMask) * 4; + $entrySize = Core::sizeOfType(Bucket::class); + } + $dataStart = $dataAddress - $hashBytes; + $usedSize = $hashBytes + $used * $entrySize; + [$newStart, ] = $this->unit($dataStart, $usedSize); + $newData = $newStart + $hashBytes; + $this->put($htCopy, 'arData', $newData); + + for ($i = 0; $i < $used; $i++) { + $sourceEntry = $dataAddress + $i * $entrySize; + $copyEntry = $this->phase === 2 ? $newData + $i * $entrySize : $sourceEntry; + if ($packed) { + $zv = Core::pointerAtAddress('zval *', $sourceEntry); + if ($zv->u1->v->type !== 0) { + $entry($sourceEntry, $copyEntry); + } + continue; + } + $bucket = Core::pointerAtAddress('Bucket *', $sourceEntry); + if ($bucket->val->u1->v->type === 0) { + continue; // hole: bytes copied verbatim, nothing to walk + } + $keyAddress = $this->ptrValue($bucket, 'key'); + if ($keyAddress !== 0) { + $newKey = $this->persistString($keyAddress); + $bucketCopy = Core::pointerAtAddress('Bucket *', $copyEntry); + if ($this->phase === 2) { + $this->put($bucketCopy, 'key', $newKey); + } + } + $entry($sourceEntry, $copyEntry); + } + } + + /** A pointed-to zend_array (IS_ARRAY zval, static_variables, attributes) */ + private function persistArray(int $source, callable $entry): int + { + [$new, $first] = $this->unit($source, Core::sizeOfType('HashTable')); + if ($first) { + $ht = Core::pointerAtAddress('HashTable *', $source); + $htCopy = $this->phase === 2 ? Core::pointerAtAddress('HashTable *', $new) : $ht; + $this->persistHashData($ht, $htCopy, $entry); + } + + return $new; + } + + // --- zvals ------------------------------------------------------------------ + + private function persistZval(int $source, int $copy): void + { + $zv = Core::pointerAtAddress('zval *', $source); + $zvCopy = $this->phase === 2 ? Core::pointerAtAddress('zval *', $copy) : $zv; + switch ($zv->u1->v->type) { + case self::IS_STRING: + $this->put($zvCopy->value, 'str', $this->persistString($this->ptrValue($zv->value, 'str'))); + break; + case self::IS_ARRAY: + $new = $this->persistArray( + $this->ptrValue($zv->value, 'arr'), + fn(int $s, int $c) => $this->persistZval($s, $c), + ); + $this->put($zvCopy->value, 'arr', $new); + break; + case self::IS_CONSTANT_AST: + $this->put($zvCopy->value, 'ast', $this->persistAstRef($this->ptrValue($zv->value, 'ast'))); + break; + case self::IS_INDIRECT: + // Points INTO another unit (a property-table slot): interior fixup + $this->defer($zvCopy->value, 'zv', $this->ptrValue($zv->value, 'zv'), 'IS_INDIRECT zval'); + break; + } + } + + // --- constant ASTs (zend_persist_ast) ---------------------------------------- + + /** The zend_ast_ref unit carries the root node inline, children are units */ + private function persistAstRef(int $source): int + { + $rootSource = $source + Core::sizeOfType(zend_ast_ref::class); + $refSize = Core::sizeOfType(zend_ast_ref::class) + $this->astNodeSize($rootSource); + [$new, $first] = $this->unit($source, $refSize); + if ($first) { + $this->persistAstNodeBody($rootSource, $new === 0 ? 0 : $new + Core::sizeOfType(zend_ast_ref::class)); + } + + return $new; + } + + private function persistAstNode(int $source): int + { + [$new, $first] = $this->unit($source, $this->astNodeSize($source)); + if ($first) { + $this->persistAstNodeBody($source, $new); + } + + return $new; + } + + private function persistAstNodeBody(int $source, int $copy): void + { + $ast = Core::pointerAtAddress('zend_ast *', $source); + $kind = $ast->kind; + if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { + $valueOffset = Core::sizeOfType('zend_ast_zval') - Core::sizeOfType(zval::class); + $this->persistZval($source + $valueOffset, $copy + $valueOffset); + + return; + } + [$childBase, $count] = $this->astChildren($source, $kind); + for ($i = 0; $i < $count; $i++) { + $childSource = $this->slotValue($childBase + $i * PHP_INT_SIZE); + if ($childSource === 0) { + continue; + } + $new = $this->persistAstNode($childSource); + $this->putAt($copy + ($childBase - $source) + $i * PHP_INT_SIZE, $new); + } + } + + /** @return array{int, int} [child slot base address, child count] */ + private function astChildren(int $source, int $kind): array + { + if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { + $list = Core::pointerAtAddress(zend_ast_list::class, $source); + + return [$source + Core::sizeOfType(zend_ast_list::class) - PHP_INT_SIZE, $list->children]; + } + + return [$source + Core::sizeOfType(zend_ast::class) - PHP_INT_SIZE, $kind >> self::ZEND_AST_CHILDREN_SHIFT]; + } + + private function astNodeSize(int $source): int + { + $ast = Core::pointerAtAddress('zend_ast *', $source); + $kind = $ast->kind; + if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { + return Core::sizeOfType('zend_ast_zval'); + } + if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { + $list = Core::pointerAtAddress(zend_ast_list::class, $source); + + return Core::sizeOfType(zend_ast_list::class) - PHP_INT_SIZE + PHP_INT_SIZE * $list->children; + } + + return Core::sizeOfType(zend_ast::class) - PHP_INT_SIZE + PHP_INT_SIZE * ($kind >> self::ZEND_AST_CHILDREN_SHIFT); + } + + // --- attributes ---------------------------------------------------------------- + + private function persistAttributes(object $owner, object $ownerCopy, string $field): void + { + $source = $this->ptrValue($owner, $field); + if ($source === 0) { + return; + } + $new = $this->persistArray($source, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress('zval *', $zvalSource); + $attrSource = $this->ptrValue($zv->value, 'ptr'); + $attr = Core::pointerAtAddress('zend_attribute *', $attrSource); + $argSize = Core::sizeOfType(zend_attribute_arg::class); + // ZEND_ATTRIBUTE_SIZE(argc) + $size = Core::sizeOfType(zend_attribute::class) + $argSize * $attr->argc - $argSize; + [$new, $first] = $this->unit($attrSource, $size); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'ptr', $new); + } + if (!$first) { + return; + } + $attrCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_attribute *', $new) : $attr; + $this->put($attrCopy, 'name', $this->persistString($this->ptrValue($attr, 'name'))); + $this->put($attrCopy, 'lcname', $this->persistString($this->ptrValue($attr, 'lcname'))); + $argBase = Core::addressOf($attr->args); + for ($i = 0; $i < $attr->argc; $i++) { + $argSource = $argBase + $i * $argSize; + $argCopy = $this->phase === 2 ? Core::addressOf($attrCopy->args) + $i * $argSize : $argSource; + $arg = Core::pointerAtAddress('zend_attribute_arg *', $argSource); + $nameAddr = $this->ptrValue($arg, 'name'); + if ($nameAddr !== 0) { + $this->put(Core::pointerAtAddress('zend_attribute_arg *', $argCopy), 'name', $this->persistString($nameAddr)); + } + $valueOffset = $argSize - Core::sizeOfType(zval::class); + $this->persistZval($argSource + $valueOffset, $argCopy + $valueOffset); + } + }); + $this->put($ownerCopy, $field, $new); + } + + // --- types ------------------------------------------------------------------------ + + /** + * @param \FFI\CData $type source zend_type view (embedded) + * @param \FFI\CData $typeCopy copy zend_type view + */ + private function persistType(object $type, object $typeCopy): void + { + $typeMask = $type->type_mask; + if (($typeMask & self::TYPE_LIST_BIT) !== 0) { + $listSource = $this->ptrValue($type, 'ptr'); + $list = Core::pointerAtAddress('zend_type_list *', $listSource); + $typeSize = Core::sizeOfType(zend_type::class); + $entryBase = Core::sizeOfType(zend_type_list::class) - $typeSize; + $size = $entryBase + $typeSize * $list->num_types; + [$new, $first] = $this->unit($listSource, $size); + $this->put($typeCopy, 'ptr', $new); + if ($first) { + for ($i = 0; $i < $list->num_types; $i++) { + $entrySource = Core::pointerAtAddress('zend_type *', $listSource + $entryBase + $i * $typeSize); + $entryCopy = $this->phase === 2 + ? Core::pointerAtAddress('zend_type *', $new + $entryBase + $i * $typeSize) + : $entrySource; + $this->persistType($entrySource, $entryCopy); + } + } + + return; + } + if (($typeMask & self::TYPE_NAME_BIT) !== 0) { + $this->put($typeCopy, 'ptr', $this->persistString($this->ptrValue($type, 'ptr'))); + } + } + + // --- op_arrays (zend_persist_op_array) ------------------------------------------------- + + /** Persists a pointed-to zend_function unit (function table entries, hooks, closures) */ + private function persistFunction(int $source): int + { + $opArray = Core::pointerAtAddress('zend_op_array *', $source); + if ($opArray->type !== Core::engineConstant('ZEND_USER_FUNCTION')) { + throw OpCacheException::unsupportedPayload('only user functions can be persisted into a file-cache image'); + } + [$new, $first] = $this->unit($source, Core::sizeOfType(zend_op_array::class)); + if ($first) { + $this->persistOpArrayBody($source, $new); + } + + return $new; + } + + /** The shared field walk for pointed-to op_arrays and the embedded main_op_array */ + private function persistOpArrayBody(int $source, int $copy): void + { + $op = Core::pointerAtAddress('zend_op_array *', $source); + $opCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_op_array *', $copy) : $op; + + $staticVariables = $this->ptrValue($op, 'static_variables'); + if ($staticVariables !== 0) { + $new = $this->persistArray($staticVariables, fn(int $s, int $c) => $this->persistZval($s, $c)); + $this->put($opCopy, 'static_variables', $new); + } + + $literals = $this->ptrValue($op, 'literals'); + if ($literals !== 0) { + $zvalSize = Core::sizeOfType(zval::class); + [$new, $first] = $this->unit($literals, $op->last_literal * $zvalSize); + $this->put($opCopy, 'literals', $new); + if ($first) { + for ($i = 0; $i < $op->last_literal; $i++) { + $this->persistZval($literals + $i * $zvalSize, $new + $i * $zvalSize); + } + } + } + + $opcodes = $this->ptrValue($op, 'opcodes'); + if ($opcodes !== 0) { + // Byte-verbatim: payload oplines are already file-form (handler + // indexes, literal-index operands, relative jumps) + [$new, ] = $this->unit($opcodes, $op->last * Core::sizeOfType('zend_op')); + $this->put($opCopy, 'opcodes', $new); + } + + $argInfo = $this->ptrValue($op, 'arg_info'); + if ($argInfo !== 0) { + $argSize = Core::sizeOfType(zend_arg_info::class); + $hasRet = ($op->fn_flags & 0x2000) !== 0 ? 1 : 0; // ZEND_ACC_HAS_RETURN_TYPE + $variadic = ($op->fn_flags & 0x4000) !== 0 ? 1 : 0; // ZEND_ACC_VARIADIC + $entries = $op->num_args + $hasRet + $variadic; + // The allocation starts at the return-type slot (arg_info[-1]) + $allocStart = $argInfo - $hasRet * $argSize; + [$new, $first] = $this->unit($allocStart, $entries * $argSize); + $this->put($opCopy, 'arg_info', $new + $hasRet * $argSize); + if ($first) { + for ($i = 0; $i < $entries; $i++) { + $entrySource = Core::pointerAtAddress('zend_arg_info *', $allocStart + $i * $argSize); + $entryCopy = $this->phase === 2 + ? Core::pointerAtAddress('zend_arg_info *', $new + $i * $argSize) + : $entrySource; + $nameAddress = $this->ptrValue($entrySource, 'name'); + if ($nameAddress !== 0) { + $this->put($entryCopy, 'name', $this->persistString($nameAddress)); + } + $this->persistType($entrySource->type, $entryCopy->type); + } + } + } + + $vars = $this->ptrValue($op, 'vars'); + if ($vars !== 0) { + [$new, $first] = $this->unit($vars, $op->last_var * PHP_INT_SIZE); + $this->put($opCopy, 'vars', $new); + if ($first) { + for ($i = 0; $i < $op->last_var; $i++) { + $stringAddress = $this->slotValue($vars + $i * PHP_INT_SIZE); + if ($stringAddress !== 0) { + $this->putAt($new + $i * PHP_INT_SIZE, $this->persistString($stringAddress)); + } + } + } + } + + foreach (['function_name', 'filename', 'doc_comment'] as $stringField) { + $address = $this->ptrValue($op, $stringField); + if ($address !== 0) { + $this->put($opCopy, $stringField, $this->persistString($address)); + } + } + + $liveRange = $this->ptrValue($op, 'live_range'); + if ($liveRange !== 0) { + [$new, ] = $this->unit($liveRange, $op->last_live_range * Core::sizeOfType(zend_live_range::class)); + $this->put($opCopy, 'live_range', $new); + } + + $this->persistAttributes($op, $opCopy, 'attributes'); + + $tryCatch = $this->ptrValue($op, 'try_catch_array'); + if ($tryCatch !== 0) { + [$new, ] = $this->unit($tryCatch, $op->last_try_catch * Core::sizeOfType(zend_try_catch_element::class)); + $this->put($opCopy, 'try_catch_array', $new); + } + + if ($op->num_dynamic_func_defs !== 0) { + $defs = $this->ptrValue($op, 'dynamic_func_defs'); + [$new, $first] = $this->unit($defs, $op->num_dynamic_func_defs * PHP_INT_SIZE); + $this->put($opCopy, 'dynamic_func_defs', $new); + if ($first) { + for ($i = 0; $i < $op->num_dynamic_func_defs; $i++) { + $defSource = $this->slotValue($defs + $i * PHP_INT_SIZE); + $this->putAt($new + $i * PHP_INT_SIZE, $this->persistFunction($defSource)); + } + } + } + + foreach ([ + 'scope' => 'op_array scope', + 'prototype' => 'op_array prototype', + 'prop_info' => 'op_array prop_info (hook back-reference)', + ] as $field => $what) { + $target = $this->ptrValue($op, $field); + if ($target !== 0) { + $this->defer($opCopy, $field, $target, $what); + } + } + // refcount / run_time_cache / static_variables_ptr map slots are copied + // verbatim: sources are persisted images where they already hold the + // file-form values (NULL, or the shared-body -1 refcount marker) + } + + // --- classes (zend_persist_class_entry, the non-LINKED branch) --------------------------- + + private function persistClassEntry(int $source): int + { + [$ceNew, $first] = $this->unit($source, Core::sizeOfType(zend_class_entry::class)); + if (!$first) { + return $ceNew; + } + $ce = Core::pointerAtAddress('zend_class_entry *', $source); + $ceCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_class_entry *', $ceNew) : $ce; + + $this->put($ceCopy, 'name', $this->persistString($this->ptrValue($ce, 'name'))); + if ($this->ptrValue($ce, 'parent') !== 0) { + if (($ce->ce_flags & Core::engineConstant('ZEND_ACC_LINKED')) !== 0) { + $this->defer($ceCopy, 'parent', $this->ptrValue($ce, 'parent'), 'linked parent class'); + } else { + $this->put($ceCopy, 'parent_name', $this->persistString($this->ptrValue($ce, 'parent_name'))); + } + } + + $this->persistHashData($ce->function_table, $ceCopy->function_table, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress('zval *', $zvalSource); + $new = $this->persistFunction($this->ptrValue($zv->value, 'func')); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'func', $new); + } + }); + + foreach ([ + 'default_properties_table' => $ce->default_properties_count, + 'default_static_members_table' => $ce->default_static_members_count, + ] as $tableField => $count) { + $table = $this->ptrValue($ce, $tableField); + if ($table === 0) { + continue; + } + $zvalSize = Core::sizeOfType(zval::class); + [$new, $first] = $this->unit($table, $count * $zvalSize); + $this->put($ceCopy, $tableField, $new); + if ($first) { + for ($i = 0; $i < $count; $i++) { + $this->persistZval($table + $i * $zvalSize, $new + $i * $zvalSize); + } + } + } + + $this->persistHashData($ce->constants_table, $ceCopy->constants_table, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress('zval *', $zvalSource); + $constSource = $this->ptrValue($zv->value, 'ptr'); + [$new, $first] = $this->unit($constSource, Core::sizeOfType(zend_class_constant::class)); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'ptr', $new); + } + if (!$first) { + return; + } + $constant = Core::pointerAtAddress('zend_class_constant *', $constSource); + $constantCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_class_constant *', $new) : $constant; + $this->persistZval($constSource, $this->phase === 2 ? $new : $constSource); // value zval is the first member + $docComment = $this->ptrValue($constant, 'doc_comment'); + if ($docComment !== 0) { + $this->put($constantCopy, 'doc_comment', $this->persistString($docComment)); + } + $this->persistAttributes($constant, $constantCopy, 'attributes'); + $this->defer($constantCopy, 'ce', $this->ptrValue($constant, 'ce'), 'class constant scope'); + $this->persistType($constant->type, $constantCopy->type); + }); + + $filename = $this->ptrValue($ce->info->user, 'filename'); + if ($filename !== 0) { + $this->put($ceCopy->info->user, 'filename', $this->persistString($filename)); + } + $docComment = $this->ptrValue($ce, 'doc_comment'); + if ($docComment !== 0) { + $this->put($ceCopy, 'doc_comment', $this->persistString($docComment)); + } + $this->persistAttributes($ce, $ceCopy, 'attributes'); + + $this->persistHashData($ce->properties_info, $ceCopy->properties_info, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress('zval *', $zvalSource); + $propSource = $this->ptrValue($zv->value, 'ptr'); + [$new, $first] = $this->unit($propSource, Core::sizeOfType(zend_property_info::class)); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'ptr', $new); + } + if (!$first) { + return; + } + $prop = Core::pointerAtAddress('zend_property_info *', $propSource); + $propCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_property_info *', $new) : $prop; + $this->defer($propCopy, 'ce', $this->ptrValue($prop, 'ce'), 'property scope'); + $this->put($propCopy, 'name', $this->persistString($this->ptrValue($prop, 'name'))); + $docComment = $this->ptrValue($prop, 'doc_comment'); + if ($docComment !== 0) { + $this->put($propCopy, 'doc_comment', $this->persistString($docComment)); + } + $this->persistAttributes($prop, $propCopy, 'attributes'); + $prototype = $this->ptrValue($prop, 'prototype'); + if ($prototype !== 0) { + $this->defer($propCopy, 'prototype', $prototype, 'property prototype'); + } + $hooks = $this->ptrValue($prop, 'hooks'); + if ($hooks !== 0) { + [$newHooks, $firstHooks] = $this->unit($hooks, self::PROPERTY_HOOK_COUNT * PHP_INT_SIZE); + $this->put($propCopy, 'hooks', $newHooks); + if ($firstHooks) { + for ($i = 0; $i < self::PROPERTY_HOOK_COUNT; $i++) { + $hookSource = $this->slotValue($hooks + $i * PHP_INT_SIZE); + if ($hookSource !== 0) { + $this->putAt($newHooks + $i * PHP_INT_SIZE, $this->persistFunction($hookSource)); + } + } + } + } + $this->persistType($prop->type, $propCopy->type); + }); + + $propTable = $this->ptrValue($ce, 'properties_info_table'); + if ($propTable !== 0) { + [$new, $first] = $this->unit($propTable, $ce->default_properties_count * PHP_INT_SIZE); + $this->put($ceCopy, 'properties_info_table', $new); + if ($first) { + for ($i = 0; $i < $ce->default_properties_count; $i++) { + $slotTarget = $this->slotValue($propTable + $i * PHP_INT_SIZE); + if ($slotTarget !== 0) { + $this->deferAt($new + $i * PHP_INT_SIZE, $slotTarget, 'properties_info_table entry'); + } + } + } + } + + if ($ce->num_interfaces !== 0) { + if (($ce->ce_flags & Core::engineConstant('ZEND_ACC_LINKED')) !== 0) { + // Mirrors the ZEND_ASSERT in zend_file_cache_serialize_class + throw OpCacheException::unsupportedPayload('a linked class with interfaces cannot be re-serialized'); + } + $this->persistClassNames($ce, $ceCopy, 'interface_names', $ce->num_interfaces); + } + if ($ce->num_traits !== 0) { + $this->persistClassNames($ce, $ceCopy, 'trait_names', $ce->num_traits); + $this->persistTraitAliases($ce, $ceCopy); + $this->persistTraitPrecedences($ce, $ceCopy); + } + + foreach (self::MAGIC_METHOD_FIELDS as $field) { + $target = $this->ptrValue($ce, $field); + if ($target !== 0) { + $this->defer($ceCopy, $field, $target, "magic method {$field}"); + } + } + + $iteratorFuncs = $this->ptrValue($ce, 'iterator_funcs_ptr'); + if ($iteratorFuncs !== 0) { + [$new, $first] = $this->unit($iteratorFuncs, Core::sizeOfType(zend_class_iterator_funcs::class)); + $this->put($ceCopy, 'iterator_funcs_ptr', $new); + if ($first) { + $funcs = Core::pointerAtAddress('zend_class_iterator_funcs *', $iteratorFuncs); + $funcsCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_class_iterator_funcs *', $new) : $funcs; + foreach (self::ITERATOR_FUNC_FIELDS as $field) { + $target = $this->ptrValue($funcs, $field); + if ($target !== 0) { + $this->defer($funcsCopy, $field, $target, "iterator {$field}"); + } + } + } + } + $arrayAccessFuncs = $this->ptrValue($ce, 'arrayaccess_funcs_ptr'); + if ($arrayAccessFuncs !== 0) { + [$new, $first] = $this->unit($arrayAccessFuncs, Core::sizeOfType(zend_class_arrayaccess_funcs::class)); + $this->put($ceCopy, 'arrayaccess_funcs_ptr', $new); + if ($first) { + $funcs = Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $arrayAccessFuncs); + $funcsCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $new) : $funcs; + foreach (self::ARRAYACCESS_FUNC_FIELDS as $field) { + $target = $this->ptrValue($funcs, $field); + if ($target !== 0) { + $this->defer($funcsCopy, $field, $target, "arrayaccess {$field}"); + } + } + } + } + + // zend_persist_class_entry: the inheritance cache never survives a persist + if ($this->phase === 2 && $this->ptrValue($ceCopy, 'inheritance_cache') !== 0) { + $this->put($ceCopy, 'inheritance_cache', 0); + } + + return $ceNew; + } + + /** + * @param \FFI\CData $ce + * @param \FFI\CData $ceCopy + */ + private function persistClassNames(object $ce, object $ceCopy, string $field, int $count): void + { + $source = $this->ptrValue($ce, $field); + if ($source === 0) { + return; + } + $nameSize = Core::sizeOfType(zend_class_name::class); + [$new, $first] = $this->unit($source, $count * $nameSize); + $this->put($ceCopy, $field, $new); + if (!$first) { + return; + } + for ($i = 0; $i < $count; $i++) { + $entrySource = Core::pointerAtAddress('zend_class_name *', $source + $i * $nameSize); + $entryCopy = $this->phase === 2 + ? Core::pointerAtAddress('zend_class_name *', $new + $i * $nameSize) + : $entrySource; + $this->put($entryCopy, 'name', $this->persistString($this->ptrValue($entrySource, 'name'))); + $this->put($entryCopy, 'lc_name', $this->persistString($this->ptrValue($entrySource, 'lc_name'))); + } + } + + /** + * @param \FFI\CData $ce + * @param \FFI\CData $ceCopy + */ + private function persistTraitAliases(object $ce, object $ceCopy): void + { + $source = $this->ptrValue($ce, 'trait_aliases'); + if ($source === 0) { + return; + } + $count = 0; + while ($this->slotValue($source + $count * PHP_INT_SIZE) !== 0) { + $count++; + } + [$new, $first] = $this->unit($source, ($count + 1) * PHP_INT_SIZE); + $this->put($ceCopy, 'trait_aliases', $new); + if (!$first) { + return; + } + for ($i = 0; $i < $count; $i++) { + $aliasSource = $this->slotValue($source + $i * PHP_INT_SIZE); + [$newAlias, $firstAlias] = $this->unit($aliasSource, Core::sizeOfType(zend_trait_alias::class)); + $this->putAt($new + $i * PHP_INT_SIZE, $newAlias); + if (!$firstAlias) { + continue; + } + $alias = Core::pointerAtAddress('zend_trait_alias *', $aliasSource); + $aliasCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_trait_alias *', $newAlias) : $alias; + foreach (['method_name', 'class_name'] as $nameField) { + $address = $this->ptrValue($alias->trait_method, $nameField); + if ($address !== 0) { + $this->put($aliasCopy->trait_method, $nameField, $this->persistString($address)); + } + } + $address = $this->ptrValue($alias, 'alias'); + if ($address !== 0) { + $this->put($aliasCopy, 'alias', $this->persistString($address)); + } + } + } + + /** + * @param \FFI\CData $ce + * @param \FFI\CData $ceCopy + */ + private function persistTraitPrecedences(object $ce, object $ceCopy): void + { + $source = $this->ptrValue($ce, 'trait_precedences'); + if ($source === 0) { + return; + } + $count = 0; + while ($this->slotValue($source + $count * PHP_INT_SIZE) !== 0) { + $count++; + } + [$new, $first] = $this->unit($source, ($count + 1) * PHP_INT_SIZE); + $this->put($ceCopy, 'trait_precedences', $new); + if (!$first) { + return; + } + for ($i = 0; $i < $count; $i++) { + $precedenceSource = $this->slotValue($source + $i * PHP_INT_SIZE); + $precedence = Core::pointerAtAddress('zend_trait_precedence *', $precedenceSource); + $size = Core::sizeOfType(zend_trait_precedence::class) + + PHP_INT_SIZE * ($precedence->num_excludes - 1); + [$newPrecedence, $firstPrecedence] = $this->unit($precedenceSource, $size); + $this->putAt($new + $i * PHP_INT_SIZE, $newPrecedence); + if (!$firstPrecedence) { + continue; + } + $precedenceCopy = $this->phase === 2 + ? Core::pointerAtAddress('zend_trait_precedence *', $newPrecedence) + : $precedence; + foreach (['method_name', 'class_name'] as $nameField) { + $address = $this->ptrValue($precedence->trait_method, $nameField); + if ($address !== 0) { + $this->put($precedenceCopy->trait_method, $nameField, $this->persistString($address)); + } + } + $excludeBase = Core::addressOf($precedence->exclude_class_names); + $excludeCopyBase = $this->phase === 2 ? Core::addressOf($precedenceCopy->exclude_class_names) : $excludeBase; + for ($j = 0; $j < $precedence->num_excludes; $j++) { + $address = $this->slotValue($excludeBase + $j * PHP_INT_SIZE); + if ($address !== 0) { + $this->putAt($excludeCopyBase + $j * PHP_INT_SIZE, $this->persistString($address)); + } + } + } + } + + // --- the script root ----------------------------------------------------------------- + + private function persistScript(int $source): void + { + [$new, ] = $this->unit($source, Core::sizeOfType(zend_persistent_script::class)); + $script = Core::pointerAtAddress('zend_persistent_script *', $source); + $scriptCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_persistent_script *', $new) : $script; + + $this->put($scriptCopy->script, 'filename', $this->persistString($this->ptrValue($script->script, 'filename'))); + + $this->persistHashData($script->script->class_table, $scriptCopy->script->class_table, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress('zval *', $zvalSource); + $new = $this->persistClassEntry($this->ptrValue($zv->value, 'ce')); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'ce', $new); + } + }); + $this->persistHashData($script->script->function_table, $scriptCopy->script->function_table, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress('zval *', $zvalSource); + $new = $this->persistFunction($this->ptrValue($zv->value, 'func')); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'func', $new); + } + }); + + $mainSource = Core::addressOf(Core::addr($script->script->main_op_array)); + $mainCopy = $this->phase === 2 ? Core::addressOf(Core::addr($scriptCopy->script->main_op_array)) : $mainSource; + $this->persistOpArrayBody($mainSource, $mainCopy); + + $warnings = $this->ptrValue($script, 'warnings'); + if ($warnings !== 0) { + [$new, $first] = $this->unit($warnings, $script->num_warnings * PHP_INT_SIZE); + $this->put($scriptCopy, 'warnings', $new); + if ($first) { + for ($i = 0; $i < $script->num_warnings; $i++) { + $warningSource = $this->slotValue($warnings + $i * PHP_INT_SIZE); + [$newWarning, $firstWarning] = $this->unit($warningSource, Core::sizeOfType(zend_error_info::class)); + $this->putAt($new + $i * PHP_INT_SIZE, $newWarning); + if (!$firstWarning) { + continue; + } + $warning = Core::pointerAtAddress('zend_error_info *', $warningSource); + $warningCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_error_info *', $newWarning) : $warning; + foreach (['filename', 'message'] as $stringField) { + $address = $this->ptrValue($warning, $stringField); + if ($address !== 0) { + $this->put($warningCopy, $stringField, $this->persistString($address)); + } + } + } + } + } + + $earlyBindings = $this->ptrValue($script, 'early_bindings'); + if ($earlyBindings !== 0) { + $bindingSize = Core::sizeOfType(zend_early_binding::class); + [$new, $first] = $this->unit($earlyBindings, $script->num_early_bindings * $bindingSize); + $this->put($scriptCopy, 'early_bindings', $new); + if ($first) { + for ($i = 0; $i < $script->num_early_bindings; $i++) { + $bindingSource = Core::pointerAtAddress('zend_early_binding *', $earlyBindings + $i * $bindingSize); + $bindingCopy = $this->phase === 2 + ? Core::pointerAtAddress('zend_early_binding *', $new + $i * $bindingSize) + : $bindingSource; + foreach (['lcname', 'rtd_key', 'lc_parent_name'] as $stringField) { + $address = $this->ptrValue($bindingSource, $stringField); + if ($address !== 0) { + $this->put($bindingCopy, $stringField, $this->persistString($address)); + } + } + } + } + } + + if ($this->phase === 2) { + // script->size is load-bearing: the loader's IS_SERIALIZED bound. + // script->mem is rewritten by zend_file_cache_unserialize on load. + $scriptCopy->size = $this->total; + if ($this->ptrValue($scriptCopy, 'mem') !== 0) { + $this->put($scriptCopy, 'mem', 0); + } + } + } +} diff --git a/tests/OpCache/GraphGrowingSerializerTest.php b/tests/OpCache/GraphGrowingSerializerTest.php new file mode 100644 index 00000000..a298747c --- /dev/null +++ b/tests/OpCache/GraphGrowingSerializerTest.php @@ -0,0 +1,187 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * The from-scratch persist-from-graph serializer (issue #117): every payload + * shape must survive a full re-layout (rebuild an untouched image from its + * graph and execute it), and the acceptance path - graft a brand-new function + * and a new method into a cached script, save(), and have a fresh worker + * execute them straight from the file cache. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class GraphGrowingSerializerTest extends TestCase +{ + use FileCacheFixture; + + /** @var list extra cache directories to clean up */ + private array $extraCacheDirs = []; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + foreach ($this->extraCacheDirs as $directory) { + self::removeDirectory($directory); + } + $this->extraCacheDirs = []; + } + + /** + * @return iterable + */ + public static function rebuildFixtures(): iterable + { + yield 'attributes + statics' => ['answer.php', 'zengine_bin_answer', '41']; + yield 'type lists' => ['type-lists.php', 'zengine_bin_typelist_run', 'ZEngineTypeListImpl:tl-ok']; + yield 'traits' => ['traits.php', 'zengine_bin_trait_run', 'greeter-shared:shouter-shared:greeter:tr-ok']; + yield 'closures' => ['closures.php', 'zengine_bin_closures_run', 'cl:42:42:cl-ok']; + yield 'property hooks' => ['property-hooks.php', 'zengine_bin_hooks_run', '0:40:gauge-40:0:ph-ok']; + yield 'iterators' => ['iterators.php', 'zengine_bin_iterators_run', 'alpha:beta:gamma:delta:it-ok']; + yield 'jumps and consts' => ['addressing-probe.php', 'zengine_bin_probe', 'probe-ok']; + } + + /** + * Rebuilding an UNTOUCHED image from its graph exercises every persist + * walker; the rebuilt binary must be relocator-round-trippable and the + * engine must execute it from the cache. + */ + #[DataProvider('rebuildFixtures')] + public function testRebuiltImageExecutesFromCache(string $fixtureName, string $target, string $expected): void + { + $fixture = self::fixtureByName($fixtureName); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + + $serializer = new ScriptSerializer($file->getReflection()->getRawScript()); + $payload = $serializer->serialize(); + $meta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: $serializer->memSize(), + strSize: strlen($payload) - $serializer->memSize(), + scriptOffset: $serializer->scriptOffset(), + timestamp: 0, + checksum: CacheMetaInfo::checksumOf($payload), + ); + file_put_contents($file->binPath(), $meta->toBinary() . $payload); + + $rebuilt = BinaryCacheFile::read($file->binPath(), $fixture); + self::assertTrue($rebuilt->verifyChecksum()); + $rebuilt->getReflection(); // relocate + $rebuilt->save($file->binPath() . '.roundtrip'); + self::assertSame( + (string) file_get_contents($file->binPath()), + (string) file_get_contents($file->binPath() . '.roundtrip'), + 'The rebuilt image must round-trip byte-identically through the relocator', + ); + unlink($file->binPath() . '.roundtrip'); + + self::assertSame($expected, self::runFromCache($fixture, $target, self::$cacheDir)); + } + + /** + * The issue #117 acceptance: a brand-new function AND a new method grafted + * into a cached script execute from the file cache in a fresh worker. + */ + public function testGraftedFunctionAndMethodExecuteFromCache(): void + { + $main = self::fixtureByName('answer.php'); + $donorSrc = self::fixtureByName('graft-donor.php'); + + $file = BinaryCacheFile::read(self::compileFixture($main), $main); + $donorDir = sys_get_temp_dir() . '/zengine-graft-donor-' . bin2hex(random_bytes(6)); + self::assertTrue(mkdir($donorDir, 0777, true)); + $this->extraCacheDirs[] = $donorDir; + $donor = BinaryCacheFile::compile($donorSrc, $donorDir); + + $view = $file->getReflection(); + self::assertFalse($view->isGraphGrown()); + $view->addFunctionFrom($donor->getReflection(), 'zengine_bin_added'); + $view->addMethodFrom($donor->getReflection(), 'ZEngineGraftDonor', 'addedReport', 'ZEngineBinSubject'); + self::assertTrue($view->isGraphGrown()); + $file->save(); + + // The written binary is sound: checksum, reflection view, round trip + $reread = BinaryCacheFile::read($file->binPath(), $main); + self::assertTrue($reread->verifyChecksum()); + self::assertArrayHasKey('zengine_bin_added', $reread->getReflection()->getFunctions()); + $reread->save($file->binPath() . '.roundtrip'); + self::assertSame( + (string) file_get_contents($file->binPath()), + (string) file_get_contents($file->binPath() . '.roundtrip'), + 'The grown image must round-trip byte-identically through the relocator', + ); + unlink($file->binPath() . '.roundtrip'); + + // A fresh worker executes the grafts AND the original entries + self::assertSame('added-fn', self::runFromCache($main, 'zengine_bin_added', self::$cacheDir)); + self::assertSame('added-method-ok', self::runFromCache($main, 'ZEngineBinSubject::addedReport', self::$cacheDir)); + self::assertSame('41', self::runFromCache($main, 'zengine_bin_answer', self::$cacheDir)); + } + + public function testGraftRefusesUnknownDonorEntries(): void + { + $main = self::fixtureByName('answer.php'); + $donorSrc = self::fixtureByName('graft-donor.php'); + + $file = BinaryCacheFile::read(self::compileFixture($main), $main); + $donorDir = sys_get_temp_dir() . '/zengine-graft-donor-' . bin2hex(random_bytes(6)); + self::assertTrue(mkdir($donorDir, 0777, true)); + $this->extraCacheDirs[] = $donorDir; + $donor = BinaryCacheFile::compile($donorSrc, $donorDir); + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessage("Cannot graft function 'zengine_bin_missing'"); + $file->getReflection()->addFunctionFrom($donor->getReflection(), 'zengine_bin_missing'); + } + + public function testGraftRefusesDuplicateKeys(): void + { + $main = self::fixtureByName('answer.php'); + $donorSrc = self::fixtureByName('graft-donor.php'); + + $file = BinaryCacheFile::read(self::compileFixture($main), $main); + $donorDir = sys_get_temp_dir() . '/zengine-graft-donor-' . bin2hex(random_bytes(6)); + self::assertTrue(mkdir($donorDir, 0777, true)); + $this->extraCacheDirs[] = $donorDir; + $donor = BinaryCacheFile::compile($donorSrc, $donorDir); + + $view = $file->getReflection(); + $view->addFunctionFrom($donor->getReflection(), 'zengine_bin_added'); + $this->expectException(OpCacheException::class); + $this->expectExceptionMessage("Cannot graft 'zengine_bin_added'"); + $view->addFunctionFrom($donor->getReflection(), 'zengine_bin_added'); + } + + private static function fixtureByName(string $name): string + { + $path = realpath(__DIR__ . '/fixtures/' . $name); + self::assertIsString($path); + + return $path; + } +} diff --git a/tests/OpCache/fixtures/graft-donor.php b/tests/OpCache/fixtures/graft-donor.php new file mode 100644 index 00000000..c0182776 --- /dev/null +++ b/tests/OpCache/fixtures/graft-donor.php @@ -0,0 +1,23 @@ + Date: Wed, 19 Aug 2026 22:34:42 +0000 Subject: [PATCH 23/28] feat(opcache): bounds-validate relocation offsets and document the trust model Defense-in-depth for the file-cache reader (issue #123). PayloadRelocator turned every stored offset into base + stored and drove its loops off count fields read straight from the payload, without range checks - so a crafted .bin carrying the current build's system_id (a build fingerprint, not an authenticator; adler32 is forgeable) fed into getReflection() was an FFI arbitrary read/write primitive. Now every stored value is validated before it becomes an address the engine walks, in the relocate() (untrusted-input) path: - requireOffset: interior-pointer offsets against [0, memSize] - requireStringOffset: tagged interned-string offsets against [0, strSize), plain string offsets against [0, memSize] - requireSpan / requireCount: scriptOffset and every count-driven element array - hashtable buckets/packed data, literals, arg_info (incl. the arg_info[-1] return slot), vars, type lists, attribute args, class property tables, properties_info_table, class/trait names, trait alias/precedence NUL-terminated arrays and their exclude lists, property hooks, dynamic_func_defs, ast children/nodes, warnings and early bindings A violation throws OpCacheException::malformedPayload - a loud refusal, never an out-of-bounds walk. The derelocate()/serialize() path and the #117 graph ScriptSerializer operate on an already-relocated in-process image and inherit this validation. The tagged-offset bound tracks the string section serialize() just rebuilt (re-pinned from the header str_size), so the relocate() inside derelocate() validates against the section it produced, not the stale size. docs/opcache-binary.md gains a "Trust model" section: .bin input must come from a trusted source; system_id is a build fingerprint (ABI guard), not authenticity; adler32 catches accidental corruption, not tampering; the bounds checks turn a memory-safety catastrophe into a clean exception but are not a licence to load untrusted code. The optional keyed-MAC for distributing protected binaries is described as a deploy-side responsibility and flagged as a possible follow-up, deliberately not built in (key management belongs to the application). BoundsValidationTest proves the loud refusal on a truncated buffer, an out-of-range scriptOffset, a hostile pointer field, a hostile hash count and an out-of-range interned-string offset - and that a well-formed image still relocates and round-trips (no false positives). Run in the debug84 and debug84-zts containers too, where an unguarded out-of-bounds read segfaults loudest: no crashes, all refusals clean. Acceptance evidence: - full default suite (536 tests, baseline skips), --group opcache --fail-on-skipped OK (64 tests, 472 assertions) on host, debug84 and debug84-zts; phpstan level max clean; php-cs-fixer clean Fixes #123 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/opcache-binary.md | 48 ++++++- phpstan.dist.neon | 7 + src/OpCache/OpCacheException.php | 12 ++ src/OpCache/PayloadRelocator.php | 168 ++++++++++++++++++++--- tests/OpCache/BoundsValidationTest.php | 182 +++++++++++++++++++++++++ 5 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 tests/OpCache/BoundsValidationTest.php diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index e272959d..6c65be83 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -266,9 +266,53 @@ $report->appliedMethods; // what actually happened, per entry patched image to already-loaded functions and classes landed as `CacheImageSync` (see above). +## Trust model — the `.bin` input must be trusted + +Loading a cache binary is loading **code**. The relocator turns stored byte +offsets into real engine addresses that the interpreter then executes, so a +`.bin` file is exactly as trusted as the PHP source it was compiled from. Treat +it that way: read binaries only from a location your own deployment controls. + +Two header fields look like integrity checks but are **not** authentication: + +- **`system_id`** is a *build fingerprint* — a hash of the PHP version, + extension set and build flags. It exists so a binary compiled by one build is + refused by an incompatible one (`systemIdMismatch`), preventing accidental + ABI mismatch. It says nothing about *who* produced the binary; anyone can + compute the current build's `system_id` and stamp it on a crafted file. +- **`checksum`** is an **adler32** of the payload. It catches accidental + corruption (a truncated write, a bad disk block). adler32 is trivially + forgeable — an attacker who alters the payload simply recomputes it — so it + is not tamper protection against a motivated adversary. + +Because neither field authenticates the producer, the relocator does **not** +rely on them for safety. Instead, **every stored offset, count and element span +is bounds-validated against the declared buffer before it is dereferenced** +(issue #123): interior-pointer offsets against `[0, memSize]`, tagged +interned-string offsets against `[0, strSize)`, `scriptOffset` and every +count-driven element array (hashtable buckets, literals, arg_info, vars, type +lists, class/trait names, property hooks, `dynamic_func_defs`, warnings, early +bindings, …) against the region bounds. A violation raises +`OpCacheException::malformedPayload` — a loud refusal, never an out-of-bounds +engine read/write. The validation lives in the `relocate()` (read) path, the +untrusted-input surface; `derelocate()`/`serialize()` and the graph +`ScriptSerializer` operate on an already-relocated, in-process image and +inherit that validation. This is defense in depth, **not** a licence to load +untrusted binaries: it converts a memory-safety catastrophe into a clean +exception, but a validated binary can still contain hostile *compiled code*. + +**Distributing protected binaries.** If you need to ship binaries across a trust +boundary (a build server to production hosts, say), authenticate them yourself +with a keyed MAC or a signature over the file before loading — e.g. an +HMAC-SHA256 with a deployment secret, verified before `BinaryCacheFile::read()`. +A built-in keyed-MAC mode is a possible future option (a follow-up to issue +#123); it is deliberately not part of this version, because the right key +management belongs to the deploying application, not the library. + ## Failure modes Everything the API rejects is a static factory on `OpCacheException` (`invalidMagic`, `truncatedFile`, `systemIdMismatch`, `checksumMismatch`, -`binFileNotFound`, `compilationFailed`, `unsupportedPayload`, …), so call sites -read as intent and the wording lives in one place. +`binFileNotFound`, `compilationFailed`, `unsupportedPayload`, +`malformedPayload`, …), so call sites read as intent and the wording lives in +one place. diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 36942d97..2f682bc0 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -122,6 +122,13 @@ parameters: - identifier: assignOp.invalid path: src/OpCache/ReflectionOpcacheFile.php + # BoundsValidationTest crafts hostile payloads by poking engine-struct + # pointer fields through FFI\CData (the address of a filename/HashTable + # slot to overwrite), the same pointer surgery PayloadRelocator does - + # FFI::addr() on a CData field read resolves to mixed here. + - + identifier: argument.type + path: tests/OpCache/BoundsValidationTest.php # The dimension tests exist to prove that a plain `count($object)` reaches the engine's # count_elements handler on a class that never declared the count itself. Rewriting them # as assertCount() would measure PHPUnit's Count constraint instead of the language diff --git a/src/OpCache/OpCacheException.php b/src/OpCache/OpCacheException.php index f744e6fb..54e7cdfa 100644 --- a/src/OpCache/OpCacheException.php +++ b/src/OpCache/OpCacheException.php @@ -146,6 +146,18 @@ public static function unresolvedGraphReference(string $what): self return new self("Graph serialization failed, {$what}: the referenced structure was not persisted"); } + /** + * A stored offset, count or element span in the payload points outside the + * declared buffer bounds - the binary is truncated or crafted. Relocating it + * would be an out-of-bounds engine read/write, so the load is refused + * (issue #123). The bin must come from a trusted producer; system_id is a + * build fingerprint, not an authenticator, and adler32 is not tamper-proof. + */ + public static function malformedPayload(string $what): self + { + return new self("Malformed opcache payload: {$what}"); + } + /** * A graft donor does not contain the requested function/class/method */ diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index 172a5b4f..74e2d126 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -24,6 +24,7 @@ use ZEngine\Generated\zend_attribute_arg; use ZEngine\Generated\zend_class_name; use ZEngine\Generated\zend_early_binding; +use ZEngine\Generated\zend_persistent_script; use ZEngine\Generated\zend_string; use ZEngine\Generated\zend_type; use ZEngine\Generated\zend_type_list; @@ -85,6 +86,13 @@ final class PayloadRelocator private readonly int $base; private readonly int $size; + /** + * Upper bound for tagged interned-string offsets. Starts at the header's + * str_size and is re-pinned to the rebuilt string-section length whenever + * serialize() re-emits it, so the relocate() inside derelocate() validates + * against the section it just produced, not the stale original size. + */ + private int $strSize; private readonly int $strSectionBase; /** Interned-string re-emission state (write path) */ @@ -119,6 +127,7 @@ public function __construct(private readonly object $buffer, private readonly Ca } $this->base = Core::addressOf(Core::addr($buffer)); $this->size = $metaInfo->memSize(); + $this->strSize = $metaInfo->strSize(); $this->strSectionBase = $this->base + $this->size; // _ZSTR_HEADER_SIZE = XtOffsetOf(zend_string, val): the flexible val[1] // member starts at the last 8-byte slot of the (padded) struct, so the @@ -136,7 +145,14 @@ public function __construct(private readonly object $buffer, private readonly Ca public function relocate(): object { $this->sharedOpcodes = []; - $script = Core::pointerAtAddress('zend_persistent_script *', $this->base + $this->metaInfo->scriptOffset()); + // The script struct itself must fit inside the mem region before we + // dereference a single field of it (issue #123) + $this->requireSpan( + $this->metaInfo->scriptOffset(), + Core::sizeOfType(zend_persistent_script::class), + 'zend_persistent_script at scriptOffset', + ); + $script = Core::pointerAtAddress('zend_persistent_script *', $this->base + $this->metaInfo->scriptOffset()); $this->unStr($script->script, 'filename'); $this->unserializeHash($script->script->class_table, $this->unserializeClass(...)); @@ -183,6 +199,9 @@ private function serialize(): string $this->serializeEarlyBindings($script); $memRegion = FFI::string($this->buffer, $this->size); + // Re-pin the tagged-offset bound to the section just emitted, so the + // relocate() in derelocate() validates against it (issue #123) + $this->strSize = strlen($this->strSection); return $memRegion . $this->strSection; } @@ -226,6 +245,79 @@ private function isUnserialized(int $pointer): bool return $pointer >= $this->base && $pointer <= $this->base + $this->size; } + // --- bounds validation (issue #123) ------------------------------------- + // Every stored offset in the file is attacker-controllable in an untrusted + // binary (system_id is a build fingerprint, adler32 is forgeable), so each + // one is range-checked before it becomes a real address the engine walks. + // The checks live in the UNSERIALIZE (relocate) primitives and the + // count-driven relocate loops - the derelocate/serialize path and the graph + // serializer both operate on an already-relocated, in-process image and + // inherit that image's validation. + + /** + * Validates a stored mem-region offset lies in [0, size]. The upper bound is + * inclusive because a return-type-only &arg_info[1] legitimately points at + * the region end. Returns the offset for fluent use. + */ + private function requireOffset(int $stored, string $what): int + { + if ($stored < 0 || $stored > $this->size) { + throw OpCacheException::malformedPayload( + sprintf('%s: stored offset %d is outside [0, %d]', $what, $stored, $this->size), + ); + } + + return $stored; + } + + /** + * Validates a stored zend_string reference: a tagged interned reference must + * land in the appended string section [0, strSize), a plain one in the mem + * region [0, size]. + */ + private function requireStringOffset(int $stored, string $what): void + { + if (($stored & 1) !== 0) { + $offset = $stored & ~1; + if ($offset < 0 || $offset >= $this->strSize) { + throw OpCacheException::malformedPayload( + sprintf('%s: interned-string offset %d is outside [0, %d)', $what, $offset, $this->strSize), + ); + } + + return; + } + $this->requireOffset($stored, $what); + } + + /** + * Validates that [resolvedAddress, resolvedAddress + count * elementSize) + * lies fully within the mem region, before a loop dereferences the span. + * A negative or overflowing count is rejected too. + */ + private function requireSpan(int $offsetOrAddress, int $bytes, string $what): void + { + // Accept either a stored offset or a resolved (base+offset) address + $offset = $offsetOrAddress >= $this->base ? $offsetOrAddress - $this->base : $offsetOrAddress; + if ($bytes < 0 || $offset < 0 || $offset > $this->size || $offset + $bytes > $this->size) { + throw OpCacheException::malformedPayload( + sprintf('%s: span [%d, %d) escapes the %d-byte mem region', $what, $offset, $offset + $bytes, $this->size), + ); + } + } + + /** Validates a count field before it drives an element-span walk */ + private function requireCount(int $count, string $what): int + { + if ($count < 0 || $count > $this->size) { + throw OpCacheException::malformedPayload( + sprintf('%s: implausible count %d for a %d-byte region', $what, $count, $this->size), + ); + } + + return $count; + } + /** UNSERIALIZE_PTR on a struct field, returning the resolved address (0 if null) */ /** * @param \FFI\CData $owner @@ -236,6 +328,7 @@ private function unPtr(object $owner, string $field): int if ($stored === 0) { return 0; } + $this->requireOffset($stored, "pointer field {$field}"); $address = $this->base + $stored; $this->writePtrField($owner, $field, $address); @@ -265,6 +358,7 @@ private function unPtrAt(int $slotAddress): int if ($stored === 0) { return 0; } + $this->requireOffset($stored, 'raw pointer slot'); $slot[0] = $this->base + $stored; return $this->base + $stored; @@ -294,6 +388,7 @@ private function unStr(object $owner, string $field): void if ($stored === 0) { return; } + $this->requireStringOffset($stored, "string field {$field}"); if (($stored & 1) !== 0) { // Tagged interned reference into the string section $address = $this->strSectionBase + ($stored & ~1); @@ -330,6 +425,7 @@ private function unStrAt(int $slotAddress): void if ($stored === 0) { return; } + $this->requireStringOffset($stored, 'raw string slot'); if (($stored & 1) !== 0) { $slot[0] = $this->strSectionBase + ($stored & ~1); } else { @@ -386,9 +482,10 @@ private function unserializeHash(object $ht, callable $each): void return; } $dataAddress = $this->unPtr($ht, 'arData'); - $used = $ht->nNumUsed; + $used = $this->requireCount((int) $ht->nNumUsed, 'hashtable nNumUsed'); if (($ht->u->flags & self::HASH_FLAG_PACKED) !== 0) { $zvalSize = Core::sizeOfType(zval::class); + $this->requireSpan($dataAddress, $used * $zvalSize, 'packed hashtable data'); for ($i = 0; $i < $used; $i++) { $zval = Core::pointerAtAddress('zval *', $dataAddress + $i * $zvalSize); if ($zval->u1->v->type !== 0) { @@ -399,6 +496,7 @@ private function unserializeHash(object $ht, callable $each): void return; } $bucketSize = Core::sizeOfType(Bucket::class); + $this->requireSpan($dataAddress, $used * $bucketSize, 'hashtable bucket data'); for ($i = 0; $i < $used; $i++) { $bucket = Core::pointerAtAddress('Bucket *', $dataAddress + $i * $bucketSize); if ($bucket->val->u1->v->type !== 0) { @@ -516,6 +614,8 @@ private function serializeZval(object $zval): void /** @param int $astAddress address of the zend_ast (already resolved) */ private function unserializeAst(int $astAddress): void { + // The node header (kind + attr) must fit before it is read + $this->requireSpan($astAddress, Core::sizeOfType(zend_ast::class), 'zend_ast node'); $ast = Core::pointerAtAddress('zend_ast *', $astAddress); $kind = $ast->kind; if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { @@ -526,15 +626,17 @@ private function unserializeAst(int $astAddress): void if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { $list = Core::pointerAtAddress('zend_ast_list *', $astAddress); $childBase = $astAddress + Core::sizeOfType(zend_ast_list::class) - PHP_INT_SIZE; - $count = $list->children; + $count = $this->requireCount((int) $list->children, 'ast list children'); } else { $childBase = $astAddress + Core::sizeOfType(zend_ast::class) - PHP_INT_SIZE; $count = $kind >> self::ZEND_AST_CHILDREN_SHIFT; } + $this->requireSpan($childBase, $count * PHP_INT_SIZE, 'ast children slots'); for ($i = 0; $i < $count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $childBase + $i * PHP_INT_SIZE)); $child = (int) $slot[0]; if ($child !== 0 && !$this->isUnserialized($child)) { + $this->requireOffset($child, 'ast child'); $slot[0] = $this->base + $child; $this->unserializeAst($this->base + $child); } @@ -612,7 +714,9 @@ private function unserializeAttribute(object $zval): void $this->unStr($attr, 'lcname'); $argSize = Core::sizeOfType(zend_attribute_arg::class); $argBase = Core::addressOf($attr->args); - for ($i = 0; $i < $attr->argc; $i++) { + $argc = $this->requireCount((int) $attr->argc, 'attribute argc'); + $this->requireSpan($argBase, $argc * $argSize, 'attribute args'); + for ($i = 0; $i < $argc; $i++) { $arg = Core::pointerAtAddress('zend_attribute_arg *', $argBase + $i * $argSize); $this->unStr($arg, 'name'); $this->unserializeZval($arg->value); @@ -667,11 +771,14 @@ private function unserializeTypeStruct(object $type): void $typeMask = $type->type_mask; if (($typeMask & self::TYPE_LIST_BIT) !== 0) { $listAddress = $this->unPtr($type, 'ptr'); - $list = Core::pointerAtAddress('zend_type_list *', $listAddress); - $typeSize = Core::sizeOfType(zend_type::class); + $this->requireSpan($listAddress, Core::sizeOfType(zend_type_list::class), 'zend_type_list header'); + $list = Core::pointerAtAddress('zend_type_list *', $listAddress); + $typeSize = Core::sizeOfType(zend_type::class); // ZEND_TYPE_LIST_FOREACH: entries start at list->types (the flexible member) $entryBase = $listAddress + Core::sizeOfType(zend_type_list::class) - $typeSize; - for ($i = 0; $i < $list->num_types; $i++) { + $numTypes = $this->requireCount((int) $list->num_types, 'type list num_types'); + $this->requireSpan($entryBase, $numTypes * $typeSize, 'type list entries'); + for ($i = 0; $i < $numTypes; $i++) { $this->unserializeTypeStruct(Core::pointerAtAddress('zend_type *', $entryBase + $i * $typeSize)); } @@ -764,7 +871,9 @@ private function unserializeOpArray(object $opArray): void if ($this->ptrValue($opArray, 'literals') !== 0) { $address = $this->unPtr($opArray, 'literals'); $zvalSize = Core::sizeOfType(zval::class); - for ($i = 0; $i < $opArray->last_literal; $i++) { + $count = $this->requireCount((int) $opArray->last_literal, 'op_array last_literal'); + $this->requireSpan($address, $count * $zvalSize, 'op_array literals'); + for ($i = 0; $i < $count; $i++) { $this->unserializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); } } @@ -784,7 +893,9 @@ private function unserializeOpArray(object $opArray): void if ($opArray->num_dynamic_func_defs !== 0) { // zend_op_array* array: relocate it, then recurse into each nested body $defsAddress = $this->unPtr($opArray, 'dynamic_func_defs'); - for ($i = 0; $i < $opArray->num_dynamic_func_defs; $i++) { + $count = $this->requireCount((int) $opArray->num_dynamic_func_defs, 'num_dynamic_func_defs'); + $this->requireSpan($defsAddress, $count * PHP_INT_SIZE, 'dynamic_func_defs table'); + for ($i = 0; $i < $count; $i++) { $defAddress = $this->unPtrAt($defsAddress + $i * PHP_INT_SIZE); $this->unserializeOpArray(Core::pointerAtAddress('zend_op_array *', $defAddress)); } @@ -871,7 +982,7 @@ private function serializeOpArray(object $opArray): void */ private function argInfoBounds(object $opArray): array { - $count = (int) $opArray->num_args; + $count = $this->requireCount((int) $opArray->num_args, 'op_array num_args'); $start = 0; if (($opArray->fn_flags & self::ZEND_ACC_HAS_RETURN_TYPE) !== 0) { $start = -1; @@ -894,6 +1005,8 @@ private function unserializeArgInfo(object $opArray): void $address = $this->unPtr($opArray, 'arg_info'); $argInfoSize = Core::sizeOfType(zend_arg_info::class); [$start, $end] = $this->argInfoBounds($opArray); + // The array starts at arg_info[start] (start is -1 for a return type) + $this->requireSpan($address + $start * $argInfoSize, ($end - $start) * $argInfoSize, 'op_array arg_info'); for ($i = $start; $i < $end; $i++) { $arg = Core::pointerAtAddress('zend_arg_info *', $address + $i * $argInfoSize); if (!$this->isUnserialized($this->ptrValue($arg, 'name'))) { @@ -932,10 +1045,13 @@ private function unserializeVars(object $opArray): void return; } $address = $this->unPtr($opArray, 'vars'); - for ($i = 0; $i < $opArray->last_var; $i++) { + $count = $this->requireCount((int) $opArray->last_var, 'op_array last_var'); + $this->requireSpan($address, $count * PHP_INT_SIZE, 'op_array vars table'); + for ($i = 0; $i < $count; $i++) { $slot = Core::pointerAtAddress('zend_string **', $address + $i * PHP_INT_SIZE); $view = Core::cast('uintptr_t *', $slot); if (!$this->isUnserialized((int) $view[0]) && (int) $view[0] !== 0) { + $this->requireStringOffset((int) $view[0], 'op_array var name'); if (((int) $view[0] & 1) !== 0) { $view[0] = $this->strSectionBase + ((int) $view[0] & ~1); } else { @@ -1062,6 +1178,8 @@ private function unserializePropertyTable(object $ce, string $field, int $count) } $address = $this->unPtr($ce, $field); $zvalSize = Core::sizeOfType(zval::class); + $count = $this->requireCount($count, "class {$field} count"); + $this->requireSpan($address, $count * $zvalSize, "class {$field}"); for ($i = 0; $i < $count; $i++) { $this->unserializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); } @@ -1091,9 +1209,12 @@ private function unserializePropInfoTable(object $ce): void return; } $address = $this->unPtr($ce, 'properties_info_table'); - for ($i = 0; $i < $ce->default_properties_count; $i++) { + $count = $this->requireCount((int) $ce->default_properties_count, 'default_properties_count'); + $this->requireSpan($address, $count * PHP_INT_SIZE, 'properties_info_table'); + for ($i = 0; $i < $count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); if ((int) $slot[0] !== 0) { + $this->requireOffset((int) $slot[0], 'properties_info_table entry'); $slot[0] = $this->base + (int) $slot[0]; } } @@ -1124,6 +1245,8 @@ private function unserializeClassNames(object $ce, string $field, int $count): v { $address = $this->unPtr($ce, $field); $nameSize = Core::sizeOfType(zend_class_name::class); + $count = $this->requireCount($count, "class {$field} count"); + $this->requireSpan($address, $count * $nameSize, "class {$field}"); for ($i = 0; $i < $count; $i++) { $name = Core::pointerAtAddress('zend_class_name *', $address + $i * $nameSize); $this->unStr($name, 'name'); @@ -1157,12 +1280,15 @@ private function unserializeTraitAliases(object $ce): void } // A NULL-terminated zend_trait_alias* array; each entry's strings follow $slotAddress = $this->unPtr($ce, 'trait_aliases'); + // Bound the terminator scan: each slot read must stay inside the region + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_aliases array'); while (($aliasAddress = $this->unPtrAt($slotAddress)) !== 0) { $alias = Core::pointerAtAddress('zend_trait_alias *', $aliasAddress); $this->unStr($alias->trait_method, 'method_name'); $this->unStr($alias->trait_method, 'class_name'); $this->unStr($alias, 'alias'); $slotAddress += PHP_INT_SIZE; + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_aliases array'); } } /** @@ -1194,15 +1320,19 @@ private function unserializeTraitPrecedences(object $ce): void } // A NULL-terminated zend_trait_precedence* array with inline exclude names $slotAddress = $this->unPtr($ce, 'trait_precedences'); + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_precedences array'); while (($precedenceAddress = $this->unPtrAt($slotAddress)) !== 0) { $precedence = Core::pointerAtAddress('zend_trait_precedence *', $precedenceAddress); $this->unStr($precedence->trait_method, 'method_name'); $this->unStr($precedence->trait_method, 'class_name'); $excludeBase = Core::addressOf($precedence->exclude_class_names); - for ($j = 0; $j < $precedence->num_excludes; $j++) { + $excludes = $this->requireCount((int) $precedence->num_excludes, 'trait precedence num_excludes'); + $this->requireSpan($excludeBase, $excludes * PHP_INT_SIZE, 'trait precedence excludes'); + for ($j = 0; $j < $excludes; $j++) { $this->unStrAt($excludeBase + $j * PHP_INT_SIZE); } $slotAddress += PHP_INT_SIZE; + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_precedences array'); } } /** @@ -1250,6 +1380,7 @@ private function unserializePropInfo(object $zval): void // zend_function*[ZEND_PROPERTY_HOOK_COUNT]: relocate the array, then // each non-NULL hook and its op_array (a shared body returns early) $hooksAddress = $this->unPtr($prop, 'hooks'); + $this->requireSpan($hooksAddress, self::PROPERTY_HOOK_COUNT * PHP_INT_SIZE, 'property hooks array'); for ($i = 0; $i < self::PROPERTY_HOOK_COUNT; $i++) { $hookAddress = $this->unPtrAt($hooksAddress + $i * PHP_INT_SIZE); if ($hookAddress !== 0) { @@ -1409,8 +1540,11 @@ private function unserializeWarnings(object $script): void return; } $address = $this->unPtr($script, 'warnings'); - for ($i = 0; $i < $script->num_warnings; $i++) { - $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); + $count = $this->requireCount((int) $script->num_warnings, 'num_warnings'); + $this->requireSpan($address, $count * PHP_INT_SIZE, 'warnings table'); + for ($i = 0; $i < $count; $i++) { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); + $this->requireOffset((int) $slot[0], 'warning entry'); $slot[0] = $this->base + (int) $slot[0]; $warning = Core::pointerAtAddress('zend_error_info *', (int) $slot[0]); $this->unStr($warning, 'filename'); @@ -1447,7 +1581,9 @@ private function unserializeEarlyBindings(object $script): void } $address = $this->unPtr($script, 'early_bindings'); $bindingSize = Core::sizeOfType(zend_early_binding::class); - for ($i = 0; $i < $script->num_early_bindings; $i++) { + $count = $this->requireCount((int) $script->num_early_bindings, 'num_early_bindings'); + $this->requireSpan($address, $count * $bindingSize, 'early_bindings table'); + for ($i = 0; $i < $count; $i++) { $binding = Core::pointerAtAddress('zend_early_binding *', $address + $i * $bindingSize); $this->unStr($binding, 'lcname'); $this->unStr($binding, 'rtd_key'); diff --git a/tests/OpCache/BoundsValidationTest.php b/tests/OpCache/BoundsValidationTest.php new file mode 100644 index 00000000..c9664057 --- /dev/null +++ b/tests/OpCache/BoundsValidationTest.php @@ -0,0 +1,182 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use FFI; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; + +/** + * Bounds-validation coverage (issue #123): a crafted or truncated binary whose + * stored offsets, counts or element spans escape the declared buffer must be + * refused loudly - never dereferenced. Every stored offset in a .bin is + * attacker-controllable (system_id is a build fingerprint, adler32 is + * forgeable), so an application that feeds an untrusted binary into + * getReflection() must get an exception, not an out-of-bounds engine walk or a + * crash. These tests corrupt a real payload's structural fields and assert the + * loud refusal; they run in the debug container too, where an unguarded + * out-of-bounds read segfaults the loudest. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class BoundsValidationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testTruncatedBufferIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + // The header still claims the full memSize, but the buffer is short: + // scriptOffset now points past the (shrunk) region + $truncated = substr($payload, 0, intdiv(strlen($payload), 2)); + $buffer = $this->bufferOf($truncated, strlen($payload)); + // Shrink the region the relocator believes it has to the real length + $shortMeta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: strlen($truncated), + strSize: 0, + scriptOffset: $meta->scriptOffset(), + timestamp: 0, + checksum: 0, + ); + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/Malformed opcache payload/'); + (new PayloadRelocator($buffer, $shortMeta))->relocate(); + } + + public function testScriptOffsetPastRegionIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $hostileMeta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: $meta->memSize(), + strSize: $meta->strSize(), + scriptOffset: $meta->memSize() + 4096, // well past the region + timestamp: 0, + checksum: 0, + ); + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/scriptOffset|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $hostileMeta))->relocate(); + } + + public function testHostileScriptPointerFieldIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $base = Core::addressOf(Core::addr($buffer)); + + // Corrupt the script's filename offset to a wild value past the region. + // zend_script is the first member of zend_persistent_script, so the + // script offset is a zend_script* - single-hop to keep the field typed. + $script = Core::pointerAtAddress('zend_script *', $base + $meta->scriptOffset()); + $filenameAt = Core::addressOf(FFI::addr($script->filename)); + Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $filenameAt))[0] = $meta->memSize() + 0x4000; + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/string field filename|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $meta))->relocate(); + } + + public function testHostileHashCountIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $base = Core::addressOf(Core::addr($buffer)); + + // Blow up the function table's nNumUsed so the bucket walk would spill + $script = Core::pointerAtAddress('zend_script *', $base + $meta->scriptOffset()); + $functionTableAt = Core::addressOf(FFI::addr($script->function_table)); + $functionTable = Core::pointerAtAddress('HashTable *', $functionTableAt); + $functionTable->nNumUsed = 0x7fffffff; + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/count|span|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $meta))->relocate(); + } + + public function testHostileInternedStringOffsetIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $base = Core::addressOf(Core::addr($buffer)); + + // Tag the script filename as an interned reference far past the (empty) + // string section - a plausible-looking but out-of-range interned offset + $script = Core::pointerAtAddress('zend_script *', $base + $meta->scriptOffset()); + $filenameAt = Core::addressOf(FFI::addr($script->filename)); + Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $filenameAt))[0] = 0x100001; // odd => tagged, offset 0x100000 + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/interned-string offset|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $meta))->relocate(); + } + + public function testValidPayloadStillRelocates(): void + { + // The guard must not reject a well-formed image (no false positives) + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate()); + } + + /** + * @return array{string, CacheMetaInfo} + */ + private function compiledPayload(): array + { + $fixture = self::fixturePath(); + $binPath = self::compileFixture($fixture); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + return [$payload, $meta]; + } + + /** + * @return \FFI\CData a writable char[capacity] holding $payload + */ + private function bufferOf(string $payload, ?int $capacity = null): object + { + $capacity = $capacity ?? strlen($payload); + $buffer = Core::new("char[{$capacity}]", false); + if ($payload !== '') { + Core::memcpy($buffer, $payload, strlen($payload)); + } + + return $buffer; + } +} From 0057a39ce9b4b8c66582e447944d6fed1f8e1ac2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 23:19:40 +0000 Subject: [PATCH 24/28] refactor(opcache): replace path-scoped PHPStan ignores with struct shapes The opcache pointer-surgery files were held at level max only through path-scoped ignore blocks in phpstan.dist.neon (property.nonObject, binaryOp.invalid, cast.int, argument.type, assignOp.invalid) because their multi-hop FFI\CData reads resolve to mixed after the first hop. Narrow those reads to the generated ZEngine\Generated\* engine-struct stubs the rest of the codebase already uses, surfaced through the docblock boundary-narrowing convention (native `object`, `@param`/`@var`/`@return` stub type), so the walkers type-check without any behaviour change. Removed the path-scoped ignore blocks for all four files: - src/OpCache/PayloadRelocator.php (property.nonObject, binaryOp.invalid, cast.int, argument.type) - src/OpCache/ScriptSerializer.php (property.nonObject, binaryOp.invalid, cast.int, argument.type) - src/OpCache/ReflectionOpcacheFile.php (property.nonObject, argument.type, binaryOp.invalid, cast.int, assignOp.invalid) - tests/OpCache/BoundsValidationTest.php (argument.type) How the errors were retired: - pointerAtAddress('T *', ...) string casts -> the stub class-string form (pointerAtAddress(T::class, ...)), which the TypedEntryPointReturnExtension types as the stub while the runtime value stays FFI\CData; - generic walker params typed to the owning stub via @param/@var docblocks (native type stays `object` - the stubs are analysis-only and would raise a runtime TypeError if used as native types); - IS_PTR bucket reads go through the typed zend_value->lval accessor; - raw uintptr_t slot dereferences go through a single readSlot() primitive guarded by assert(is_int(...)), matching Core::threadLocalStorageBase(); - FFI::string/FFI::memcpy sizes stated non-negative with max(...,0), matching HashTable::count()'s analyser clamp; - the trivial unserializeType/serializeType field wrappers inlined to unserializeTypeStruct($x->type) on the now-typed owners. Justified surviving inline @phpstan-ignore argument.type (8 total), each an FFI::addr() on a pointer field that must stay inline to yield the field SLOT address (a by-value hop through Core::addr() addresses a pointer copy - proven by BoundsValidationTest) and cannot be CData-typed because the field name is dynamic: PayloadRelocator ptrValue/writePtrField, ScriptSerializer ptrValue/put/defer, ReflectionOpcacheFile addMethodFrom (scope re-point), and BoundsValidationTest's two filename-slot corruptions. This mirrors the existing Compiler.php precedent for inline FFI::addr ignores. phpstan-baseline.neon is untouched. Full suite is byte-for-byte identical: 536 tests / 5408 assertions / 5 skipped / 5 incomplete before and after, in default, opcache-runner and release --group opcache modes, and in the debug84 container. Fixes #126 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- phpstan.dist.neon | 61 ---- src/OpCache/PayloadRelocator.php | 408 ++++++++++--------------- src/OpCache/ReflectionOpcacheFile.php | 109 ++++--- src/OpCache/ScriptSerializer.php | 174 ++++++----- tests/OpCache/BoundsValidationTest.php | 17 +- 5 files changed, 342 insertions(+), 427 deletions(-) diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 2f682bc0..2e68a4c6 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -68,67 +68,6 @@ parameters: - identifier: offsetAccess.nonOffsetAccessible path: src/Type/StructArray.php - # PayloadRelocator is the file-cache pointer-surgery machinery: it walks - # engine structs field by field through FFI\CData, so a chained access - # (`$zval->u1->v->type`) resolves to `mixed` after the first hop and the - # arithmetic/casts on those reads cannot be statically typed. The FFI - # blast radius is confined to this one audited file (AGENTS.md); every - # path is covered by the byte-identity and execute-from-cache tests. - - - identifier: property.nonObject - path: src/OpCache/PayloadRelocator.php - - - identifier: binaryOp.invalid - path: src/OpCache/PayloadRelocator.php - - - identifier: cast.int - path: src/OpCache/PayloadRelocator.php - - - identifier: argument.type - path: src/OpCache/PayloadRelocator.php - # ScriptSerializer is the second audited pointer-surgery file (the - # persist-from-graph writer, issue #117): the same CData field walking - # as PayloadRelocator, covered by the rebuild/graft execute-from-cache - # tests and the relocator round-trip identity checks. - - - identifier: property.nonObject - path: src/OpCache/ScriptSerializer.php - - - identifier: binaryOp.invalid - path: src/OpCache/ScriptSerializer.php - - - identifier: cast.int - path: src/OpCache/ScriptSerializer.php - - - identifier: argument.type - path: src/OpCache/ScriptSerializer.php - # ReflectionOpcacheFile is the CData facade over the relocated script: - # it reads embedded engine structs (script.filename, function/class - # tables) that resolve to `mixed` after the first CData hop; since - # issue #117 it also carries the graft plumbing (image hashtable - # regrowth), which does the same CData arithmetic. - - - identifier: property.nonObject - path: src/OpCache/ReflectionOpcacheFile.php - - - identifier: argument.type - path: src/OpCache/ReflectionOpcacheFile.php - - - identifier: binaryOp.invalid - path: src/OpCache/ReflectionOpcacheFile.php - - - identifier: cast.int - path: src/OpCache/ReflectionOpcacheFile.php - - - identifier: assignOp.invalid - path: src/OpCache/ReflectionOpcacheFile.php - # BoundsValidationTest crafts hostile payloads by poking engine-struct - # pointer fields through FFI\CData (the address of a filename/HashTable - # slot to overwrite), the same pointer surgery PayloadRelocator does - - # FFI::addr() on a CData field read resolves to mixed here. - - - identifier: argument.type - path: tests/OpCache/BoundsValidationTest.php # The dimension tests exist to prove that a plain `count($object)` reaches the engine's # count_elements handler on a class that never declared the count itself. Rewriting them # as assertCount() would measure PHPUnit's Count constraint instead of the language diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index 74e2d126..2ba51970 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -17,15 +17,28 @@ use FFI\CData; use ZEngine\Core; use ZEngine\Generated\Bucket; +use ZEngine\Generated\HashTable as HashTableStruct; use ZEngine\Generated\zend_arg_info; use ZEngine\Generated\zend_ast; use ZEngine\Generated\zend_ast_list; use ZEngine\Generated\zend_ast_ref; +use ZEngine\Generated\zend_ast_zval; +use ZEngine\Generated\zend_attribute; use ZEngine\Generated\zend_attribute_arg; +use ZEngine\Generated\zend_class_arrayaccess_funcs; +use ZEngine\Generated\zend_class_constant; +use ZEngine\Generated\zend_class_entry; +use ZEngine\Generated\zend_class_iterator_funcs; use ZEngine\Generated\zend_class_name; use ZEngine\Generated\zend_early_binding; +use ZEngine\Generated\zend_error_info; +use ZEngine\Generated\zend_function; +use ZEngine\Generated\zend_op_array; use ZEngine\Generated\zend_persistent_script; +use ZEngine\Generated\zend_property_info; use ZEngine\Generated\zend_string; +use ZEngine\Generated\zend_trait_alias; +use ZEngine\Generated\zend_trait_precedence; use ZEngine\Generated\zend_type; use ZEngine\Generated\zend_type_list; use ZEngine\Generated\zval; @@ -140,7 +153,7 @@ public function __construct(private readonly object $buffer, private readonly Ca * Rewrites the buffer in place, converting every stored offset to a real * address, and returns a typed pointer to the embedded zend_persistent_script. * - * @return \FFI\CData + * @return zend_persistent_script */ public function relocate(): object { @@ -152,7 +165,7 @@ public function relocate(): object Core::sizeOfType(zend_persistent_script::class), 'zend_persistent_script at scriptOffset', ); - $script = Core::pointerAtAddress('zend_persistent_script *', $this->base + $this->metaInfo->scriptOffset()); + $script = Core::pointerAtAddress(zend_persistent_script::class, $this->base + $this->metaInfo->scriptOffset()); $this->unStr($script->script, 'filename'); $this->unserializeHash($script->script->class_table, $this->unserializeClass(...)); @@ -189,7 +202,7 @@ private function serialize(): string $this->strSection = ''; $this->internedXlat = []; $this->sharedOpcodes = []; - $script = Core::pointerAtAddress('zend_persistent_script *', $this->base + $this->metaInfo->scriptOffset()); + $script = Core::pointerAtAddress(zend_persistent_script::class, $this->base + $this->metaInfo->scriptOffset()); $this->serStr($script->script, 'filename'); $this->serializeHash($script->script->class_table, $this->serializeClass(...)); @@ -198,7 +211,8 @@ private function serialize(): string $this->serializeWarnings($script); $this->serializeEarlyBindings($script); - $memRegion = FFI::string($this->buffer, $this->size); + // max(...,0) only states the non-negative mem-region size to the analyser + $memRegion = FFI::string($this->buffer, max($this->size, 0)); // Re-pin the tagged-offset bound to the section just emitted, so the // relocate() in derelocate() validates against it (issue #123) $this->strSize = strlen($this->strSection); @@ -208,12 +222,25 @@ private function serialize(): string // --- pointer/offset primitives (SERIALIZE_PTR / UNSERIALIZE_PTR) -------- + /** + * Reads a uintptr_t pointer slot as a PHP int - the raw-pointer read + * primitive. The dereferenced CData element is always an integer at runtime; + * the guard states that to the analyser without widening any real value. + * + * @param \FFI\CData $slot a uintptr_t* view over the slot to read + */ + private function readSlot(object $slot): int + { + $value = $slot[0]; + \assert(\is_int($value)); + + return $value; + } + /** * Reads a pointer field's stored value through a raw integer view of its * storage, so it works for every pointee type including void* (which * Core::addressOf cannot cast). 0 when the C NULL surfaces as PHP null. - * - * @param \FFI\CData $owner */ private function ptrValue(object $owner, string $field): int { @@ -221,15 +248,16 @@ private function ptrValue(object $owner, string $field): int return 0; } - return (int) Core::cast('uintptr_t *', FFI::addr($owner->$field))[0]; + // A dynamically-named pointer field cannot be statically resolved, so + // FFI::addr() on the mixed field read is the one irreducible CData hop. + // @phpstan-ignore argument.type (FFI::addr of a dynamic FFI\CData pointer field) + return $this->readSlot(Core::cast('uintptr_t *', FFI::addr($owner->$field))); } - /** - * @param \FFI\CData $owner - */ private function writePtrField(object $owner, string $field, int $address): void { - // Only ever called for a currently non-null field, so FFI::addr is safe + // Only ever called for a currently non-null field, so FFI::addr is safe. + // @phpstan-ignore argument.type (FFI::addr of a dynamic FFI\CData pointer field) $slot = Core::cast('uintptr_t *', FFI::addr($owner->$field)); $slot[0] = $address; } @@ -319,9 +347,6 @@ private function requireCount(int $count, string $what): int } /** UNSERIALIZE_PTR on a struct field, returning the resolved address (0 if null) */ - /** - * @param \FFI\CData $owner - */ private function unPtr(object $owner, string $field): int { $stored = $this->ptrValue($owner, $field); @@ -336,9 +361,6 @@ private function unPtr(object $owner, string $field): int } /** SERIALIZE_PTR on a struct field, returning the pre-serialization address (0 if null) */ - /** - * @param \FFI\CData $owner - */ private function serPtr(object $owner, string $field): int { $address = $this->ptrValue($owner, $field); @@ -354,7 +376,7 @@ private function serPtr(object $owner, string $field): int private function unPtrAt(int $slotAddress): int { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); - $stored = (int) $slot[0]; + $stored = $this->readSlot($slot); if ($stored === 0) { return 0; } @@ -368,7 +390,7 @@ private function unPtrAt(int $slotAddress): int private function serPtrAt(int $slotAddress): int { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); - $address = (int) $slot[0]; + $address = $this->readSlot($slot); if ($address === 0) { return 0; } @@ -378,9 +400,6 @@ private function serPtrAt(int $slotAddress): int } // --- interned-string primitives (UNSERIALIZE_STR / SERIALIZE_STR) ------ - /** - * @param \FFI\CData $owner - */ private function unStr(object $owner, string $field): void { @@ -398,9 +417,6 @@ private function unStr(object $owner, string $field): void // GC flag normalization is deliberately skipped (see class docblock) $this->writePtrField($owner, $field, $address); } - /** - * @param \FFI\CData $owner - */ private function serStr(object $owner, string $field): void { @@ -421,7 +437,7 @@ private function serStr(object $owner, string $field): void private function unStrAt(int $slotAddress): void { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); - $stored = (int) $slot[0]; + $stored = $this->readSlot($slot); if ($stored === 0) { return; } @@ -437,7 +453,7 @@ private function unStrAt(int $slotAddress): void private function serStrAt(int $slotAddress): void { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); - $address = (int) $slot[0]; + $address = $this->readSlot($slot); if ($address === 0) { return; } @@ -457,24 +473,23 @@ private function emitInterned(int $address): int if (isset($this->internedXlat[$address])) { return $this->internedXlat[$address]; } - $stringPointer = Core::pointerAtAddress('zend_string *', $address); + $stringPointer = Core::pointerAtAddress(zend_string::class, $address); $length = $stringPointer->len; $structSize = Core::getAlignedSize($this->zendStringHeaderSize + $length + 1); $tagged = strlen($this->strSection) | 1; $this->internedXlat[$address] = $tagged; - $this->strSection .= FFI::string(Core::cast('char *', $stringPointer), $structSize); + // max(...,0) only states the non-negative aligned size to the analyser + $this->strSection .= FFI::string(Core::cast('char *', $stringPointer), max($structSize, 0)); return $tagged; } // --- hashes (zend_file_cache_(un)serialize_hash) ----------------------- - /** - * @param \FFI\CData $ht - */ private function unserializeHash(object $ht, callable $each): void { + /** @var HashTableStruct $ht Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if (($ht->u->flags & self::HASH_FLAG_UNINITIALIZED) !== 0) { return; } @@ -487,7 +502,7 @@ private function unserializeHash(object $ht, callable $each): void $zvalSize = Core::sizeOfType(zval::class); $this->requireSpan($dataAddress, $used * $zvalSize, 'packed hashtable data'); for ($i = 0; $i < $used; $i++) { - $zval = Core::pointerAtAddress('zval *', $dataAddress + $i * $zvalSize); + $zval = Core::pointerAtAddress(zval::class, $dataAddress + $i * $zvalSize); if ($zval->u1->v->type !== 0) { $each($zval); } @@ -498,19 +513,17 @@ private function unserializeHash(object $ht, callable $each): void $bucketSize = Core::sizeOfType(Bucket::class); $this->requireSpan($dataAddress, $used * $bucketSize, 'hashtable bucket data'); for ($i = 0; $i < $used; $i++) { - $bucket = Core::pointerAtAddress('Bucket *', $dataAddress + $i * $bucketSize); + $bucket = Core::pointerAtAddress(Bucket::class, $dataAddress + $i * $bucketSize); if ($bucket->val->u1->v->type !== 0) { $this->unStr($bucket, 'key'); $each($bucket->val); } } } - /** - * @param \FFI\CData $ht - */ private function serializeHash(object $ht, callable $each): void { + /** @var HashTableStruct $ht Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if (($ht->u->flags & self::HASH_FLAG_UNINITIALIZED) !== 0) { $this->writePtrField($ht, 'arData', 0); @@ -524,7 +537,7 @@ private function serializeHash(object $ht, callable $each): void if (($ht->u->flags & self::HASH_FLAG_PACKED) !== 0) { $zvalSize = Core::sizeOfType(zval::class); for ($i = 0; $i < $used; $i++) { - $zval = Core::pointerAtAddress('zval *', $dataAddress + $i * $zvalSize); + $zval = Core::pointerAtAddress(zval::class, $dataAddress + $i * $zvalSize); if ($zval->u1->v->type !== 0) { $each($zval); } @@ -534,7 +547,7 @@ private function serializeHash(object $ht, callable $each): void } $bucketSize = Core::sizeOfType(Bucket::class); for ($i = 0; $i < $used; $i++) { - $bucket = Core::pointerAtAddress('Bucket *', $dataAddress + $i * $bucketSize); + $bucket = Core::pointerAtAddress(Bucket::class, $dataAddress + $i * $bucketSize); if ($bucket->val->u1->v->type !== 0) { $this->serStr($bucket, 'key'); $each($bucket->val); @@ -543,12 +556,10 @@ private function serializeHash(object $ht, callable $each): void } // --- zvals ------------------------------------------------------------- - /** - * @param \FFI\CData $zval - */ private function unserializeZval(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ switch ($zval->u1->v->type) { case self::IS_STRING: $stored = $this->ptrValue($zval->value, 'str'); @@ -560,7 +571,7 @@ private function unserializeZval(object $zval): void if (!$this->isUnserialized($this->ptrValue($zval->value, 'arr'))) { $arrAddress = $this->unPtr($zval->value, 'arr'); $this->unserializeHash( - Core::pointerAtAddress('zend_array *', $arrAddress), + Core::pointerAtAddress(HashTableStruct::class, $arrAddress), $this->unserializeZval(...), ); } @@ -576,12 +587,10 @@ private function unserializeZval(object $zval): void break; } } - /** - * @param \FFI\CData $zval - */ private function serializeZval(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ switch ($zval->u1->v->type) { case self::IS_STRING: if (!$this->isSerialized($this->ptrValue($zval->value, 'str'))) { @@ -592,7 +601,7 @@ private function serializeZval(object $zval): void if (!$this->isSerialized($this->ptrValue($zval->value, 'arr'))) { $arrAddress = $this->serPtr($zval->value, 'arr'); $this->serializeHash( - Core::pointerAtAddress('zend_array *', $arrAddress), + Core::pointerAtAddress(HashTableStruct::class, $arrAddress), $this->serializeZval(...), ); } @@ -616,15 +625,15 @@ private function unserializeAst(int $astAddress): void { // The node header (kind + attr) must fit before it is read $this->requireSpan($astAddress, Core::sizeOfType(zend_ast::class), 'zend_ast node'); - $ast = Core::pointerAtAddress('zend_ast *', $astAddress); + $ast = Core::pointerAtAddress(zend_ast::class, $astAddress); $kind = $ast->kind; if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { - $this->unserializeZval(Core::pointerAtAddress('zend_ast_zval *', $astAddress)->val); + $this->unserializeZval(Core::pointerAtAddress(zend_ast_zval::class, $astAddress)->val); return; } if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { - $list = Core::pointerAtAddress('zend_ast_list *', $astAddress); + $list = Core::pointerAtAddress(zend_ast_list::class, $astAddress); $childBase = $astAddress + Core::sizeOfType(zend_ast_list::class) - PHP_INT_SIZE; $count = $this->requireCount((int) $list->children, 'ast list children'); } else { @@ -634,7 +643,7 @@ private function unserializeAst(int $astAddress): void $this->requireSpan($childBase, $count * PHP_INT_SIZE, 'ast children slots'); for ($i = 0; $i < $count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $childBase + $i * PHP_INT_SIZE)); - $child = (int) $slot[0]; + $child = $this->readSlot($slot); if ($child !== 0 && !$this->isUnserialized($child)) { $this->requireOffset($child, 'ast child'); $slot[0] = $this->base + $child; @@ -645,15 +654,15 @@ private function unserializeAst(int $astAddress): void private function serializeAst(int $astAddress): void { - $ast = Core::pointerAtAddress('zend_ast *', $astAddress); + $ast = Core::pointerAtAddress(zend_ast::class, $astAddress); $kind = $ast->kind; if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { - $this->serializeZval(Core::pointerAtAddress('zend_ast_zval *', $astAddress)->val); + $this->serializeZval(Core::pointerAtAddress(zend_ast_zval::class, $astAddress)->val); return; } if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { - $list = Core::pointerAtAddress('zend_ast_list *', $astAddress); + $list = Core::pointerAtAddress(zend_ast_list::class, $astAddress); $childBase = $astAddress + Core::sizeOfType(zend_ast_list::class) - PHP_INT_SIZE; $count = $list->children; } else { @@ -662,7 +671,7 @@ private function serializeAst(int $astAddress): void } for ($i = 0; $i < $count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $childBase + $i * PHP_INT_SIZE)); - $child = (int) $slot[0]; + $child = $this->readSlot($slot); if ($child !== 0 && !$this->isSerialized($child)) { $slot[0] = $child - $this->base; $this->serializeAst($child); @@ -671,9 +680,6 @@ private function serializeAst(int $astAddress): void } // --- attributes -------------------------------------------------------- - /** - * @param \FFI\CData $owner - */ private function unserializeAttributes(object $owner, string $field): void { @@ -683,13 +689,10 @@ private function unserializeAttributes(object $owner, string $field): void } $htAddress = $this->unPtr($owner, $field); $this->unserializeHash( - Core::pointerAtAddress('HashTable *', $htAddress), + Core::pointerAtAddress(HashTableStruct::class, $htAddress), $this->unserializeAttribute(...), ); } - /** - * @param \FFI\CData $owner - */ private function serializeAttributes(object $owner, string $field): void { @@ -699,17 +702,15 @@ private function serializeAttributes(object $owner, string $field): void } $htAddress = $this->serPtr($owner, $field); $this->serializeHash( - Core::pointerAtAddress('HashTable *', $htAddress), + Core::pointerAtAddress(HashTableStruct::class, $htAddress), $this->serializeAttribute(...), ); } - /** - * @param \FFI\CData $zval - */ private function unserializeAttribute(object $zval): void { - $attr = Core::pointerAtAddress('zend_attribute *', $this->unPtr($zval->value, 'ptr')); + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $attr = Core::pointerAtAddress(zend_attribute::class, $this->unPtr($zval->value, 'ptr')); $this->unStr($attr, 'name'); $this->unStr($attr, 'lcname'); $argSize = Core::sizeOfType(zend_attribute_arg::class); @@ -717,69 +718,52 @@ private function unserializeAttribute(object $zval): void $argc = $this->requireCount((int) $attr->argc, 'attribute argc'); $this->requireSpan($argBase, $argc * $argSize, 'attribute args'); for ($i = 0; $i < $argc; $i++) { - $arg = Core::pointerAtAddress('zend_attribute_arg *', $argBase + $i * $argSize); + $arg = Core::pointerAtAddress(zend_attribute_arg::class, $argBase + $i * $argSize); $this->unStr($arg, 'name'); $this->unserializeZval($arg->value); } } - /** - * @param \FFI\CData $zval - */ private function serializeAttribute(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ $address = $this->serPtr($zval->value, 'ptr'); - $attr = Core::pointerAtAddress('zend_attribute *', $address); + $attr = Core::pointerAtAddress(zend_attribute::class, $address); $this->serStr($attr, 'name'); $this->serStr($attr, 'lcname'); $argSize = Core::sizeOfType(zend_attribute_arg::class); $argBase = Core::addressOf($attr->args); for ($i = 0; $i < $attr->argc; $i++) { - $arg = Core::pointerAtAddress('zend_attribute_arg *', $argBase + $i * $argSize); + $arg = Core::pointerAtAddress(zend_attribute_arg::class, $argBase + $i * $argSize); $this->serStr($arg, 'name'); $this->serializeZval($arg->value); } } // --- types (zend_type name/list) --------------------------------------- - /** - * @param \FFI\CData $owner - */ - - private function unserializeType(object $owner, string $field): void - { - $this->unserializeTypeStruct($owner->$field); - } - /** - * @param \FFI\CData $owner - */ - - private function serializeType(object $owner, string $field): void - { - $this->serializeTypeStruct($owner->$field); - } /** * One zend_type in place - the ZEND_TYPE_HAS_LIST branch of * zend_file_cache_unserialize_type relocates the zend_type_list pointer and * recurses into every entry, so DNF sub-lists like (A&B)|C unfold naturally. * - * @param \FFI\CData $type a zend_type view (embedded field or list entry) + * @param zend_type $type a zend_type view (embedded field or list entry) */ private function unserializeTypeStruct(object $type): void { + /** @var zend_type $type Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ $typeMask = $type->type_mask; if (($typeMask & self::TYPE_LIST_BIT) !== 0) { $listAddress = $this->unPtr($type, 'ptr'); $this->requireSpan($listAddress, Core::sizeOfType(zend_type_list::class), 'zend_type_list header'); - $list = Core::pointerAtAddress('zend_type_list *', $listAddress); + $list = Core::pointerAtAddress(zend_type_list::class, $listAddress); $typeSize = Core::sizeOfType(zend_type::class); // ZEND_TYPE_LIST_FOREACH: entries start at list->types (the flexible member) $entryBase = $listAddress + Core::sizeOfType(zend_type_list::class) - $typeSize; $numTypes = $this->requireCount((int) $list->num_types, 'type list num_types'); $this->requireSpan($entryBase, $numTypes * $typeSize, 'type list entries'); for ($i = 0; $i < $numTypes; $i++) { - $this->unserializeTypeStruct(Core::pointerAtAddress('zend_type *', $entryBase + $i * $typeSize)); + $this->unserializeTypeStruct(Core::pointerAtAddress(zend_type::class, $entryBase + $i * $typeSize)); } return; @@ -794,18 +778,19 @@ private function unserializeTypeStruct(object $type): void * stores the list pointer as an offset but keeps walking the entries through * the still-real address (its SERIALIZE_PTR/UNSERIALIZE_PTR pair). * - * @param \FFI\CData $type a zend_type view (embedded field or list entry) + * @param zend_type $type a zend_type view (embedded field or list entry) */ private function serializeTypeStruct(object $type): void { + /** @var zend_type $type Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ $typeMask = $type->type_mask; if (($typeMask & self::TYPE_LIST_BIT) !== 0) { $listAddress = $this->serPtr($type, 'ptr'); - $list = Core::pointerAtAddress('zend_type_list *', $listAddress); + $list = Core::pointerAtAddress(zend_type_list::class, $listAddress); $typeSize = Core::sizeOfType(zend_type::class); $entryBase = $listAddress + Core::sizeOfType(zend_type_list::class) - $typeSize; for ($i = 0; $i < $list->num_types; $i++) { - $this->serializeTypeStruct(Core::pointerAtAddress('zend_type *', $entryBase + $i * $typeSize)); + $this->serializeTypeStruct(Core::pointerAtAddress(zend_type::class, $entryBase + $i * $typeSize)); } return; @@ -816,30 +801,24 @@ private function serializeTypeStruct(object $type): void } // --- op_array (the executable body) ------------------------------------ - /** - * @param \FFI\CData $zval - */ private function unserializeFunc(object $zval): void { - $func = Core::pointerAtAddress('zend_function *', $this->unPtr($zval->value, 'func')); + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $func = Core::pointerAtAddress(zend_function::class, $this->unPtr($zval->value, 'func')); $this->unserializeOpArray($func->op_array); } - /** - * @param \FFI\CData $zval - */ private function serializeFunc(object $zval): void { - $func = Core::pointerAtAddress('zend_function *', $this->serPtr($zval->value, 'func')); + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $func = Core::pointerAtAddress(zend_function::class, $this->serPtr($zval->value, 'func')); $this->serializeOpArray($func->op_array); } - /** - * @param \FFI\CData $opArray - */ private function unserializeOpArray(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ // ZEND_MAP_PTR / run-time cache normalization is skipped (never executed here) if ($this->isUnserialized($this->ptrValue($opArray, 'opcodes'))) { return; // shared method body already relocated @@ -866,7 +845,7 @@ private function unserializeOpArray(object $opArray): void if ($this->ptrValue($opArray, 'static_variables') !== 0) { $address = $this->unPtr($opArray, 'static_variables'); - $this->unserializeHash(Core::pointerAtAddress('zend_array *', $address), $this->unserializeZval(...)); + $this->unserializeHash(Core::pointerAtAddress(HashTableStruct::class, $address), $this->unserializeZval(...)); } if ($this->ptrValue($opArray, 'literals') !== 0) { $address = $this->unPtr($opArray, 'literals'); @@ -874,7 +853,7 @@ private function unserializeOpArray(object $opArray): void $count = $this->requireCount((int) $opArray->last_literal, 'op_array last_literal'); $this->requireSpan($address, $count * $zvalSize, 'op_array literals'); for ($i = 0; $i < $count; $i++) { - $this->unserializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); + $this->unserializeZval(Core::pointerAtAddress(zval::class, $address + $i * $zvalSize)); } } // opcodes: only the array pointer is relocated. Per-opline operands are @@ -897,7 +876,7 @@ private function unserializeOpArray(object $opArray): void $this->requireSpan($defsAddress, $count * PHP_INT_SIZE, 'dynamic_func_defs table'); for ($i = 0; $i < $count; $i++) { $defAddress = $this->unPtrAt($defsAddress + $i * PHP_INT_SIZE); - $this->unserializeOpArray(Core::pointerAtAddress('zend_op_array *', $defAddress)); + $this->unserializeOpArray(Core::pointerAtAddress(zend_op_array::class, $defAddress)); } } $this->unStr($opArray, 'function_name'); @@ -909,12 +888,10 @@ private function unserializeOpArray(object $opArray): void $this->unPtr($opArray, 'prototype'); $this->unPtr($opArray, 'prop_info'); } - /** - * @param \FFI\CData $opArray - */ private function serializeOpArray(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->isSerialized($this->ptrValue($opArray, 'opcodes'))) { return; } @@ -944,13 +921,13 @@ private function serializeOpArray(object $opArray): void if ($this->ptrValue($opArray, 'static_variables') !== 0) { $address = $this->serPtr($opArray, 'static_variables'); - $this->serializeHash(Core::pointerAtAddress('zend_array *', $address), $this->serializeZval(...)); + $this->serializeHash(Core::pointerAtAddress(HashTableStruct::class, $address), $this->serializeZval(...)); } if ($this->ptrValue($opArray, 'literals') !== 0) { $address = $this->serPtr($opArray, 'literals'); $zvalSize = Core::sizeOfType(zval::class); for ($i = 0; $i < $opArray->last_literal; $i++) { - $this->serializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); + $this->serializeZval(Core::pointerAtAddress(zval::class, $address + $i * $zvalSize)); } } $this->serPtr($opArray, 'opcodes'); @@ -962,7 +939,7 @@ private function serializeOpArray(object $opArray): void $defsAddress = $this->serPtr($opArray, 'dynamic_func_defs'); for ($i = 0; $i < $opArray->num_dynamic_func_defs; $i++) { $defAddress = $this->serPtrAt($defsAddress + $i * PHP_INT_SIZE); - $this->serializeOpArray(Core::pointerAtAddress('zend_op_array *', $defAddress)); + $this->serializeOpArray(Core::pointerAtAddress(zend_op_array::class, $defAddress)); } } $this->serStr($opArray, 'function_name'); @@ -978,10 +955,10 @@ private function serializeOpArray(object $opArray): void /** * @return array{int, int} [start index, end index) for the arg_info walk - * @param \FFI\CData $opArray */ private function argInfoBounds(object $opArray): array { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ $count = $this->requireCount((int) $opArray->num_args, 'op_array num_args'); $start = 0; if (($opArray->fn_flags & self::ZEND_ACC_HAS_RETURN_TYPE) !== 0) { @@ -993,12 +970,10 @@ private function argInfoBounds(object $opArray): array return [$start, $count]; } - /** - * @param \FFI\CData $opArray - */ private function unserializeArgInfo(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($opArray, 'arg_info') === 0) { return; } @@ -1008,19 +983,17 @@ private function unserializeArgInfo(object $opArray): void // The array starts at arg_info[start] (start is -1 for a return type) $this->requireSpan($address + $start * $argInfoSize, ($end - $start) * $argInfoSize, 'op_array arg_info'); for ($i = $start; $i < $end; $i++) { - $arg = Core::pointerAtAddress('zend_arg_info *', $address + $i * $argInfoSize); + $arg = Core::pointerAtAddress(zend_arg_info::class, $address + $i * $argInfoSize); if (!$this->isUnserialized($this->ptrValue($arg, 'name'))) { $this->unStr($arg, 'name'); } - $this->unserializeType($arg, 'type'); + $this->unserializeTypeStruct($arg->type); } } - /** - * @param \FFI\CData $opArray - */ private function serializeArgInfo(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($opArray, 'arg_info') === 0) { return; } @@ -1028,19 +1001,17 @@ private function serializeArgInfo(object $opArray): void $argInfoSize = Core::sizeOfType(zend_arg_info::class); [$start, $end] = $this->argInfoBounds($opArray); for ($i = $start; $i < $end; $i++) { - $arg = Core::pointerAtAddress('zend_arg_info *', $address + $i * $argInfoSize); + $arg = Core::pointerAtAddress(zend_arg_info::class, $address + $i * $argInfoSize); if (!$this->isSerialized($this->ptrValue($arg, 'name'))) { $this->serStr($arg, 'name'); } - $this->serializeType($arg, 'type'); + $this->serializeTypeStruct($arg->type); } } - /** - * @param \FFI\CData $opArray - */ private function unserializeVars(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($opArray, 'vars') === 0) { return; } @@ -1050,22 +1021,20 @@ private function unserializeVars(object $opArray): void for ($i = 0; $i < $count; $i++) { $slot = Core::pointerAtAddress('zend_string **', $address + $i * PHP_INT_SIZE); $view = Core::cast('uintptr_t *', $slot); - if (!$this->isUnserialized((int) $view[0]) && (int) $view[0] !== 0) { - $this->requireStringOffset((int) $view[0], 'op_array var name'); - if (((int) $view[0] & 1) !== 0) { - $view[0] = $this->strSectionBase + ((int) $view[0] & ~1); + if (!$this->isUnserialized($this->readSlot($view)) && $this->readSlot($view) !== 0) { + $this->requireStringOffset($this->readSlot($view), 'op_array var name'); + if (($this->readSlot($view) & 1) !== 0) { + $view[0] = $this->strSectionBase + ($this->readSlot($view) & ~1); } else { - $view[0] = $this->base + (int) $view[0]; + $view[0] = $this->base + $this->readSlot($view); } } } } - /** - * @param \FFI\CData $opArray - */ private function serializeVars(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($opArray, 'vars') === 0) { return; } @@ -1073,7 +1042,7 @@ private function serializeVars(object $opArray): void for ($i = 0; $i < $opArray->last_var; $i++) { $slot = Core::pointerAtAddress('zend_string **', $address + $i * PHP_INT_SIZE); $view = Core::cast('uintptr_t *', $slot); - $stored = (int) $view[0]; + $stored = $this->readSlot($view); if ($stored === 0 || $this->isSerialized($stored)) { continue; } @@ -1086,13 +1055,11 @@ private function serializeVars(object $opArray): void } // --- classes ----------------------------------------------------------- - /** - * @param \FFI\CData $zval - */ private function unserializeClass(object $zval): void { - $ce = Core::pointerAtAddress('zend_class_entry *', $this->unPtr($zval->value, 'ce')); + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $ce = Core::pointerAtAddress(zend_class_entry::class, $this->unPtr($zval->value, 'ce')); $this->unStr($ce, 'name'); if ($this->ptrValue($ce, 'parent') !== 0) { if (($ce->ce_flags & self::ZEND_ACC_LINKED) === 0) { @@ -1124,13 +1091,11 @@ private function unserializeClass(object $zval): void $this->unserializeIteratorFuncs($ce); // MAP_PTR / default_object_handlers / get_iterator are execution-only (skipped) } - /** - * @param \FFI\CData $zval - */ private function serializeClass(object $zval): void { - $ce = Core::pointerAtAddress('zend_class_entry *', $this->serPtr($zval->value, 'ce')); + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $ce = Core::pointerAtAddress(zend_class_entry::class, $this->serPtr($zval->value, 'ce')); $this->serStr($ce, 'name'); if ($this->ptrValue($ce, 'parent') !== 0) { if (($ce->ce_flags & self::ZEND_ACC_LINKED) === 0) { @@ -1167,12 +1132,10 @@ private function serializeClass(object $zval): void '__serialize', '__unserialize', '__isset', '__unset', '__tostring', '__callstatic', '__debugInfo', ]; - /** - * @param \FFI\CData $ce - */ private function unserializePropertyTable(object $ce, string $field, int $count): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, $field) === 0) { return; } @@ -1181,30 +1144,26 @@ private function unserializePropertyTable(object $ce, string $field, int $count) $count = $this->requireCount($count, "class {$field} count"); $this->requireSpan($address, $count * $zvalSize, "class {$field}"); for ($i = 0; $i < $count; $i++) { - $this->unserializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); + $this->unserializeZval(Core::pointerAtAddress(zval::class, $address + $i * $zvalSize)); } } - /** - * @param \FFI\CData $ce - */ private function serializePropertyTable(object $ce, string $field, int $count): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, $field) === 0) { return; } $address = $this->serPtr($ce, $field); $zvalSize = Core::sizeOfType(zval::class); for ($i = 0; $i < $count; $i++) { - $this->serializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); + $this->serializeZval(Core::pointerAtAddress(zval::class, $address + $i * $zvalSize)); } } - /** - * @param \FFI\CData $ce - */ private function unserializePropInfoTable(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, 'properties_info_table') === 0) { return; } @@ -1213,68 +1172,60 @@ private function unserializePropInfoTable(object $ce): void $this->requireSpan($address, $count * PHP_INT_SIZE, 'properties_info_table'); for ($i = 0; $i < $count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); - if ((int) $slot[0] !== 0) { - $this->requireOffset((int) $slot[0], 'properties_info_table entry'); - $slot[0] = $this->base + (int) $slot[0]; + if ($this->readSlot($slot) !== 0) { + $this->requireOffset($this->readSlot($slot), 'properties_info_table entry'); + $slot[0] = $this->base + $this->readSlot($slot); } } } - /** - * @param \FFI\CData $ce - */ private function serializePropInfoTable(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, 'properties_info_table') === 0) { return; } $address = $this->serPtr($ce, 'properties_info_table'); for ($i = 0; $i < $ce->default_properties_count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); - $stored = (int) $slot[0]; + $stored = $this->readSlot($slot); if ($stored !== 0) { $slot[0] = $stored - $this->base; } } } - /** - * @param \FFI\CData $ce - */ private function unserializeClassNames(object $ce, string $field, int $count): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ $address = $this->unPtr($ce, $field); $nameSize = Core::sizeOfType(zend_class_name::class); $count = $this->requireCount($count, "class {$field} count"); $this->requireSpan($address, $count * $nameSize, "class {$field}"); for ($i = 0; $i < $count; $i++) { - $name = Core::pointerAtAddress('zend_class_name *', $address + $i * $nameSize); + $name = Core::pointerAtAddress(zend_class_name::class, $address + $i * $nameSize); $this->unStr($name, 'name'); $this->unStr($name, 'lc_name'); } } - /** - * @param \FFI\CData $ce - */ private function serializeClassNames(object $ce, string $field, int $count): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ $address = $this->serPtr($ce, $field); $nameSize = Core::sizeOfType(zend_class_name::class); for ($i = 0; $i < $count; $i++) { - $name = Core::pointerAtAddress('zend_class_name *', $address + $i * $nameSize); + $name = Core::pointerAtAddress(zend_class_name::class, $address + $i * $nameSize); $this->serStr($name, 'name'); $this->serStr($name, 'lc_name'); } } // --- traits (the num_traits branch of zend_file_cache_(un)serialize_class) - /** - * @param \FFI\CData $ce - */ private function unserializeTraitAliases(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, 'trait_aliases') === 0) { return; } @@ -1283,7 +1234,7 @@ private function unserializeTraitAliases(object $ce): void // Bound the terminator scan: each slot read must stay inside the region $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_aliases array'); while (($aliasAddress = $this->unPtrAt($slotAddress)) !== 0) { - $alias = Core::pointerAtAddress('zend_trait_alias *', $aliasAddress); + $alias = Core::pointerAtAddress(zend_trait_alias::class, $aliasAddress); $this->unStr($alias->trait_method, 'method_name'); $this->unStr($alias->trait_method, 'class_name'); $this->unStr($alias, 'alias'); @@ -1291,30 +1242,26 @@ private function unserializeTraitAliases(object $ce): void $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_aliases array'); } } - /** - * @param \FFI\CData $ce - */ private function serializeTraitAliases(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, 'trait_aliases') === 0) { return; } $slotAddress = $this->serPtr($ce, 'trait_aliases'); while (($aliasAddress = $this->serPtrAt($slotAddress)) !== 0) { - $alias = Core::pointerAtAddress('zend_trait_alias *', $aliasAddress); + $alias = Core::pointerAtAddress(zend_trait_alias::class, $aliasAddress); $this->serStr($alias->trait_method, 'method_name'); $this->serStr($alias->trait_method, 'class_name'); $this->serStr($alias, 'alias'); $slotAddress += PHP_INT_SIZE; } } - /** - * @param \FFI\CData $ce - */ private function unserializeTraitPrecedences(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, 'trait_precedences') === 0) { return; } @@ -1322,7 +1269,7 @@ private function unserializeTraitPrecedences(object $ce): void $slotAddress = $this->unPtr($ce, 'trait_precedences'); $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_precedences array'); while (($precedenceAddress = $this->unPtrAt($slotAddress)) !== 0) { - $precedence = Core::pointerAtAddress('zend_trait_precedence *', $precedenceAddress); + $precedence = Core::pointerAtAddress(zend_trait_precedence::class, $precedenceAddress); $this->unStr($precedence->trait_method, 'method_name'); $this->unStr($precedence->trait_method, 'class_name'); $excludeBase = Core::addressOf($precedence->exclude_class_names); @@ -1335,18 +1282,16 @@ private function unserializeTraitPrecedences(object $ce): void $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_precedences array'); } } - /** - * @param \FFI\CData $ce - */ private function serializeTraitPrecedences(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, 'trait_precedences') === 0) { return; } $slotAddress = $this->serPtr($ce, 'trait_precedences'); while (($precedenceAddress = $this->serPtrAt($slotAddress)) !== 0) { - $precedence = Core::pointerAtAddress('zend_trait_precedence *', $precedenceAddress); + $precedence = Core::pointerAtAddress(zend_trait_precedence::class, $precedenceAddress); $this->serStr($precedence->trait_method, 'method_name'); $this->serStr($precedence->trait_method, 'class_name'); $excludeBase = Core::addressOf($precedence->exclude_class_names); @@ -1356,16 +1301,14 @@ private function serializeTraitPrecedences(object $ce): void $slotAddress += PHP_INT_SIZE; } } - /** - * @param \FFI\CData $zval - */ private function unserializePropInfo(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->isUnserialized($this->ptrValue($zval->value, 'ptr'))) { return; } - $prop = Core::pointerAtAddress('zend_property_info *', $this->unPtr($zval->value, 'ptr')); + $prop = Core::pointerAtAddress(zend_property_info::class, $this->unPtr($zval->value, 'ptr')); if ($this->isUnserialized($this->ptrValue($prop, 'ce'))) { return; } @@ -1384,22 +1327,20 @@ private function unserializePropInfo(object $zval): void for ($i = 0; $i < self::PROPERTY_HOOK_COUNT; $i++) { $hookAddress = $this->unPtrAt($hooksAddress + $i * PHP_INT_SIZE); if ($hookAddress !== 0) { - $this->unserializeOpArray(Core::pointerAtAddress('zend_function *', $hookAddress)->op_array); + $this->unserializeOpArray(Core::pointerAtAddress(zend_function::class, $hookAddress)->op_array); } } } - $this->unserializeType($prop, 'type'); + $this->unserializeTypeStruct($prop->type); } - /** - * @param \FFI\CData $zval - */ private function serializePropInfo(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->isSerialized($this->ptrValue($zval->value, 'ptr'))) { return; } - $prop = Core::pointerAtAddress('zend_property_info *', $this->serPtr($zval->value, 'ptr')); + $prop = Core::pointerAtAddress(zend_property_info::class, $this->serPtr($zval->value, 'ptr')); if ($this->isSerialized($this->ptrValue($prop, 'ce'))) { return; } @@ -1417,22 +1358,20 @@ private function serializePropInfo(object $zval): void for ($i = 0; $i < self::PROPERTY_HOOK_COUNT; $i++) { $hookAddress = $this->serPtrAt($hooksAddress + $i * PHP_INT_SIZE); if ($hookAddress !== 0) { - $this->serializeOpArray(Core::pointerAtAddress('zend_function *', $hookAddress)->op_array); + $this->serializeOpArray(Core::pointerAtAddress(zend_function::class, $hookAddress)->op_array); } } } - $this->serializeType($prop, 'type'); + $this->serializeTypeStruct($prop->type); } - /** - * @param \FFI\CData $zval - */ private function unserializeClassConstant(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->isUnserialized($this->ptrValue($zval->value, 'ptr'))) { return; } - $constant = Core::pointerAtAddress('zend_class_constant *', $this->unPtr($zval->value, 'ptr')); + $constant = Core::pointerAtAddress(zend_class_constant::class, $this->unPtr($zval->value, 'ptr')); if ($this->isUnserialized($this->ptrValue($constant, 'ce'))) { return; } @@ -1442,18 +1381,16 @@ private function unserializeClassConstant(object $zval): void $this->unStr($constant, 'doc_comment'); } $this->unserializeAttributes($constant, 'attributes'); - $this->unserializeType($constant, 'type'); + $this->unserializeTypeStruct($constant->type); } - /** - * @param \FFI\CData $zval - */ private function serializeClassConstant(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->isSerialized($this->ptrValue($zval->value, 'ptr'))) { return; } - $constant = Core::pointerAtAddress('zend_class_constant *', $this->serPtr($zval->value, 'ptr')); + $constant = Core::pointerAtAddress(zend_class_constant::class, $this->serPtr($zval->value, 'ptr')); if ($this->isSerialized($this->ptrValue($constant, 'ce'))) { return; } @@ -1463,11 +1400,8 @@ private function serializeClassConstant(object $zval): void $this->serStr($constant, 'doc_comment'); } $this->serializeAttributes($constant, 'attributes'); - $this->serializeType($constant, 'type'); + $this->serializeTypeStruct($constant->type); } - /** - * @param \FFI\CData $ce - */ /** * zf_* field order matches the C walk (zend_file_cache.c), not the struct layout. @@ -1484,36 +1418,34 @@ private function serializeClassConstant(object $zval): void * the placeholder is preserved verbatim like every other execution-only field * and the written file keeps the exact bytes the engine expects. * - * @param \FFI\CData $ce */ private function unserializeIteratorFuncs(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, 'iterator_funcs_ptr') !== 0) { $address = $this->unPtr($ce, 'iterator_funcs_ptr'); - $funcs = Core::pointerAtAddress('zend_class_iterator_funcs *', $address); + $funcs = Core::pointerAtAddress(zend_class_iterator_funcs::class, $address); foreach (self::ITERATOR_FUNC_FIELDS as $field) { $this->unPtr($funcs, $field); } } if ($this->ptrValue($ce, 'arrayaccess_funcs_ptr') !== 0) { $address = $this->unPtr($ce, 'arrayaccess_funcs_ptr'); - $funcs = Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $address); + $funcs = Core::pointerAtAddress(zend_class_arrayaccess_funcs::class, $address); foreach (self::ARRAYACCESS_FUNC_FIELDS as $field) { $this->unPtr($funcs, $field); } } } - /** - * @param \FFI\CData $ce - */ private function serializeIteratorFuncs(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ // The C serialize converts the zf_* members through the still-real struct // pointer first and the struct pointer itself last; mirrored exactly $iteratorAddress = $this->ptrValue($ce, 'iterator_funcs_ptr'); if ($iteratorAddress !== 0) { - $funcs = Core::pointerAtAddress('zend_class_iterator_funcs *', $iteratorAddress); + $funcs = Core::pointerAtAddress(zend_class_iterator_funcs::class, $iteratorAddress); foreach (self::ITERATOR_FUNC_FIELDS as $field) { $this->serPtr($funcs, $field); } @@ -1521,7 +1453,7 @@ private function serializeIteratorFuncs(object $ce): void } $arrayAccessAddress = $this->ptrValue($ce, 'arrayaccess_funcs_ptr'); if ($arrayAccessAddress !== 0) { - $funcs = Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $arrayAccessAddress); + $funcs = Core::pointerAtAddress(zend_class_arrayaccess_funcs::class, $arrayAccessAddress); foreach (self::ARRAYACCESS_FUNC_FIELDS as $field) { $this->serPtr($funcs, $field); } @@ -1530,12 +1462,10 @@ private function serializeIteratorFuncs(object $ce): void } // --- warnings / early bindings ----------------------------------------- - /** - * @param \FFI\CData $script - */ private function unserializeWarnings(object $script): void { + /** @var zend_persistent_script $script Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($script, 'warnings') === 0) { return; } @@ -1543,39 +1473,37 @@ private function unserializeWarnings(object $script): void $count = $this->requireCount((int) $script->num_warnings, 'num_warnings'); $this->requireSpan($address, $count * PHP_INT_SIZE, 'warnings table'); for ($i = 0; $i < $count; $i++) { - $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); - $this->requireOffset((int) $slot[0], 'warning entry'); - $slot[0] = $this->base + (int) $slot[0]; - $warning = Core::pointerAtAddress('zend_error_info *', (int) $slot[0]); + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); + $stored = $this->readSlot($slot); + $this->requireOffset($stored, 'warning entry'); + $resolved = $this->base + $stored; + $slot[0] = $resolved; + $warning = Core::pointerAtAddress(zend_error_info::class, $resolved); $this->unStr($warning, 'filename'); $this->unStr($warning, 'message'); } } - /** - * @param \FFI\CData $script - */ private function serializeWarnings(object $script): void { + /** @var zend_persistent_script $script Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($script, 'warnings') === 0) { return; } $address = $this->serPtr($script, 'warnings'); for ($i = 0; $i < $script->num_warnings; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); - $warnAddr = (int) $slot[0]; + $warnAddr = $this->readSlot($slot); $slot[0] = $warnAddr - $this->base; - $warning = Core::pointerAtAddress('zend_error_info *', $warnAddr); + $warning = Core::pointerAtAddress(zend_error_info::class, $warnAddr); $this->serStr($warning, 'filename'); $this->serStr($warning, 'message'); } } - /** - * @param \FFI\CData $script - */ private function unserializeEarlyBindings(object $script): void { + /** @var zend_persistent_script $script Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($script, 'early_bindings') === 0) { return; } @@ -1584,25 +1512,23 @@ private function unserializeEarlyBindings(object $script): void $count = $this->requireCount((int) $script->num_early_bindings, 'num_early_bindings'); $this->requireSpan($address, $count * $bindingSize, 'early_bindings table'); for ($i = 0; $i < $count; $i++) { - $binding = Core::pointerAtAddress('zend_early_binding *', $address + $i * $bindingSize); + $binding = Core::pointerAtAddress(zend_early_binding::class, $address + $i * $bindingSize); $this->unStr($binding, 'lcname'); $this->unStr($binding, 'rtd_key'); $this->unStr($binding, 'lc_parent_name'); } } - /** - * @param \FFI\CData $script - */ private function serializeEarlyBindings(object $script): void { + /** @var zend_persistent_script $script Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($script, 'early_bindings') === 0) { return; } $address = $this->serPtr($script, 'early_bindings'); $bindingSize = Core::sizeOfType(zend_early_binding::class); for ($i = 0; $i < $script->num_early_bindings; $i++) { - $binding = Core::pointerAtAddress('zend_early_binding *', $address + $i * $bindingSize); + $binding = Core::pointerAtAddress(zend_early_binding::class, $address + $i * $bindingSize); $this->serStr($binding, 'lcname'); $this->serStr($binding, 'rtd_key'); $this->serStr($binding, 'lc_parent_name'); diff --git a/src/OpCache/ReflectionOpcacheFile.php b/src/OpCache/ReflectionOpcacheFile.php index 510988d7..f8d5e80f 100644 --- a/src/OpCache/ReflectionOpcacheFile.php +++ b/src/OpCache/ReflectionOpcacheFile.php @@ -14,9 +14,13 @@ namespace ZEngine\OpCache; use FFI; -use FFI\CData; use ZEngine\Core; use ZEngine\Generated\Bucket; +use ZEngine\Generated\HashTable as HashTableStruct; +use ZEngine\Generated\zend_class_entry; +use ZEngine\Generated\zend_op_array; +use ZEngine\Generated\zend_persistent_script; +use ZEngine\Generated\zend_string; use ZEngine\Reflection\ReflectionClass; use ZEngine\Reflection\ReflectionFunction; use ZEngine\Type\HashTable; @@ -43,13 +47,16 @@ final class ReflectionOpcacheFile /** @var list donor images whose units this image now references */ private array $donors = []; + /** @var zend_persistent_script Typed view of the relocated persistent script this handle wraps */ + private readonly object $script; + /** - * @param \FFI\CData $script Relocated zend_persistent_script inside the image buffer - * @param object|null $imageOwner Owner of the relocated buffer (the PayloadRelocator): - * retained so that holding this view alone provably keeps - * the buffer - which every wrapper this view hands out - * points into - alive (see CacheImageSync, whose swapped-in - * bodies keep executing out of that buffer) + * @param \FFI\CData|zend_persistent_script $script Relocated zend_persistent_script inside the image buffer + * @param object|null $imageOwner Owner of the relocated buffer (the PayloadRelocator): + * retained so that holding this view alone provably keeps + * the buffer - which every wrapper this view hands out + * points into - alive (see CacheImageSync, whose swapped-in + * bodies keep executing out of that buffer) */ public function __construct( private readonly object $script, @@ -61,7 +68,7 @@ public function __construct( * The relocated zend_persistent_script this handle wraps * * @internal core-layer escape hatch for BinaryCacheFile/ScriptSerializer - * @return \FFI\CData + * @return zend_persistent_script */ public function getRawScript(): object { @@ -90,7 +97,11 @@ public function donorImages(): array */ public function getFileName(): string { - return StringEntry::fromCData($this->script->script->filename)->getStringValue(); + // A persistent script always carries a filename block + $filename = $this->script->script->filename; + \assert($filename !== null); + + return StringEntry::fromCData($filename)->getStringValue(); } /** @@ -98,7 +109,7 @@ public function getFileName(): string */ public function getScriptFunction(): ReflectionFunction { - $function = Core::cast('zend_function *', FFI::addr($this->script->script->main_op_array)); + $function = Core::cast('zend_function *', Core::addr($this->script->script->main_op_array)); return ReflectionFunction::fromCData($function); } @@ -108,7 +119,7 @@ public function getScriptFunction(): ReflectionFunction */ public function functionTable(): HashTable { - return HashTable::fromCData(FFI::addr($this->script->script->function_table)); + return HashTable::fromCData(Core::addr($this->script->script->function_table)); } /** @@ -116,7 +127,7 @@ public function functionTable(): HashTable */ public function classTable(): HashTable { - return HashTable::fromCData(FFI::addr($this->script->script->class_table)); + return HashTable::fromCData(Core::addr($this->script->script->class_table)); } /** @@ -165,7 +176,12 @@ public function addMethodFrom(self $donor, string $donorClassName, string $metho [$keyAddress, $methodAddress] = $entry; // Re-point the method's scope at the adopting class - $method = Core::pointerAtAddress('zend_op_array *', $methodAddress); + $method = Core::pointerAtAddress(zend_op_array::class, $methodAddress); + // A grafted method op_array always carries its donor-class scope slot + \assert($method->scope !== null); + // FFI::addr must stay inline on the pointer-field access to yield the + // scope SLOT address (a by-value hop would address a pointer copy). + // @phpstan-ignore argument.type (FFI::addr of a zend_class_entry* pointer field) Core::cast('uintptr_t *', FFI::addr($method->scope))[0] = Core::addressOf($targetClass); $this->insertPtrEntry($targetClass->function_table, $keyAddress, $methodAddress); @@ -217,22 +233,27 @@ public function getClasses(): array * Finds a class entry by its own name, case-insensitively; class-table * bucket keys can be opcache rtd keys, so match on ce->name instead. * - * @return \FFI\CData|null a zend_class_entry* into the image + * @return zend_class_entry|null a zend_class_entry* into the image */ private function findClassByName(string $className): ?object { - $ht = $this->script->script->class_table; - $bucketSize = Core::sizeOfType(Bucket::class); + $ht = $this->script->script->class_table; + $bucketSize = Core::sizeOfType(Bucket::class); + // A populated class table always carries a bucket data block + \assert($ht->arData !== null); $dataAddress = Core::addressOf($ht->arData); for ($i = 0; $i < $ht->nNumUsed; $i++) { - $bucket = Core::pointerAtAddress('Bucket *', $dataAddress + $i * $bucketSize); + $bucket = Core::pointerAtAddress(Bucket::class, $dataAddress + $i * $bucketSize); if ($bucket->val->u1->v->type === 0) { continue; } $classEntry = Core::pointerAtAddress( - 'zend_class_entry *', - (int) Core::cast('uintptr_t *', FFI::addr($bucket->val->value))[0], + zend_class_entry::class, + // IS_PTR bucket: the stored class-entry pointer lives in the value union's long slot + $bucket->val->value->lval, ); + // Every compiled class entry carries its own name block + \assert($classEntry->name !== null); $name = StringEntry::fromCData($classEntry->name)->getStringValue(); if (strcasecmp($name, $className) === 0) { return $classEntry; @@ -245,7 +266,7 @@ private function findClassByName(string $className): ?object /** * Finds a bucket by exact key in a keyed image table. * - * @param \FFI\CData $ht HashTable view + * @param HashTableStruct $ht HashTable view * @return array{int, int}|null [key zend_string address, value pointer address] */ private static function findKeyedEntry(object $ht, string $key): ?array @@ -253,17 +274,20 @@ private static function findKeyedEntry(object $ht, string $key): ?array if (($ht->u->flags & Core::engineConstant('HASH_FLAG_UNINITIALIZED')) !== 0) { return null; } - $bucketSize = Core::sizeOfType(Bucket::class); + $bucketSize = Core::sizeOfType(Bucket::class); + // An initialized (non-uninitialized) table always carries a bucket data block + \assert($ht->arData !== null); $dataAddress = Core::addressOf($ht->arData); for ($i = 0; $i < $ht->nNumUsed; $i++) { - $bucket = Core::pointerAtAddress('Bucket *', $dataAddress + $i * $bucketSize); + $bucket = Core::pointerAtAddress(Bucket::class, $dataAddress + $i * $bucketSize); if ($bucket->val->u1->v->type === 0 || $bucket->key === null) { continue; } if (StringEntry::fromCData($bucket->key)->getStringValue() === $key) { return [ Core::addressOf($bucket->key), - (int) Core::cast('uintptr_t *', FFI::addr($bucket->val->value))[0], + // IS_PTR bucket: the stored pointer lives in the value union's long slot + $bucket->val->value->lval, ]; } } @@ -279,7 +303,7 @@ private static function findKeyedEntry(object $ht, string $key): ?array * the persisted format expects it (hash slots ahead of arData, bucket-index * chains via Z_NEXT, HT_SIZE_TO_MASK = -(2 * nTableSize)). * - * @param \FFI\CData $ht HashTable view (embedded in the image) + * @param HashTableStruct $ht HashTable view (embedded in the image) */ private function insertPtrEntry(object $ht, int $keyAddress, int $valueAddress): void { @@ -287,7 +311,7 @@ private function insertPtrEntry(object $ht, int $keyAddress, int $valueAddress): if (($flags & Core::engineConstant('HASH_FLAG_PACKED')) !== 0) { throw OpCacheException::unsupportedPayload('grafting into a packed hashtable'); } - $key = Core::pointerAtAddress('zend_string *', $keyAddress); + $key = Core::pointerAtAddress(zend_string::class, $keyAddress); $hash = $key->h; if ($hash === 0) { throw OpCacheException::unsupportedPayload('graft key string carries no precomputed hash'); @@ -295,9 +319,11 @@ private function insertPtrEntry(object $ht, int $keyAddress, int $valueAddress): $bucketSize = Core::sizeOfType(Bucket::class); $uninitialized = ($flags & Core::engineConstant('HASH_FLAG_UNINITIALIZED')) !== 0; - $used = $uninitialized ? 0 : $ht->nNumUsed; - $tableSize = $uninitialized ? 8 : $ht->nTableSize; - $oldData = $uninitialized ? 0 : Core::addressOf($ht->arData); + // An initialized image table always points its data block at real buckets + \assert($uninitialized || $ht->arData !== null); + $used = $uninitialized ? 0 : $ht->nNumUsed; + $tableSize = $uninitialized ? 8 : $ht->nTableSize; + $oldData = $uninitialized ? 0 : Core::addressOf($ht->arData); if (!$uninitialized && self::findKeyedEntry($ht, StringEntry::fromCData($key)->getStringValue()) !== null) { throw OpCacheException::duplicateHashTableKey(StringEntry::fromCData($key)->getStringValue()); @@ -316,7 +342,7 @@ private function insertPtrEntry(object $ht, int $keyAddress, int $valueAddress): $newData = $blockBase + $hashBytes; if ($used > 0) { - FFI::memcpy( + Core::memcpy( Core::cast('char *', Core::pointerAtAddress('void *', $newData)), Core::cast('char *', Core::pointerAtAddress('void *', $oldData)), $used * $bucketSize, @@ -324,29 +350,32 @@ private function insertPtrEntry(object $ht, int $keyAddress, int $valueAddress): } // The appended bucket: an IS_PTR zval, hash and key - $bucket = Core::pointerAtAddress('Bucket *', $newData + $used * $bucketSize); - $bucket->val->u1->type_info = Core::engineConstant('IS_PTR'); - Core::cast('uintptr_t *', FFI::addr($bucket->val->value))[0] = $valueAddress; - $bucket->h = $hash; - $bucket->key = $key; + $bucket = Core::pointerAtAddress(Bucket::class, $newData + $used * $bucketSize); + $bucket->val->u1->type_info = Core::engineConstant('IS_PTR'); + Core::cast('uintptr_t *', Core::addr($bucket->val->value))[0] = $valueAddress; + $bucket->h = $hash; + $bucket->key = $key; // HT_HASH_RESET + full rehash (bucket-index chains, like zend_hash_persist) for ($i = 0; $i < 2 * $tableSize; $i++) { Core::cast('uint32_t *', Core::pointerAtAddress('void *', $blockBase + $i * 4))[0] = 0xFFFFFFFF; // HT_INVALID_IDX } for ($idx = 0; $idx < $newUsed; $idx++) { - $entry = Core::pointerAtAddress('Bucket *', $newData + $idx * $bucketSize); + $entry = Core::pointerAtAddress(Bucket::class, $newData + $idx * $bucketSize); if ($entry->val->u1->v->type === 0) { continue; } - $nIndex = ($entry->h | $newMask) & 0xFFFFFFFF; - $slot = $nIndex - 0x100000000; // (int32_t)nIndex, always negative - $slotAddr = $newData + $slot * 4; - $entry->val->u2->next = (int) Core::cast('uint32_t *', Core::pointerAtAddress('void *', $slotAddr))[0]; + $nIndex = ($entry->h | $newMask) & 0xFFFFFFFF; + $slot = $nIndex - 0x100000000; // (int32_t)nIndex, always negative + $slotAddr = $newData + $slot * 4; + // HT_HASH slots are uint32_t; the deref reads as a PHP int (Z_NEXT chain head) + $chainHead = Core::cast('uint32_t *', Core::pointerAtAddress('void *', $slotAddr))[0]; + \assert(\is_int($chainHead)); + $entry->val->u2->next = $chainHead; Core::cast('uint32_t *', Core::pointerAtAddress('void *', $slotAddr))[0] = $idx; } - $ht->arData = Core::pointerAtAddress('Bucket *', $newData); + $ht->arData = Core::pointerAtAddress(Bucket::class, $newData); $ht->nNumUsed = $newUsed; $ht->nNumOfElements = ($uninitialized ? 0 : $ht->nNumOfElements) + 1; $ht->nTableSize = $tableSize; diff --git a/src/OpCache/ScriptSerializer.php b/src/OpCache/ScriptSerializer.php index fa2e4a7c..a9dceb8f 100644 --- a/src/OpCache/ScriptSerializer.php +++ b/src/OpCache/ScriptSerializer.php @@ -17,6 +17,7 @@ use FFI\CData; use ZEngine\Core; use ZEngine\Generated\Bucket; +use ZEngine\Generated\HashTable as HashTableStruct; use ZEngine\Generated\zend_arg_info; use ZEngine\Generated\zend_ast; use ZEngine\Generated\zend_ast_list; @@ -139,7 +140,7 @@ final class ScriptSerializer private readonly int $zendStringHeaderSize; /** - * @param CData $script the relocated zend_persistent_script* of the live image + * @param CData|zend_persistent_script $script the relocated zend_persistent_script* of the live image */ public function __construct(private readonly object $script) { @@ -177,6 +178,8 @@ public function serialize(): string $this->persistScript($scriptAddress); $this->resolveDeferred(); + // The emit buffer was just allocated above and is never cleared here + \assert($this->out !== null); // The emitted region is a valid relocated image; the on-disk offset // encoding is the relocator's serialize - byte-tested machinery $meta = CacheMetaInfo::forPayload( @@ -234,7 +237,8 @@ private function unit(int $source, int $size): array FFI::memcpy( Core::cast('char *', Core::pointerAtAddress('void *', $new)), Core::cast('char *', Core::pointerAtAddress('void *', $source)), - $size, + // max(...,0) only states the non-negative unit size to the analyser + max($size, 0), ); return [$new, true]; @@ -266,9 +270,22 @@ private function mapAddress(int $source): int } /** - * Reads a pointer field's stored value as an integer (0 for C NULL). + * Reads a uintptr_t pointer slot as a PHP int - the raw-pointer read + * primitive. The dereferenced CData element is always an integer at runtime; + * the guard states that to the analyser without widening any real value. * - * @param \FFI\CData $owner + * @param \FFI\CData $slot a uintptr_t* view over the slot to read + */ + private function readSlot(object $slot): int + { + $value = $slot[0]; + \assert(\is_int($value)); + + return $value; + } + + /** + * Reads a pointer field's stored value as an integer (0 for C NULL). */ private function ptrValue(object $owner, string $field): int { @@ -276,20 +293,24 @@ private function ptrValue(object $owner, string $field): int return 0; } - return (int) Core::cast('uintptr_t *', FFI::addr($owner->$field))[0]; + // A dynamically-named pointer field cannot be statically resolved, so + // FFI::addr() on the mixed field read is the one irreducible CData hop. + // @phpstan-ignore argument.type (FFI::addr of a dynamic FFI\CData pointer field) + return $this->readSlot(Core::cast('uintptr_t *', FFI::addr($owner->$field))); } /** * Writes a pointer field in the emit pass (no-op while measuring). The * field always holds its non-null source value at this point. * - * @param \FFI\CData $owner a view into the COPY + * @param object $owner a view into the COPY */ private function put(object $owner, string $field, int $address): void { if ($this->phase !== 2) { return; } + // @phpstan-ignore argument.type (FFI::addr of a dynamic FFI\CData pointer field) $slot = Core::cast('uintptr_t *', FFI::addr($owner->$field)); $slot[0] = $address; } @@ -306,7 +327,7 @@ private function putAt(int $slotAddress, int $value): void /** Reads a raw pointer slot */ private function slotValue(int $slotAddress): int { - return (int) Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress))[0]; + return $this->readSlot(Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress))); } /** Defers a copy-slot rewrite until the xlat table is complete */ @@ -319,13 +340,14 @@ private function deferAt(int $slotAddress, int $sourceTarget, string $what): voi } /** - * @param \FFI\CData $owner a view into the COPY, field currently non-null + * @param object $owner a view into the COPY, field currently non-null */ private function defer(object $owner, string $field, int $sourceTarget, string $what): void { if ($this->phase !== 2) { return; } + // @phpstan-ignore argument.type (FFI::addr of a dynamic FFI\CData pointer field) $this->deferred[] = [Core::addressOf(FFI::addr($owner->$field)), $sourceTarget, $what]; } @@ -346,11 +368,11 @@ private function resolveDeferred(): void */ private function persistString(int $source): int { - $string = Core::pointerAtAddress('zend_string *', $source); + $string = Core::pointerAtAddress(zend_string::class, $source); $size = $this->zendStringHeaderSize + $string->len + 1; [$new, $first] = $this->unit($source, $size); if ($first && $this->phase === 2) { - $copy = Core::pointerAtAddress('zend_string *', $new); + $copy = Core::pointerAtAddress(zend_string::class, $new); $typeInfo = $copy->gc->u->type_info; if (($typeInfo & Core::engineConstant('IS_STR_INTERNED')) === 0) { // zend_set_str_gc_flags, file_cache_only branch @@ -371,11 +393,12 @@ private function persistString(int $source): int * HashTable struct itself lives in its owner (embedded) or in its own unit * (zend_array). $entry receives [source zval address, copy zval address]. * - * @param \FFI\CData $ht source HashTable view - * @param \FFI\CData $htCopy copy HashTable view (same as $ht while measuring) + * @param HashTableStruct $ht source HashTable view + * @param object $htCopy copy HashTable view (same as $ht while measuring) */ private function persistHashData(object $ht, object $htCopy, callable $entry): void { + /** @var HashTableStruct $ht Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if (($ht->u->flags & Core::engineConstant('HASH_FLAG_UNINITIALIZED')) !== 0) { return; // arData is written as 0 by the relocator's serialize stage } @@ -403,20 +426,20 @@ private function persistHashData(object $ht, object $htCopy, callable $entry): v $sourceEntry = $dataAddress + $i * $entrySize; $copyEntry = $this->phase === 2 ? $newData + $i * $entrySize : $sourceEntry; if ($packed) { - $zv = Core::pointerAtAddress('zval *', $sourceEntry); + $zv = Core::pointerAtAddress(zval::class, $sourceEntry); if ($zv->u1->v->type !== 0) { $entry($sourceEntry, $copyEntry); } continue; } - $bucket = Core::pointerAtAddress('Bucket *', $sourceEntry); + $bucket = Core::pointerAtAddress(Bucket::class, $sourceEntry); if ($bucket->val->u1->v->type === 0) { continue; // hole: bytes copied verbatim, nothing to walk } $keyAddress = $this->ptrValue($bucket, 'key'); if ($keyAddress !== 0) { $newKey = $this->persistString($keyAddress); - $bucketCopy = Core::pointerAtAddress('Bucket *', $copyEntry); + $bucketCopy = Core::pointerAtAddress(Bucket::class, $copyEntry); if ($this->phase === 2) { $this->put($bucketCopy, 'key', $newKey); } @@ -430,8 +453,8 @@ private function persistArray(int $source, callable $entry): int { [$new, $first] = $this->unit($source, Core::sizeOfType('HashTable')); if ($first) { - $ht = Core::pointerAtAddress('HashTable *', $source); - $htCopy = $this->phase === 2 ? Core::pointerAtAddress('HashTable *', $new) : $ht; + $ht = Core::pointerAtAddress(HashTableStruct::class, $source); + $htCopy = $this->phase === 2 ? Core::pointerAtAddress(HashTableStruct::class, $new) : $ht; $this->persistHashData($ht, $htCopy, $entry); } @@ -442,8 +465,8 @@ private function persistArray(int $source, callable $entry): int private function persistZval(int $source, int $copy): void { - $zv = Core::pointerAtAddress('zval *', $source); - $zvCopy = $this->phase === 2 ? Core::pointerAtAddress('zval *', $copy) : $zv; + $zv = Core::pointerAtAddress(zval::class, $source); + $zvCopy = $this->phase === 2 ? Core::pointerAtAddress(zval::class, $copy) : $zv; switch ($zv->u1->v->type) { case self::IS_STRING: $this->put($zvCopy->value, 'str', $this->persistString($this->ptrValue($zv->value, 'str'))); @@ -492,7 +515,7 @@ private function persistAstNode(int $source): int private function persistAstNodeBody(int $source, int $copy): void { - $ast = Core::pointerAtAddress('zend_ast *', $source); + $ast = Core::pointerAtAddress(zend_ast::class, $source); $kind = $ast->kind; if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { $valueOffset = Core::sizeOfType('zend_ast_zval') - Core::sizeOfType(zval::class); @@ -525,7 +548,7 @@ private function astChildren(int $source, int $kind): array private function astNodeSize(int $source): int { - $ast = Core::pointerAtAddress('zend_ast *', $source); + $ast = Core::pointerAtAddress(zend_ast::class, $source); $kind = $ast->kind; if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { return Core::sizeOfType('zend_ast_zval'); @@ -548,30 +571,30 @@ private function persistAttributes(object $owner, object $ownerCopy, string $fie return; } $new = $this->persistArray($source, function (int $zvalSource, int $zvalCopy): void { - $zv = Core::pointerAtAddress('zval *', $zvalSource); + $zv = Core::pointerAtAddress(zval::class, $zvalSource); $attrSource = $this->ptrValue($zv->value, 'ptr'); - $attr = Core::pointerAtAddress('zend_attribute *', $attrSource); + $attr = Core::pointerAtAddress(zend_attribute::class, $attrSource); $argSize = Core::sizeOfType(zend_attribute_arg::class); // ZEND_ATTRIBUTE_SIZE(argc) $size = Core::sizeOfType(zend_attribute::class) + $argSize * $attr->argc - $argSize; [$new, $first] = $this->unit($attrSource, $size); if ($this->phase === 2) { - $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'ptr', $new); + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'ptr', $new); } if (!$first) { return; } - $attrCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_attribute *', $new) : $attr; + $attrCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_attribute::class, $new) : $attr; $this->put($attrCopy, 'name', $this->persistString($this->ptrValue($attr, 'name'))); $this->put($attrCopy, 'lcname', $this->persistString($this->ptrValue($attr, 'lcname'))); $argBase = Core::addressOf($attr->args); for ($i = 0; $i < $attr->argc; $i++) { $argSource = $argBase + $i * $argSize; $argCopy = $this->phase === 2 ? Core::addressOf($attrCopy->args) + $i * $argSize : $argSource; - $arg = Core::pointerAtAddress('zend_attribute_arg *', $argSource); + $arg = Core::pointerAtAddress(zend_attribute_arg::class, $argSource); $nameAddr = $this->ptrValue($arg, 'name'); if ($nameAddr !== 0) { - $this->put(Core::pointerAtAddress('zend_attribute_arg *', $argCopy), 'name', $this->persistString($nameAddr)); + $this->put(Core::pointerAtAddress(zend_attribute_arg::class, $argCopy), 'name', $this->persistString($nameAddr)); } $valueOffset = $argSize - Core::sizeOfType(zval::class); $this->persistZval($argSource + $valueOffset, $argCopy + $valueOffset); @@ -583,15 +606,16 @@ private function persistAttributes(object $owner, object $ownerCopy, string $fie // --- types ------------------------------------------------------------------------ /** - * @param \FFI\CData $type source zend_type view (embedded) - * @param \FFI\CData $typeCopy copy zend_type view + * @param zend_type $type source zend_type view (embedded) + * @param object $typeCopy copy zend_type view */ private function persistType(object $type, object $typeCopy): void { + /** @var zend_type $type Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ $typeMask = $type->type_mask; if (($typeMask & self::TYPE_LIST_BIT) !== 0) { $listSource = $this->ptrValue($type, 'ptr'); - $list = Core::pointerAtAddress('zend_type_list *', $listSource); + $list = Core::pointerAtAddress(zend_type_list::class, $listSource); $typeSize = Core::sizeOfType(zend_type::class); $entryBase = Core::sizeOfType(zend_type_list::class) - $typeSize; $size = $entryBase + $typeSize * $list->num_types; @@ -599,9 +623,9 @@ private function persistType(object $type, object $typeCopy): void $this->put($typeCopy, 'ptr', $new); if ($first) { for ($i = 0; $i < $list->num_types; $i++) { - $entrySource = Core::pointerAtAddress('zend_type *', $listSource + $entryBase + $i * $typeSize); + $entrySource = Core::pointerAtAddress(zend_type::class, $listSource + $entryBase + $i * $typeSize); $entryCopy = $this->phase === 2 - ? Core::pointerAtAddress('zend_type *', $new + $entryBase + $i * $typeSize) + ? Core::pointerAtAddress(zend_type::class, $new + $entryBase + $i * $typeSize) : $entrySource; $this->persistType($entrySource, $entryCopy); } @@ -619,7 +643,7 @@ private function persistType(object $type, object $typeCopy): void /** Persists a pointed-to zend_function unit (function table entries, hooks, closures) */ private function persistFunction(int $source): int { - $opArray = Core::pointerAtAddress('zend_op_array *', $source); + $opArray = Core::pointerAtAddress(zend_op_array::class, $source); if ($opArray->type !== Core::engineConstant('ZEND_USER_FUNCTION')) { throw OpCacheException::unsupportedPayload('only user functions can be persisted into a file-cache image'); } @@ -634,8 +658,8 @@ private function persistFunction(int $source): int /** The shared field walk for pointed-to op_arrays and the embedded main_op_array */ private function persistOpArrayBody(int $source, int $copy): void { - $op = Core::pointerAtAddress('zend_op_array *', $source); - $opCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_op_array *', $copy) : $op; + $op = Core::pointerAtAddress(zend_op_array::class, $source); + $opCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_op_array::class, $copy) : $op; $staticVariables = $this->ptrValue($op, 'static_variables'); if ($staticVariables !== 0) { @@ -675,9 +699,9 @@ private function persistOpArrayBody(int $source, int $copy): void $this->put($opCopy, 'arg_info', $new + $hasRet * $argSize); if ($first) { for ($i = 0; $i < $entries; $i++) { - $entrySource = Core::pointerAtAddress('zend_arg_info *', $allocStart + $i * $argSize); + $entrySource = Core::pointerAtAddress(zend_arg_info::class, $allocStart + $i * $argSize); $entryCopy = $this->phase === 2 - ? Core::pointerAtAddress('zend_arg_info *', $new + $i * $argSize) + ? Core::pointerAtAddress(zend_arg_info::class, $new + $i * $argSize) : $entrySource; $nameAddress = $this->ptrValue($entrySource, 'name'); if ($nameAddress !== 0) { @@ -758,8 +782,8 @@ private function persistClassEntry(int $source): int if (!$first) { return $ceNew; } - $ce = Core::pointerAtAddress('zend_class_entry *', $source); - $ceCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_class_entry *', $ceNew) : $ce; + $ce = Core::pointerAtAddress(zend_class_entry::class, $source); + $ceCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_class_entry::class, $ceNew) : $ce; $this->put($ceCopy, 'name', $this->persistString($this->ptrValue($ce, 'name'))); if ($this->ptrValue($ce, 'parent') !== 0) { @@ -771,10 +795,10 @@ private function persistClassEntry(int $source): int } $this->persistHashData($ce->function_table, $ceCopy->function_table, function (int $zvalSource, int $zvalCopy): void { - $zv = Core::pointerAtAddress('zval *', $zvalSource); + $zv = Core::pointerAtAddress(zval::class, $zvalSource); $new = $this->persistFunction($this->ptrValue($zv->value, 'func')); if ($this->phase === 2) { - $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'func', $new); + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'func', $new); } }); @@ -797,17 +821,17 @@ private function persistClassEntry(int $source): int } $this->persistHashData($ce->constants_table, $ceCopy->constants_table, function (int $zvalSource, int $zvalCopy): void { - $zv = Core::pointerAtAddress('zval *', $zvalSource); + $zv = Core::pointerAtAddress(zval::class, $zvalSource); $constSource = $this->ptrValue($zv->value, 'ptr'); [$new, $first] = $this->unit($constSource, Core::sizeOfType(zend_class_constant::class)); if ($this->phase === 2) { - $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'ptr', $new); + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'ptr', $new); } if (!$first) { return; } - $constant = Core::pointerAtAddress('zend_class_constant *', $constSource); - $constantCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_class_constant *', $new) : $constant; + $constant = Core::pointerAtAddress(zend_class_constant::class, $constSource); + $constantCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_class_constant::class, $new) : $constant; $this->persistZval($constSource, $this->phase === 2 ? $new : $constSource); // value zval is the first member $docComment = $this->ptrValue($constant, 'doc_comment'); if ($docComment !== 0) { @@ -829,17 +853,17 @@ private function persistClassEntry(int $source): int $this->persistAttributes($ce, $ceCopy, 'attributes'); $this->persistHashData($ce->properties_info, $ceCopy->properties_info, function (int $zvalSource, int $zvalCopy): void { - $zv = Core::pointerAtAddress('zval *', $zvalSource); + $zv = Core::pointerAtAddress(zval::class, $zvalSource); $propSource = $this->ptrValue($zv->value, 'ptr'); [$new, $first] = $this->unit($propSource, Core::sizeOfType(zend_property_info::class)); if ($this->phase === 2) { - $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'ptr', $new); + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'ptr', $new); } if (!$first) { return; } - $prop = Core::pointerAtAddress('zend_property_info *', $propSource); - $propCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_property_info *', $new) : $prop; + $prop = Core::pointerAtAddress(zend_property_info::class, $propSource); + $propCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_property_info::class, $new) : $prop; $this->defer($propCopy, 'ce', $this->ptrValue($prop, 'ce'), 'property scope'); $this->put($propCopy, 'name', $this->persistString($this->ptrValue($prop, 'name'))); $docComment = $this->ptrValue($prop, 'doc_comment'); @@ -906,8 +930,8 @@ private function persistClassEntry(int $source): int [$new, $first] = $this->unit($iteratorFuncs, Core::sizeOfType(zend_class_iterator_funcs::class)); $this->put($ceCopy, 'iterator_funcs_ptr', $new); if ($first) { - $funcs = Core::pointerAtAddress('zend_class_iterator_funcs *', $iteratorFuncs); - $funcsCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_class_iterator_funcs *', $new) : $funcs; + $funcs = Core::pointerAtAddress(zend_class_iterator_funcs::class, $iteratorFuncs); + $funcsCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_class_iterator_funcs::class, $new) : $funcs; foreach (self::ITERATOR_FUNC_FIELDS as $field) { $target = $this->ptrValue($funcs, $field); if ($target !== 0) { @@ -921,8 +945,8 @@ private function persistClassEntry(int $source): int [$new, $first] = $this->unit($arrayAccessFuncs, Core::sizeOfType(zend_class_arrayaccess_funcs::class)); $this->put($ceCopy, 'arrayaccess_funcs_ptr', $new); if ($first) { - $funcs = Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $arrayAccessFuncs); - $funcsCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $new) : $funcs; + $funcs = Core::pointerAtAddress(zend_class_arrayaccess_funcs::class, $arrayAccessFuncs); + $funcsCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_class_arrayaccess_funcs::class, $new) : $funcs; foreach (self::ARRAYACCESS_FUNC_FIELDS as $field) { $target = $this->ptrValue($funcs, $field); if ($target !== 0) { @@ -940,10 +964,6 @@ private function persistClassEntry(int $source): int return $ceNew; } - /** - * @param \FFI\CData $ce - * @param \FFI\CData $ceCopy - */ private function persistClassNames(object $ce, object $ceCopy, string $field, int $count): void { $source = $this->ptrValue($ce, $field); @@ -957,19 +977,15 @@ private function persistClassNames(object $ce, object $ceCopy, string $field, in return; } for ($i = 0; $i < $count; $i++) { - $entrySource = Core::pointerAtAddress('zend_class_name *', $source + $i * $nameSize); + $entrySource = Core::pointerAtAddress(zend_class_name::class, $source + $i * $nameSize); $entryCopy = $this->phase === 2 - ? Core::pointerAtAddress('zend_class_name *', $new + $i * $nameSize) + ? Core::pointerAtAddress(zend_class_name::class, $new + $i * $nameSize) : $entrySource; $this->put($entryCopy, 'name', $this->persistString($this->ptrValue($entrySource, 'name'))); $this->put($entryCopy, 'lc_name', $this->persistString($this->ptrValue($entrySource, 'lc_name'))); } } - /** - * @param \FFI\CData $ce - * @param \FFI\CData $ceCopy - */ private function persistTraitAliases(object $ce, object $ceCopy): void { $source = $this->ptrValue($ce, 'trait_aliases'); @@ -992,8 +1008,8 @@ private function persistTraitAliases(object $ce, object $ceCopy): void if (!$firstAlias) { continue; } - $alias = Core::pointerAtAddress('zend_trait_alias *', $aliasSource); - $aliasCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_trait_alias *', $newAlias) : $alias; + $alias = Core::pointerAtAddress(zend_trait_alias::class, $aliasSource); + $aliasCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_trait_alias::class, $newAlias) : $alias; foreach (['method_name', 'class_name'] as $nameField) { $address = $this->ptrValue($alias->trait_method, $nameField); if ($address !== 0) { @@ -1007,10 +1023,6 @@ private function persistTraitAliases(object $ce, object $ceCopy): void } } - /** - * @param \FFI\CData $ce - * @param \FFI\CData $ceCopy - */ private function persistTraitPrecedences(object $ce, object $ceCopy): void { $source = $this->ptrValue($ce, 'trait_precedences'); @@ -1028,7 +1040,7 @@ private function persistTraitPrecedences(object $ce, object $ceCopy): void } for ($i = 0; $i < $count; $i++) { $precedenceSource = $this->slotValue($source + $i * PHP_INT_SIZE); - $precedence = Core::pointerAtAddress('zend_trait_precedence *', $precedenceSource); + $precedence = Core::pointerAtAddress(zend_trait_precedence::class, $precedenceSource); $size = Core::sizeOfType(zend_trait_precedence::class) + PHP_INT_SIZE * ($precedence->num_excludes - 1); [$newPrecedence, $firstPrecedence] = $this->unit($precedenceSource, $size); @@ -1037,7 +1049,7 @@ private function persistTraitPrecedences(object $ce, object $ceCopy): void continue; } $precedenceCopy = $this->phase === 2 - ? Core::pointerAtAddress('zend_trait_precedence *', $newPrecedence) + ? Core::pointerAtAddress(zend_trait_precedence::class, $newPrecedence) : $precedence; foreach (['method_name', 'class_name'] as $nameField) { $address = $this->ptrValue($precedence->trait_method, $nameField); @@ -1061,23 +1073,23 @@ private function persistTraitPrecedences(object $ce, object $ceCopy): void private function persistScript(int $source): void { [$new, ] = $this->unit($source, Core::sizeOfType(zend_persistent_script::class)); - $script = Core::pointerAtAddress('zend_persistent_script *', $source); - $scriptCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_persistent_script *', $new) : $script; + $script = Core::pointerAtAddress(zend_persistent_script::class, $source); + $scriptCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_persistent_script::class, $new) : $script; $this->put($scriptCopy->script, 'filename', $this->persistString($this->ptrValue($script->script, 'filename'))); $this->persistHashData($script->script->class_table, $scriptCopy->script->class_table, function (int $zvalSource, int $zvalCopy): void { - $zv = Core::pointerAtAddress('zval *', $zvalSource); + $zv = Core::pointerAtAddress(zval::class, $zvalSource); $new = $this->persistClassEntry($this->ptrValue($zv->value, 'ce')); if ($this->phase === 2) { - $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'ce', $new); + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'ce', $new); } }); $this->persistHashData($script->script->function_table, $scriptCopy->script->function_table, function (int $zvalSource, int $zvalCopy): void { - $zv = Core::pointerAtAddress('zval *', $zvalSource); + $zv = Core::pointerAtAddress(zval::class, $zvalSource); $new = $this->persistFunction($this->ptrValue($zv->value, 'func')); if ($this->phase === 2) { - $this->put(Core::pointerAtAddress('zval *', $zvalCopy)->value, 'func', $new); + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'func', $new); } }); @@ -1097,8 +1109,8 @@ private function persistScript(int $source): void if (!$firstWarning) { continue; } - $warning = Core::pointerAtAddress('zend_error_info *', $warningSource); - $warningCopy = $this->phase === 2 ? Core::pointerAtAddress('zend_error_info *', $newWarning) : $warning; + $warning = Core::pointerAtAddress(zend_error_info::class, $warningSource); + $warningCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_error_info::class, $newWarning) : $warning; foreach (['filename', 'message'] as $stringField) { $address = $this->ptrValue($warning, $stringField); if ($address !== 0) { @@ -1116,9 +1128,9 @@ private function persistScript(int $source): void $this->put($scriptCopy, 'early_bindings', $new); if ($first) { for ($i = 0; $i < $script->num_early_bindings; $i++) { - $bindingSource = Core::pointerAtAddress('zend_early_binding *', $earlyBindings + $i * $bindingSize); + $bindingSource = Core::pointerAtAddress(zend_early_binding::class, $earlyBindings + $i * $bindingSize); $bindingCopy = $this->phase === 2 - ? Core::pointerAtAddress('zend_early_binding *', $new + $i * $bindingSize) + ? Core::pointerAtAddress(zend_early_binding::class, $new + $i * $bindingSize) : $bindingSource; foreach (['lcname', 'rtd_key', 'lc_parent_name'] as $stringField) { $address = $this->ptrValue($bindingSource, $stringField); diff --git a/tests/OpCache/BoundsValidationTest.php b/tests/OpCache/BoundsValidationTest.php index c9664057..f63f4357 100644 --- a/tests/OpCache/BoundsValidationTest.php +++ b/tests/OpCache/BoundsValidationTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use ZEngine\Core; +use ZEngine\Generated\zend_script; /** * Bounds-validation coverage (issue #123): a crafted or truncated binary whose @@ -99,7 +100,11 @@ public function testHostileScriptPointerFieldIsRefused(): void // Corrupt the script's filename offset to a wild value past the region. // zend_script is the first member of zend_persistent_script, so the // script offset is a zend_script* - single-hop to keep the field typed. - $script = Core::pointerAtAddress('zend_script *', $base + $meta->scriptOffset()); + $script = Core::pointerAtAddress(zend_script::class, $base + $meta->scriptOffset()); + // The compiled fixture always carries a filename block + \assert($script->filename !== null); + // FFI::addr must stay inline on the pointer-field access to yield the filename SLOT address + // @phpstan-ignore argument.type (FFI::addr of a zend_string* pointer field) $filenameAt = Core::addressOf(FFI::addr($script->filename)); Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $filenameAt))[0] = $meta->memSize() + 0x4000; @@ -115,8 +120,8 @@ public function testHostileHashCountIsRefused(): void $base = Core::addressOf(Core::addr($buffer)); // Blow up the function table's nNumUsed so the bucket walk would spill - $script = Core::pointerAtAddress('zend_script *', $base + $meta->scriptOffset()); - $functionTableAt = Core::addressOf(FFI::addr($script->function_table)); + $script = Core::pointerAtAddress(zend_script::class, $base + $meta->scriptOffset()); + $functionTableAt = Core::addressOf(Core::addr($script->function_table)); $functionTable = Core::pointerAtAddress('HashTable *', $functionTableAt); $functionTable->nNumUsed = 0x7fffffff; @@ -133,7 +138,11 @@ public function testHostileInternedStringOffsetIsRefused(): void // Tag the script filename as an interned reference far past the (empty) // string section - a plausible-looking but out-of-range interned offset - $script = Core::pointerAtAddress('zend_script *', $base + $meta->scriptOffset()); + $script = Core::pointerAtAddress(zend_script::class, $base + $meta->scriptOffset()); + // The compiled fixture always carries a filename block + \assert($script->filename !== null); + // FFI::addr must stay inline on the pointer-field access to yield the filename SLOT address + // @phpstan-ignore argument.type (FFI::addr of a zend_string* pointer field) $filenameAt = Core::addressOf(FFI::addr($script->filename)); Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $filenameAt))[0] = 0x100001; // odd => tagged, offset 0x100000 From 225bbca1b5289e66e13442343c3fce2237abd6b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 00:27:56 +0000 Subject: [PATCH 25/28] fix(opcache): resolve duplicate $script property from #122/#126 merge The rebase of #126 onto #122 kept both #126's standalone `private readonly object $script` declaration and #122's promoted constructor property of the same name, a fatal redeclaration. Keep #122's promoted property (per the merge resolution) and move #126's `@var zend_persistent_script` narrowing onto the promoted param so PHPStan types $this->script as the stub rather than the FFI\CData|zend_persistent_script @param union, restoring #126's zero-path-scoped-ignore struct-shape typing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- src/OpCache/ReflectionOpcacheFile.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/OpCache/ReflectionOpcacheFile.php b/src/OpCache/ReflectionOpcacheFile.php index f8d5e80f..1ce61540 100644 --- a/src/OpCache/ReflectionOpcacheFile.php +++ b/src/OpCache/ReflectionOpcacheFile.php @@ -47,9 +47,6 @@ final class ReflectionOpcacheFile /** @var list donor images whose units this image now references */ private array $donors = []; - /** @var zend_persistent_script Typed view of the relocated persistent script this handle wraps */ - private readonly object $script; - /** * @param \FFI\CData|zend_persistent_script $script Relocated zend_persistent_script inside the image buffer * @param object|null $imageOwner Owner of the relocated buffer (the PayloadRelocator): @@ -59,6 +56,7 @@ final class ReflectionOpcacheFile * bodies keep executing out of that buffer) */ public function __construct( + /** @var zend_persistent_script Typed view of the relocated persistent script this handle wraps */ private readonly object $script, // @phpstan-ignore property.onlyWritten (lifetime pin: held, never read) private readonly ?object $imageOwner = null, From 93fcd8defc984afbcf7355e737b31a6d046b1fda Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 01:15:05 +0000 Subject: [PATCH 26/28] fix(opcache): teach the graph serializer the PHP 8.5 constant-expression shapes The persist-from-graph ScriptSerializer was ported on the 8.4 line (#117) and never met the two node shapes PHP 8.5 introduced into the file-cache format, both exercised by the answer.php fixture: - ZEND_AST_OP_ARRAY (a static closure compiled into a constant expression): the generic AST walk sized the node at 8 bytes (66 >> 8 = 0 children), truncating the 16-byte zend_ast_op_array, so the embedded op_array pointer slot was never copied nor remapped and the rebuilt image carried neighboring-unit bytes there - caught by the relocator's bounds validation as a garbage static_variables offset. The node is now sized as zend_ast_op_array and the embedded body is persisted like a function-table entry, mirroring PayloadRelocator::serializeAst()/unserializeAst() on the offset-encoding side. ZEND_AST_CALLABLE_CONVERT (zend_ast_fcc, same 16-byte layout) is sized correctly too and copied verbatim. - ZEND_DECLARE_ATTRIBUTED_CONST + ZEND_OP_DATA whose IS_CONST operand is an IS_PTR literal holding the attribute table: the literal walk skips IS_PTR zvals, so the table is now persisted from the opline pair, the exact counterpart of PayloadRelocator::walkAttributedConstOplines(). Fixes the two GraphGrowingSerializerTest failures on the reconciled master (PHP 8.5) line; the relocator itself already handled both shapes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- src/OpCache/ScriptSerializer.php | 93 ++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 5 deletions(-) diff --git a/src/OpCache/ScriptSerializer.php b/src/OpCache/ScriptSerializer.php index a9dceb8f..57efb301 100644 --- a/src/OpCache/ScriptSerializer.php +++ b/src/OpCache/ScriptSerializer.php @@ -32,6 +32,7 @@ use ZEngine\Generated\zend_early_binding; use ZEngine\Generated\zend_error_info; use ZEngine\Generated\zend_live_range; +use ZEngine\Generated\zend_op; use ZEngine\Generated\zend_op_array; use ZEngine\Generated\zend_persistent_script; use ZEngine\Generated\zend_property_info; @@ -91,10 +92,23 @@ final class ScriptSerializer private const int IS_CONSTANT_AST = 11; private const int IS_INDIRECT = 12; - private const int ZEND_AST_ZVAL = 64; - private const int ZEND_AST_CONSTANT = 65; - private const int ZEND_AST_IS_LIST_SHIFT = 7; - private const int ZEND_AST_CHILDREN_SHIFT = 8; + /** + * AST node kinds the walk special-cases. PHP 8.5 added two node shapes that can + * appear in a constant expression: ZEND_AST_OP_ARRAY (a static closure compiled + * into the expression, carrying a zend_op_array pointer) and ZEND_AST_CALLABLE_CONVERT + * (first-class callable syntax, whose zend_ast_fcc holds only a ZEND_MAP_PTR slot). + */ + private const int ZEND_AST_ZVAL = 64; + private const int ZEND_AST_CONSTANT = 65; + private const int ZEND_AST_OP_ARRAY = 66; + private const int ZEND_AST_CALLABLE_CONVERT = 3; + private const int ZEND_AST_IS_LIST_SHIFT = 7; + private const int ZEND_AST_CHILDREN_SHIFT = 8; + + /** Opcodes whose operands carry file-cache state the persist walk must follow (PHP 8.5) */ + private const int ZEND_DECLARE_ATTRIBUTED_CONST = 210; + private const int ZEND_OP_DATA = 137; + private const int IS_CONST = 1; /** zend_type bit layout (zend_types.h) - list/name discriminators */ private const int TYPE_LIST_BIT = 4194304; // _ZEND_TYPE_LIST_BIT @@ -523,6 +537,22 @@ private function persistAstNodeBody(int $source, int $copy): void return; } + if ($kind === self::ZEND_AST_OP_ARRAY) { + // PHP 8.5: a static closure compiled into a constant expression; the + // embedded body is persisted the same way a function-table entry is + $node = Core::pointerAtAddress('zend_ast_op_array *', $source); + $new = $this->persistFunction($this->ptrValue($node, 'op_array')); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress('zend_ast_op_array *', $copy), 'op_array', $new); + } + + return; + } + if ($kind === self::ZEND_AST_CALLABLE_CONVERT) { + // zend_ast_fcc holds only a ZEND_MAP_PTR slot, which is execution-only + // state copied verbatim with the node bytes + return; + } [$childBase, $count] = $this->astChildren($source, $kind); for ($i = 0; $i < $count; $i++) { $childSource = $this->slotValue($childBase + $i * PHP_INT_SIZE); @@ -553,6 +583,12 @@ private function astNodeSize(int $source): int if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { return Core::sizeOfType('zend_ast_zval'); } + if ($kind === self::ZEND_AST_OP_ARRAY || $kind === self::ZEND_AST_CALLABLE_CONVERT) { + // zend_ast_op_array and zend_ast_fcc share one 16-byte layout + // (kind, attr, lineno, one pointer-sized slot); zend_ast_fcc is not + // in the generated header, so the op_array view sizes both + return Core::sizeOfType('zend_ast_op_array'); + } if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { $list = Core::pointerAtAddress(zend_ast_list::class, $source); @@ -667,10 +703,12 @@ private function persistOpArrayBody(int $source, int $copy): void $this->put($opCopy, 'static_variables', $new); } - $literals = $this->ptrValue($op, 'literals'); + $literals = $this->ptrValue($op, 'literals'); + $literalsNew = 0; if ($literals !== 0) { $zvalSize = Core::sizeOfType(zval::class); [$new, $first] = $this->unit($literals, $op->last_literal * $zvalSize); + $literalsNew = $new; $this->put($opCopy, 'literals', $new); if ($first) { for ($i = 0; $i < $op->last_literal; $i++) { @@ -686,6 +724,7 @@ private function persistOpArrayBody(int $source, int $copy): void [$new, ] = $this->unit($opcodes, $op->last * Core::sizeOfType('zend_op')); $this->put($opCopy, 'opcodes', $new); } + $this->persistAttributedConstOplines($op, $opcodes, $literals, $literalsNew); $argInfo = $this->ptrValue($op, 'arg_info'); if ($argInfo !== 0) { @@ -774,6 +813,50 @@ private function persistOpArrayBody(int $source, int $copy): void // file-form values (NULL, or the shared-body -1 refcount marker) } + /** + * Persists the attribute tables reachable only from ZEND_DECLARE_ATTRIBUTED_CONST oplines. + * + * PHP 8.5 compiles a global `const` carrying attributes to ZEND_DECLARE_ATTRIBUTED_CONST + * followed by a ZEND_OP_DATA whose op1 literal is an IS_PTR zval holding the compiled + * attribute HashTable. The literal walk skips IS_PTR zvals, so - exactly like + * {@see PayloadRelocator::walkAttributedConstOplines()} on the offset-encoding side - + * the table is persisted from the opline pair, otherwise the rebuilt image would keep + * a pointer into the source graph. The operand is read as a literal INDEX because + * payload oplines are file-form (see the opcodes copy above). + * + * @param object $op source zend_op_array view + * @param int $opcodes source opcodes address, 0 when null + * @param int $literals source literals address, 0 when null + * @param int $literalsNew literals copy address (0 in the measure pass) + */ + private function persistAttributedConstOplines(object $op, int $opcodes, int $literals, int $literalsNew): void + { + /** @var zend_op_array $op Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $count = (int) $op->last; + if ($opcodes === 0 || $literals === 0 || $count < 2) { + return; + } + $oplineSize = Core::sizeOfType('zend_op'); + $zvalSize = Core::sizeOfType(zval::class); + // The pair is (DECLARE_ATTRIBUTED_CONST, OP_DATA), so the scan starts at index 1 + for ($i = 1; $i < $count; $i++) { + $opline = Core::pointerAtAddress(zend_op::class, $opcodes + $i * $oplineSize); + if ($opline->opcode !== self::ZEND_OP_DATA || $opline->op1_type !== self::IS_CONST) { + continue; + } + $previous = Core::pointerAtAddress(zend_op::class, $opcodes + ($i - 1) * $oplineSize); + if ($previous->opcode !== self::ZEND_DECLARE_ATTRIBUTED_CONST) { + continue; + } + $index = $opline->op1->constant; + $literal = Core::pointerAtAddress(zval::class, $literals + $index * $zvalSize); + $literalCopy = $this->phase === 2 + ? Core::pointerAtAddress(zval::class, $literalsNew + $index * $zvalSize) + : $literal; + $this->persistAttributes($literal->value, $literalCopy->value, 'ptr'); + } + } + // --- classes (zend_persist_class_entry, the non-LINKED branch) --------------------------- private function persistClassEntry(int $source): int From dd4227a24f85c8c242a5677df1b716e4d4212f68 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:22:38 +0000 Subject: [PATCH 27/28] Generate windows FFI engine definitions for PHP 8.5 (#59) --- include/8.5/windows-x64-nts/engine.h | 3 +++ include/8.5/windows-x64-zts/engine.h | 3 +++ 2 files changed, 6 insertions(+) diff --git a/include/8.5/windows-x64-nts/engine.h b/include/8.5/windows-x64-nts/engine.h index 8b589c31..15c57386 100644 --- a/include/8.5/windows-x64-nts/engine.h +++ b/include/8.5/windows-x64-nts/engine.h @@ -1057,6 +1057,7 @@ extern zval * __vectorcall zend_hash_index_find(const HashTable *, zend_ulong); extern void __vectorcall zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void __vectorcall zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * __vectorcall zend_objects_new(zend_class_entry *); extern void __vectorcall zend_object_std_init(zend_object *, zend_class_entry *); @@ -1102,4 +1103,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.5/windows-x64-zts/engine.h b/include/8.5/windows-x64-zts/engine.h index 9273461e..0dc5da5f 100644 --- a/include/8.5/windows-x64-zts/engine.h +++ b/include/8.5/windows-x64-zts/engine.h @@ -1059,6 +1059,7 @@ extern zval * __vectorcall zend_hash_index_find(const HashTable *, zend_ulong); extern void __vectorcall zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void __vectorcall zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * __vectorcall zend_objects_new(zend_class_entry *); extern void __vectorcall zend_object_std_init(zend_object *, zend_class_entry *); @@ -1105,4 +1106,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; From 9e0ab94fe355eb14d658805b77bf5bdf5da02b09 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:23:03 +0000 Subject: [PATCH 28/28] Generate darwin FFI engine definitions for PHP 8.5 (#58) --- include/8.5/darwin-arm64-nts/engine.h | 3 +++ include/8.5/darwin-arm64-zts/engine.h | 3 +++ include/8.5/darwin-x64-nts/engine.h | 3 +++ 3 files changed, 9 insertions(+) diff --git a/include/8.5/darwin-arm64-nts/engine.h b/include/8.5/darwin-arm64-nts/engine.h index e3479506..abfc0b08 100644 --- a/include/8.5/darwin-arm64-nts/engine.h +++ b/include/8.5/darwin-arm64-nts/engine.h @@ -1037,6 +1037,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); @@ -1083,4 +1084,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.5/darwin-arm64-zts/engine.h b/include/8.5/darwin-arm64-zts/engine.h index 140a3448..a7d6ea35 100644 --- a/include/8.5/darwin-arm64-zts/engine.h +++ b/include/8.5/darwin-arm64-zts/engine.h @@ -1039,6 +1039,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); @@ -1086,4 +1087,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.5/darwin-x64-nts/engine.h b/include/8.5/darwin-x64-nts/engine.h index a7b3fd6e..81dd2ddd 100644 --- a/include/8.5/darwin-x64-nts/engine.h +++ b/include/8.5/darwin-x64-nts/engine.h @@ -1037,6 +1037,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); @@ -1083,4 +1084,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32];