From 54719582bcffe161620fd5b708bbba274aeb1493 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:28:46 +0000 Subject: [PATCH 01/14] feat(reflection): preserve the previous body of redefine() under an alias and accept method donors redefine() now takes `\Closure|FunctionLikeInterface $newBody` and an optional `?string $preserveAs`: - a user method of another class (the donor) can be adopted as the new body in place, exactly like a closure body; the entry keeps its identity (name, scope, prototype, visibility, static/final flags) while the compiled body, signature, attributes and doc comment follow the donor; - with `preserveAs` the previous body is published before the swap as a private method of the declaring class (ZEND_ACC_CHANGED when an ancestor declares a private method of that name, shared into subclasses that were linked before) or as a second global function, so it stays callable and can be swapped back in with a later redefine(). ReflectionClass::addMethod() accepts a method donor as well and publishes an override of an inherited method with the engine's inheritance rules (prototype kept, no final override, no visibility narrowing, same static-ness, parent-private redeclaration marked CHANGED); the class's magic slots and the method tables of subclasses linked before follow the override. FunctionBodySwap allocates the fresh run-time cache of a swapped or published entry from request memory WITHOUT ZEND_ACC_HEAP_RT_CACHE and releases it on the next swap of the entry: a heap-flagged cache shared by a parent and a subclass method table was efree()d once per table at a destructor-path shutdown (USE_ZEND_ALLOC=0), i.e. for every subclass linked after a swap. New RedefinitionException (named factories) and SharedMemoryException::sharedEntryHasLinkedSubclasses(); docs/hot-swap.md documents method donors, preserveAs, the addMethod() override and the run-time cache rule. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- README.md | 2 + docs/hot-swap.md | 104 +++- src/OpCache/SharedMemoryException.php | 19 + src/Reflection/FunctionBodySwap.php | 142 ++++- src/Reflection/FunctionLikeInterface.php | 5 + src/Reflection/FunctionLikeTrait.php | 279 ++++++--- src/Reflection/MethodPublisher.php | 548 ++++++++++++++++++ src/Reflection/PendingBodySwap.php | 15 +- src/Reflection/RedefinitionException.php | 136 +++++ src/Reflection/ReflectionClass.php | 71 ++- src/Reflection/ReflectionMethod.php | 13 + tests/HotSwap/RedefineAliasMatrixTest.php | 92 +++ .../HotSwap/scripts/redefine-alias-matrix.php | 118 ++++ .../Reflection/RedefinePreserveAliasTest.php | 333 +++++++++++ tests/Stub/Alias/Ancestor.php | 30 + tests/Stub/Alias/AncestorDonor.php | 27 + tests/Stub/Alias/Constructed.php | 28 + tests/Stub/Alias/ConstructedDonor.php | 27 + tests/Stub/Alias/Counter.php | 28 + tests/Stub/Alias/CounterDonor.php | 27 + tests/Stub/Alias/Descendant.php | 25 + tests/Stub/Alias/DescendantDonor.php | 27 + tests/Stub/Alias/DescendantOverrideDonor.php | 28 + tests/Stub/Alias/EarlyBase.php | 25 + tests/Stub/Alias/EarlyDonor.php | 27 + tests/Stub/Alias/EarlyOverrideBase.php | 25 + tests/Stub/Alias/EarlyOverrideDonor.php | 25 + tests/Stub/Alias/EarlyOverrideLeaf.php | 19 + tests/Stub/Alias/EarlyOverrideMid.php | 19 + tests/Stub/Alias/EarlySub.php | 19 + tests/Stub/Alias/GrandDescendant.php | 19 + tests/Stub/Alias/Greeter.php | 32 + tests/Stub/Alias/GreeterDonor.php | 36 ++ tests/Stub/Alias/Invalid.php | 35 ++ tests/Stub/Alias/InvalidAbstract.php | 22 + tests/Stub/Alias/OverrideBase.php | 37 ++ tests/Stub/Alias/OverrideDerived.php | 25 + tests/Stub/Alias/OverrideDerivedSub.php | 19 + tests/Stub/Alias/OverrideDonor.php | 35 ++ tests/Stub/Alias/OverrideSealedDonor.php | 28 + tests/Stub/Alias/Secretive.php | 35 ++ tests/Stub/Alias/SecretiveDonor.php | 30 + tests/Stub/Alias/Statics.php | 25 + tests/Stub/Alias/StaticsChild.php | 19 + tests/Stub/Alias/StaticsDonor.php | 27 + tests/Stub/Alias/Twice.php | 25 + tests/Stub/Alias/functions.php | 20 + 47 files changed, 2636 insertions(+), 116 deletions(-) create mode 100644 src/Reflection/MethodPublisher.php create mode 100644 src/Reflection/RedefinitionException.php create mode 100644 tests/HotSwap/RedefineAliasMatrixTest.php create mode 100644 tests/HotSwap/scripts/redefine-alias-matrix.php create mode 100644 tests/Reflection/RedefinePreserveAliasTest.php create mode 100644 tests/Stub/Alias/Ancestor.php create mode 100644 tests/Stub/Alias/AncestorDonor.php create mode 100644 tests/Stub/Alias/Constructed.php create mode 100644 tests/Stub/Alias/ConstructedDonor.php create mode 100644 tests/Stub/Alias/Counter.php create mode 100644 tests/Stub/Alias/CounterDonor.php create mode 100644 tests/Stub/Alias/Descendant.php create mode 100644 tests/Stub/Alias/DescendantDonor.php create mode 100644 tests/Stub/Alias/DescendantOverrideDonor.php create mode 100644 tests/Stub/Alias/EarlyBase.php create mode 100644 tests/Stub/Alias/EarlyDonor.php create mode 100644 tests/Stub/Alias/EarlyOverrideBase.php create mode 100644 tests/Stub/Alias/EarlyOverrideDonor.php create mode 100644 tests/Stub/Alias/EarlyOverrideLeaf.php create mode 100644 tests/Stub/Alias/EarlyOverrideMid.php create mode 100644 tests/Stub/Alias/EarlySub.php create mode 100644 tests/Stub/Alias/GrandDescendant.php create mode 100644 tests/Stub/Alias/Greeter.php create mode 100644 tests/Stub/Alias/GreeterDonor.php create mode 100644 tests/Stub/Alias/Invalid.php create mode 100644 tests/Stub/Alias/InvalidAbstract.php create mode 100644 tests/Stub/Alias/OverrideBase.php create mode 100644 tests/Stub/Alias/OverrideDerived.php create mode 100644 tests/Stub/Alias/OverrideDerivedSub.php create mode 100644 tests/Stub/Alias/OverrideDonor.php create mode 100644 tests/Stub/Alias/OverrideSealedDonor.php create mode 100644 tests/Stub/Alias/Secretive.php create mode 100644 tests/Stub/Alias/SecretiveDonor.php create mode 100644 tests/Stub/Alias/Statics.php create mode 100644 tests/Stub/Alias/StaticsChild.php create mode 100644 tests/Stub/Alias/StaticsDonor.php create mode 100644 tests/Stub/Alias/Twice.php create mode 100644 tests/Stub/Alias/functions.php diff --git a/README.md b/README.md index 6450463b..6bfcef38 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,8 @@ $class->addMethod('count', fn() => 42); // add a method from a closure $method = new ReflectionMethod(Service::class, 'handle'); $method->setPublic(); $method->redefine(fn() => 'patched'); // swap the method body +$method->redefine(fn() => 'wrapped:' . $this->handleOriginal(), preserveAs: 'handleOriginal'); // ...keeping the previous body callable +$method->redefine($donor->getMethod('handle')); // or adopt the body of another class's method ``` ### Generated functions — no FFI trampoline diff --git a/docs/hot-swap.md b/docs/hot-swap.md index e6fca566..5b3823f1 100644 --- a/docs/hot-swap.md +++ b/docs/hot-swap.md @@ -50,6 +50,108 @@ dispatch the new body. The implementation is centralized in The internal-function branch is unchanged: an internal function's `handler` is replaced with a trampoline that calls the closure. +### Method donors + +`redefine()` also accepts another **user method or function** as the donor +(`\Closure|FunctionLikeInterface`): the body of a method compiled in a separate +"donor" class is adopted the same way a closure body is. + +```php +$live = new ReflectionClass(Service::class); +$donor = new ReflectionClass(ServiceDonor::class); +$live->getMethod('handle')->redefine($donor->getMethod('handle')); +``` + +- The donor must carry the entry's **exact signature** (compared as the engine + resolved it: parameter and return types, by-reference passing, variadics) and + its static-ness, and may not be abstract or internal. +- `self`/`parent` types are resolved to the **donor's** declaring class by the + compiler and keep enforcing it after the swap, so a donor spells the entry's + class explicitly where the entry says `self`; only `static` is late-bound. +- `self::`, `parent::`, `static::` and `$this` inside the donor body resolve + against the entry's class at run time (the body runs in the entry's scope); + `self::class` and `__CLASS__` are compile-time literals of the donor class. +- The signature (`arg_info`, including parameter defaults), the attributes, the + doc comment, the file name and the line numbers all travel with the donor body. + +### Preserving the previous body (`preserveAs`) + +`redefine($newBody, preserveAs: 'name')` keeps the previous body callable: it is +published under that name **before** the swap, as a private method of the +declaring class (or as a second global function for a free function). The alias +is a regular entry with the previous body, signature and static-ness - a backup +the new body can call, and that can be swapped back in: + +```php +$method = new ReflectionMethod(Service::class, 'handle'); +$method->redefine($donor->getMethod('handle'), preserveAs: 'handleOriginal'); // swap in +// ... the new body may call $this->handleOriginal(...) ... +$method->redefine($live->getMethod('handleOriginal')); // swap back +$live->removeMethods('handleOriginal'); // drop the backup +``` + +What the alias publication does, in engine terms: + +- The alias is a new `zend_function` container allocated from **request + memory** (the engine never frees user function containers itself, exactly + like its own arena-allocated op_arrays), adopting the previous body with its + own body share and name - so the previous body survives the swap that + destroys the entry's share. Per-request re-publication (an FPM worker + redefining on every request) therefore stays memory-flat across requests. +- It is **private**, never a constructor (`ZEND_ACC_CTOR` cleared) and never + final. When an ancestor already declares a private method of the alias name, + the alias carries `ZEND_ACC_CHANGED` exactly like a redeclared private method, + so the ancestor's own body still resolves the ancestor's alias on instances + of the subclass (`zend_get_parent_private_method`). +- Subclasses that were **linked before** and inherit the entry receive the + alias in their method tables too, with the ownership an inherited bucket + holds (`zend_duplicate_function` semantics) - private methods are inherited + into child tables at link time, and the swapped body propagating through the + shared entry pointer would otherwise call an alias the subclass lacks. +- Refused with `RedefinitionException` (nothing modified): an alias name the + class already declares (a second preserving `redefine()` of the same method + needs another name, or the first alias restored/removed first), an alias name + colliding with an inherited **non-private** method, an invalid name, a global + function name that already exists, and an internal entry (no op_array to + publish). +- Under opcache the declaring class is copied out of shared memory first (see + below); publishing is refused with `SharedMemoryException` while a subclass + linked against the **shared** entry exists, because that subclass keeps the + shared parent and could never see the alias. Redefine before subclasses link. + +### Overriding an inherited method (`addMethod()` with a donor) + +`ReflectionClass::addMethod()` accepts a donor method as well. For a name the +class only **inherits**, the donor body is published as an own entry that +overrides the inherited one with the engine's inheritance rules: the prototype +is kept (reflection and `parent::` keep working), a final method cannot be +overridden, visibility cannot be narrowed, static-ness must agree, and a method +that is private in the ancestor is merely redeclared (`ZEND_ACC_CHANGED`). The +class's magic slots that referenced the inherited entry (constructor, `__get`, +...) follow the override, and so do the method tables and magic slots of +subclasses linked before that still inherited the ancestor's entry. A name the +class already declares itself is refused - that is `redefine()`'s job. + +```php +// Child inherits handle() from Base; the donor reaches the base body through parent:: +$childDonor = new ReflectionClass(ChildDonor::class); // class ChildDonor extends Base { ... parent::handle() ... } +(new ReflectionClass(Child::class))->addMethod('handle', $childDonor->getMethod('handle')); +``` + +### Run-time caches of swapped and published entries + +Every swapped entry and every published container gets a fresh, zeroed run-time +cache allocated from request memory and referenced **without** +`ZEND_ACC_HEAP_RT_CACHE`. A heap-flagged cache is `efree()`d by +`destroy_op_array` before its refcount check - once per method table that +publishes the structure - so an entry shared by a parent and a subclass table +(every subclass linked after a swap, every alias inherited by a subclass) would +have the same block freed twice at a destructor-path shutdown. The cache is +therefore never left to the engine: the next swap of the same entry releases it +(repeated swaps stay memory-flat) and the request end reclaims the rest. It is +never left `NULL` either: a call site whose inline cache already resolved the +entry skips the lazy initialization. + ## Class hot-swap (`HotSwap` / `ClassDelta`) ```php @@ -129,7 +231,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. 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 **class**: method `redefine()` (with or without `preserveAs`), `addMethod()` (closure or donor, including inherited-method overrides), `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) | 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`. | diff --git a/src/OpCache/SharedMemoryException.php b/src/OpCache/SharedMemoryException.php index d0110a6f..da8e1424 100644 --- a/src/OpCache/SharedMemoryException.php +++ b/src/OpCache/SharedMemoryException.php @@ -82,6 +82,25 @@ public static function functionNotPublished(string $lowerKey): self return new self("Cannot copy out function {$lowerKey}: it is not published in the given table"); } + /** + * A publication into the method table of a shared-memory class would be invisible to + * a subclass that was linked against the shared entry before the copy-out + * + * Copying the class out of shared memory repoints the class-table bucket at a + * per-process copy, but a subclass linked earlier keeps the shared entry as its parent + * and in its inherited method buckets: nothing published into the copy ever reaches + * it (see the copy-out caveats in docs/hot-swap.md). Weave before subclasses link. + */ + public static function sharedEntryHasLinkedSubclasses(string $className, string $methodName, string $subclassName): self + { + return new self( + 'Cannot publish into the method table of the immutable (opcache shared-memory) class ' + . "{$className}: its subclass {$subclassName} was linked against the shared entry and " + . "inherits {$methodName}() from it, so the publication would never reach that " + . 'subclass - perform the mutation before the subclass is linked', + ); + } + /** * Handler installation targeted a temporary lazy-linking class copy while declining * the inheritance cache is unavailable (issues #238/#241) diff --git a/src/Reflection/FunctionBodySwap.php b/src/Reflection/FunctionBodySwap.php index a0381350..1f6ae825 100644 --- a/src/Reflection/FunctionBodySwap.php +++ b/src/Reflection/FunctionBodySwap.php @@ -80,6 +80,18 @@ final class FunctionBodySwap | Core::ZEND_ACC_STRICT_TYPES | Core::ZEND_ACC_IMMUTABLE; + /** + * Run-time caches minted by swaps and publications, keyed by entry address + * + * A minted cache is request memory the entry refers to WITHOUT the + * ZEND_ACC_HEAP_RT_CACHE flag (see installFreshRunTimeCache()), so the engine never + * frees it: the next swap of the same entry, or the rollback of the swap that minted + * it, releases it here - which keeps repeated swaps memory-flat. + * + * @var array Entry address => `void **` view of the cache block + */ + private static array $mintedRunTimeCaches = []; + /** * This is an utility class, no instances needed */ @@ -157,6 +169,9 @@ public static function swapUserFunctionBody( for ($share = 0; $share < $publishedShares; $share++) { self::addBodyReference($entryFunction); } + // The cache minted by an earlier swap of this entry is released when this swap + // commits (or kept, when this swap is rolled back) + $previousMintedCache = self::$mintedRunTimeCaches[$entryAddress] ?? null; self::installFreshRunTimeCache($entryFunction); self::unshareStaticVariables($entryFunction); @@ -166,6 +181,7 @@ public static function swapUserFunctionBody( $entryAddress, $publishedShares, $destroyPrevious, + $previousMintedCache, ); } @@ -181,17 +197,34 @@ public static function swapUserFunctionBody( * @internal */ public static function countPublishedShares(FunctionLikeInterface $entryFunction): int + { + return max(count(self::publishedShareHolders($entryFunction)), 1); + } + + /** + * Returns the user classes whose method tables publish the given entry structure + * under its own name: the declaring class and every subclass that inherited the + * entry at link time (see countPublishedShares() for the accounting this implies) + * + * A structure published in no class table (a plain function entry) has no holders. + * Class aliases resolve to the same class entry and are deduplicated. + * + * @return list + * + * @internal + */ + public static function publishedShareHolders(FunctionLikeInterface $entryFunction): array { $entryCommon = $entryFunction->getCommonPointer(); if ($entryCommon->scope === null) { - return 1; + return []; } $namePointer = $entryCommon->function_name; assert($namePointer instanceof CData); $lowerName = strtolower(StringEntry::fromCData($namePointer)->getStringValue()); $entryAddress = $entryFunction->getAddress(); - $shares = 0; + $holders = []; $seenClasses = []; foreach (Core::$executor->classTable as $classValue) { try { @@ -212,11 +245,11 @@ public static function countPublishedShares(FunctionLikeInterface $entryFunction } $bucketValue = $reflectionClass->getMethodTable()->find($lowerName); if ($bucketValue !== null && Core::addressOf($bucketValue->getRawFunction()) === $entryAddress) { - $shares++; + $holders[] = $reflectionClass; } } - return max($shares, 1); + return $holders; } /** @@ -230,17 +263,20 @@ public static function countPublishedShares(FunctionLikeInterface $entryFunction * * @param CData $container zend_function container to fill (writable, zeroed or reused) * @param FunctionLikeInterface $donor Donor method whose body is adopted - * @param ReflectionClass $newScope Class the published method will belong to + * @param ReflectionClass|null $newScope Class the published method will belong to, or null + * for a global function entry * * @internal */ - public static function adoptFunctionForPublishing(object $container, FunctionLikeInterface $donor, ReflectionClass $newScope): void + public static function adoptFunctionForPublishing(object $container, FunctionLikeInterface $donor, ?ReflectionClass $newScope): void { Core::memcpy($container, $donor->getEntryPointer(), Core::sizeOfType(zend_function::class)); $containerFunction = ReflectionFunction::fromCData(Core::cast(zend_function::class, Core::addr($container))); $containerCommon = $containerFunction->getCommonPointer(); - $containerCommon->scope = Core::cast(zend_class_entry::class, $newScope->getRawValue()); + $containerCommon->scope = $newScope === null + ? null + : Core::cast(zend_class_entry::class, $newScope->getRawValue()); // The published bucket owns one reference on the name and one body share $namePointer = $containerCommon->function_name; @@ -338,25 +374,54 @@ private static function counterValue(object $counterPointer): int * * The donor's cache is scope-dependent (inline caches for self::/parent:: and * property slots assume the donor's scope) and owned by the donor, so it must - * never be shared. A heap cache (ZEND_ACC_HEAP_RT_CACHE) is released by - * destroy_op_array together with the entry - and by the next swap's - * previous-body destruction, which keeps repeated swaps memory-flat. + * never be shared. + * + * The fresh cache is a request-memory block the entry refers to WITHOUT the + * ZEND_ACC_HEAP_RT_CACHE flag - the shape the engine gives ordinary compiled + * functions (an arena block, released with the request). A heap-flagged cache is + * efree()d by destroy_op_array BEFORE its refcount check, i.e. once per bucket that + * publishes the structure: an entry shared by a parent and a subclass table (every + * subclass linked after a swap, every published alias inherited by a subclass) would + * have the same block freed once per table at shutdown. So the block is never left + * to the engine; the next swap of the same entry releases it (keeping repeated swaps + * memory-flat) and the request end reclaims whatever is left. It is never left NULL + * either: a call site whose inline cache already resolved the entry skips the lazy + * initialization and dereferences the cache unconditionally. */ private static function installFreshRunTimeCache(FunctionLikeInterface $entryFunction): void { - $opArray = $entryFunction->getOpArrayPointer(); - $entryCommon = $entryFunction->getCommonPointer(); - $cacheSize = $opArray->cache_size; + $opArray = $entryFunction->getOpArrayPointer(); + $entryCommon = $entryFunction->getCommonPointer(); + $entryAddress = $entryFunction->getAddress(); + $cacheSize = $opArray->cache_size; if ($cacheSize > 0) { - // Request-allocator block handed over to the engine: destroy_op_array - // releases it through efree, matching the allocation exactly - $cache = Core::new("char[{$cacheSize}]", false); - $opArray->run_time_cache__ptr = Core::cast('void **', $cache); - $entryCommon->fn_flags |= Core::ZEND_ACC_HEAP_RT_CACHE; + $cache = Core::cast('void **', Core::new("char[{$cacheSize}]", false)); + $opArray->run_time_cache__ptr = $cache; + self::$mintedRunTimeCaches[$entryAddress] = $cache; } else { $opArray->run_time_cache__ptr = null; - $entryCommon->fn_flags &= (~Core::ZEND_ACC_HEAP_RT_CACHE); + unset(self::$mintedRunTimeCaches[$entryAddress]); } + $entryCommon->fn_flags &= (~Core::ZEND_ACC_HEAP_RT_CACHE); + } + + /** + * Releases the run-time cache an earlier swap minted for the entry, once the entry + * executes a body with its own fresh cache + * + * Kept allocated while a frame of the entry still runs on it (the same rule as for + * the previous body, see destroyPreviousBody()). + * + * @param CData|null $previousMintedCache `void **` view recorded before the swap + * + * @internal called by PendingBodySwap::commit() + */ + public static function releaseMintedRunTimeCache(int $entryAddress, ?CData $previousMintedCache): void + { + if ($previousMintedCache === null || self::hasLiveFrame($entryAddress)) { + return; + } + Core::free($previousMintedCache); } /** @@ -407,13 +472,19 @@ private static function unshareStaticVariables(FunctionLikeInterface $entryFunct * refcount - shared bodies (a live template op_array, a fake closure over the * old body) keep their arrays until the last holder dies. * - * @param CData $previousBody zend_function snapshot taken by the swap - * @param int $releasedShares Total bucket shares the entry held on the previous body + * @param CData $previousBody zend_function snapshot taken by the swap + * @param int $releasedShares Total bucket shares the entry held on the previous body + * @param CData|null $previousMintedCache Run-time cache minted for the previous body by an + * earlier swap, if any (see installFreshRunTimeCache()) * * @internal called by PendingBodySwap::commit() */ - public static function destroyPreviousBody(object $previousBody, int $entryAddress, int $releasedShares): void - { + public static function destroyPreviousBody( + object $previousBody, + int $entryAddress, + int $releasedShares, + ?CData $previousMintedCache = null, + ): void { $previousFunction = ReflectionFunction::fromCData(Core::cast('zend_function *', Core::addr($previousBody))); $previousOpArray = $previousFunction->getOpArrayPointer(); @@ -426,6 +497,11 @@ public static function destroyPreviousBody(object $previousBody, int $entryAddre // redefinition, see docs/hot-swap.md. return; } + if ($previousMintedCache !== null) { + // The cache of the previous body was minted by z-engine (no heap flag, so + // destroy_op_array below leaves it alone) and nobody reads it anymore + Core::free($previousMintedCache); + } // 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 @@ -498,18 +574,26 @@ private static function hasLiveFrame(int $entryAddress): bool /** * Returns the resources taken by an uncommitted swap and forgets its bookkeeping * + * @param CData|null $previousMintedCache Cache record to reinstate for the entry (the one + * the previous body still refers to), if any + * * @internal called by PendingBodySwap::rollback() while the donor is still alive */ public static function releaseSwappedInBody( FunctionLikeInterface $entryFunction, int $publishedShares, + ?CData $previousMintedCache = null, ): void { - // The fresh run-time cache was installed by the swap and is not published anywhere - $opArray = $entryFunction->getOpArrayPointer(); - $entryFlags = $entryFunction->getCommonPointer()->fn_flags; - $cachePointer = $opArray->run_time_cache__ptr; - if (($entryFlags & Core::ZEND_ACC_HEAP_RT_CACHE) !== 0 && $cachePointer !== null) { - Core::free($cachePointer); + // The fresh run-time cache was minted by the swap and is not published anywhere + $entryAddress = $entryFunction->getAddress(); + $mintedCache = self::$mintedRunTimeCaches[$entryAddress] ?? null; + if ($mintedCache !== null) { + Core::free($mintedCache); + } + if ($previousMintedCache !== null) { + self::$mintedRunTimeCaches[$entryAddress] = $previousMintedCache; + } else { + unset(self::$mintedRunTimeCaches[$entryAddress]); } // The defaults table was never duplicated, so the rollback owes nothing for it: // restoring the previous body puts the entry's own pointer back in place diff --git a/src/Reflection/FunctionLikeInterface.php b/src/Reflection/FunctionLikeInterface.php index cd2058e9..6a1b7ebb 100644 --- a/src/Reflection/FunctionLikeInterface.php +++ b/src/Reflection/FunctionLikeInterface.php @@ -52,6 +52,11 @@ public function getEntryPointer(): object; public function isUserDefined(): bool; + /** + * Pointer-based name of the entry, null for unnamed entries (see FunctionLikeTrait::getFunctionName()) + */ + public function getFunctionName(): ?string; + /** * Compiled-variable names by CV slot (see FunctionLikeTrait::getVariableNames()) * diff --git a/src/Reflection/FunctionLikeTrait.php b/src/Reflection/FunctionLikeTrait.php index 79c46b34..3cf8e7fa 100644 --- a/src/Reflection/FunctionLikeTrait.php +++ b/src/Reflection/FunctionLikeTrait.php @@ -171,34 +171,99 @@ private function replaceFunctionFlags(int $clearMask, int $setMask): void } /** - * Redefines an existing method in the class with closure - * - * The previous function body is destroyed with engine semantics (the entry keeps - * one owned share of the new closure body instead) - repeated redefinitions of - * the same entry are memory-flat, see FunctionBodySwap. An opcache-shared - * (ZEND_ACC_IMMUTABLE) global function is first copied out of shared memory into - * a writable entry (its SHM body stays allocated, never freed); a method of an - * opcache-shared class is redefined on the writable copy of its declaring class, - * which is copied out of shared memory the same way - see docs/hot-swap.md for - * the matrix and the copy-out caveats. - * - * @internal + * Replaces the body of this function/method with the body of a closure or of another + * user function/method (the donor), in place + * + * The entry keeps its identity - name, scope, prototype, visibility, static and final + * flags - while the compiled body, the signature (arg_info), the attributes and the doc + * comment follow the donor. The published zend_function pointer never changes, so + * warmed-up inline caches, subclass method buckets and prototype links stay valid and + * dispatch the new body immediately. A donor must carry the entry's exact signature and + * its static-ness, and may not be abstract or internal. `self`/`parent` types are + * resolved to the DONOR's declaring class by the compiler and would keep enforcing it, + * so a donor spells the entry's class explicitly where the entry says `self`. + * + * With `$preserveAs` the PREVIOUS body is kept callable: it is published under that name + * before the swap - as a private method of the declaring class, carrying ZEND_ACC_CHANGED + * when an ancestor already declares a private method of that name, and inherited by every + * subclass linked earlier; or as a second global function. The alias is a regular entry: + * `redefine()` with the alias as the donor swaps the previous body back in, and + * `ReflectionClass::removeMethods()` drops the alias again. An alias name the class + * already declares is refused, so a second preserving redefine() of the same method needs + * a different name (or the first alias restored/removed first). + * + * The previous body is destroyed with engine semantics (the entry keeps one owned share + * of the new body instead, and a preserved alias keeps one of the previous one) - + * repeated redefinitions of the same entry are memory-flat, see FunctionBodySwap. An + * opcache-shared (ZEND_ACC_IMMUTABLE) global function is first copied out of shared + * memory into a writable entry (its SHM body stays allocated, never freed); a method of + * an opcache-shared class is redefined on the writable copy of its declaring class, + * which is copied out of shared memory the same way - see docs/hot-swap.md for the matrix + * and the copy-out caveats. Preserving an alias on a shared-memory class is refused while + * a subclass linked against the shared entry exists (it could never see the alias). + * + * An internal function keeps the handler-trampoline path: only a closure can redefine + * it, and its body cannot be preserved under an alias. + * + * @param \Closure|FunctionLikeInterface $newBody Closure, or a user method/function whose body is adopted + * @param string|null $preserveAs Name to publish the previous body under + * + * @throws RedefinitionException When the donor or the alias cannot be accepted (nothing was modified) + * @throws SharedMemoryException When the entry cannot be copied out of shared memory */ - public function redefine(\Closure $newCode): void + public function redefine(\Closure|FunctionLikeInterface $newBody, ?string $preserveAs = null): void { - $this->ensureCompatibleClosure($newCode); + if ($this->isInternal()) { + if (!$newBody instanceof \Closure) { + throw RedefinitionException::internalEntryRequiresClosure($this->getName()); + } + if ($preserveAs !== null) { + throw RedefinitionException::aliasUnsupportedForInternalEntry($this->getName()); + } + $this->ensureCompatibleSignature(new \ReflectionFunction($newBody)); - if (!$this->isInternal()) { + // For internal function we can simply adjust a handler + /** @var zend_internal_function $internalEntry Internal entries hold the internal view */ + $internalEntry = $this->pointer; + $internalEntry->handler = function (CData $executeData, CData $returnValue) use ($newBody): void { + $rawValue = ReflectionValue::fromValueEntry($returnValue); + $stackTrace = debug_backtrace(0, 2); + $result = $newBody(...$stackTrace[1]['args']); + $rawValue->setNativeValue($result); + }; + + return; + } + + if ($newBody instanceof \Closure) { + $this->ensureCompatibleSignature(new \ReflectionFunction($newBody)); $selfExecutionState = Core::$executor->getExecutionState(); $newCodeEntry = $selfExecutionState->getArgument(0)->getRawObject(); $closureEntry = ClosureEntry::fromCData(Core::cast('zend_closure *', $newCodeEntry)); - $newFunction = $closureEntry->getRawFunction(); + $donorFunction = ReflectionFunction::fromCData(Core::addr($closureEntry->getRawFunction())); + } else { + $this->ensureAcceptableDonor($newBody); + $donorFunction = $newBody; + } - $isSharedMemoryEntry = $this->copyEntryOutOfSharedMemory(); + if ($preserveAs !== null) { + // Checked before the copy-out: a subclass linked against the shared entry could + // never see an alias published into the per-process copy + MethodPublisher::assertAliasPublishable($this); + } + $isSharedMemoryEntry = $this->copyEntryOutOfSharedMemory(); + $entryFunction = ReflectionFunction::fromCData($this->pointer); - $entryFunction = ReflectionFunction::fromCData($this->pointer); - $donorFunction = ReflectionFunction::fromCData(Core::addr($newFunction)); - FunctionBodySwap::swapUserFunctionBody( + $unpublishAlias = null; + if ($preserveAs !== null) { + // The alias takes its own share of the previous body BEFORE the swap releases + // the entry's share, which is what keeps the body alive through the swap + [, $unpublishAlias] = $entryFunction->getCommonPointer()->scope !== null + ? MethodPublisher::publishMethodAlias($entryFunction, $preserveAs) + : MethodPublisher::publishFunctionAlias($entryFunction, $preserveAs); + } + try { + $pendingSwap = FunctionBodySwap::swapUserFunctionBody( $entryFunction, $donorFunction, preserveDeclaration: true, @@ -207,18 +272,75 @@ public function redefine(\Closure $newCode): void // Subclass method tables may share this very structure - every such // bucket releases one body reference when the engine destroys it publishedShares: FunctionBodySwap::countPublishedShares($entryFunction), - )->commit(); - } else { - // For internal function we can simply adjust a handler - /** @var zend_internal_function $internalEntry Internal entries hold the internal view */ - $internalEntry = $this->pointer; - $internalEntry->handler = function (CData $executeData, CData $returnValue) use ($newCode): void { - $rawValue = ReflectionValue::fromValueEntry($returnValue); - $stackTrace = debug_backtrace(0, 2); - $result = $newCode(...$stackTrace[1]['args']); - $rawValue->setNativeValue($result); - }; + ); + } catch (\Throwable $error) { + if ($unpublishAlias !== null) { + $unpublishAlias(); + } + throw $error; } + $pendingSwap->commit(); + } + + /** + * Marks or unmarks this entry as redeclaring a method that is private in an ancestor + * (ZEND_ACC_CHANGED): the engine then resolves the ancestor's own private method for + * calls made from the ancestor's scope on instances of this class + * + * @internal + */ + public function setChangedFlag(bool $isChanged): void + { + $this->setFunctionFlag(Core::ZEND_ACC_CHANGED, $isChanged); + } + + /** + * Marks or unmarks this entry as a constructor (ZEND_ACC_CTOR) + * + * @internal + */ + public function setConstructorFlag(bool $isConstructor): void + { + $this->setFunctionFlag(Core::ZEND_ACC_CTOR, $isConstructor); + } + + /** + * Checks that a user method/function can serve as the donor body of this entry + * + * @throws RedefinitionException + */ + private function ensureAcceptableDonor(FunctionLikeInterface $donor): void + { + $entryName = $this->getName(); + $donorName = MethodPublisher::entryName($donor); + if (!$donor->isUserDefined()) { + throw RedefinitionException::donorIsInternal($donorName); + } + if ($donor->getAddress() === $this->getAddress()) { + throw RedefinitionException::donorIsEntry($entryName); + } + $donorFlags = $donor->getCommonPointer()->fn_flags; + $entryFlags = $this->getCommonPointer()->fn_flags; + assert(is_int($donorFlags) && is_int($entryFlags)); + if (($donorFlags & Core::ZEND_ACC_ABSTRACT) !== 0) { + throw RedefinitionException::donorIsAbstract($donorName); + } + if (($donorFlags & Core::ZEND_ACC_STATIC) !== ($entryFlags & Core::ZEND_ACC_STATIC)) { + throw RedefinitionException::staticModifierMismatch($entryName, $donorName); + } + if (!$donor instanceof \ReflectionFunctionAbstract) { + throw RedefinitionException::donorNotIntrospectable($donorName, new \LogicException('not a reflection object')); + } + // Pointer-level wrappers (fromRawEntry) carry no native reflection state: the native + // `name` property is only initialized by the native constructor, and the signature + // readers below would fail with an engine error on such a wrapper + if (!(new \ReflectionProperty(\ReflectionFunctionAbstract::class, 'name'))->isInitialized($donor)) { + throw RedefinitionException::donorNotIntrospectable( + $donorName, + new \LogicException('the native reflection state of the donor is not initialized'), + ); + } + $this->ensureCompatibleSignature($donor); } /** @@ -689,60 +811,63 @@ public function getLiveRanges(): array abstract protected function getHash(): string; /** - * Checks if the given closure signature is compatible to original one (number of arguments, type hints, etc) + * Checks that the donor signature matches this entry's (parameters, by-reference passing, + * variadics, types and the return type) + * + * Types are compared as the engine resolved them at compile time. That is deliberate for + * `self`/`parent` types: the compiler resolves them to the declaring class of the DONOR, + * and the adopted arg_info keeps enforcing that class at run time - a donor must spell + * the entry's class explicitly where the entry says `self` (only `static` is late-bound). * - * @throws \ReflectionException if closure signature is not compatible with current function/method + * @throws RedefinitionException if the donor signature is not compatible with this entry + */ + private function ensureCompatibleSignature(\ReflectionFunctionAbstract $donor): void + { + $entrySignature = self::renderSignature($this); + $donorSignature = self::renderSignature($donor); + + if ($entrySignature !== $donorSignature) { + throw RedefinitionException::signatureMismatch($entrySignature, $donorSignature); + } + } + + /** + * Renders a comparable signature string of a function */ - private function ensureCompatibleClosure(\Closure $newCode): void + private static function renderSignature(\ReflectionFunctionAbstract $function): string { - /** @var \ReflectionFunction[] $reflectionPair */ - $reflectionPair = [$this, new \ReflectionFunction($newCode)]; - $signatures = []; - foreach ($reflectionPair as $index => $reflectionFunction) { - $signature = 'function '; - if ($reflectionFunction->returnsReference()) { - $signature .= '&'; + $renderType = static fn(\ReflectionType $type): string => (string) $type; + + $signature = 'function '; + if ($function->returnsReference()) { + $signature .= '&'; + } + $signature .= '('; + $parameters = []; + foreach ($function->getParameters() as $reflectionParameter) { + $parameter = ''; + $type = $reflectionParameter->getType(); + if ($type !== null) { + $parameter .= $renderType($type) . ' '; } - $signature .= '('; - $parameters = []; - foreach ($reflectionFunction->getParameters() as $reflectionParameter) { - $parameter = ''; - if ($reflectionParameter->hasType()) { - $type = $reflectionParameter->getType(); - if ($type->allowsNull()) { - $parameter .= '?'; - } - $parameter .= $type->getName() . ' '; - } - if ($reflectionParameter->isPassedByReference()) { - $parameter .= '&'; - } - if ($reflectionParameter->isVariadic()) { - $parameter .= '...'; - } - $parameter .= '$'; - $parameter .= $reflectionParameter->getName(); - $parameters[] = $parameter; + if ($reflectionParameter->isPassedByReference()) { + $parameter .= '&'; } - $signature .= join(', ', $parameters); - $signature .= ')'; - if ($reflectionFunction->hasReturnType()) { - $signature .= ': '; - $type = $reflectionFunction->getReturnType(); - if ($type->allowsNull()) { - $signature .= '?'; - } - $signature .= $type->getName(); + if ($reflectionParameter->isVariadic()) { + $parameter .= '...'; } - $signatures[] = $signature; + $parameter .= '$'; + $parameter .= $reflectionParameter->getName(); + $parameters[] = $parameter; } - - if ($signatures[0] !== $signatures[1]) { - throw new \ReflectionException( - 'Given function signature: "' . $signatures[1] . '"' . - ' should be compatible with original "' . $signatures[0] . '"', - ); + $signature .= join(', ', $parameters); + $signature .= ')'; + $returnType = $function->getReturnType(); + if ($returnType !== null) { + $signature .= ': ' . $renderType($returnType); } + + return $signature; } /** diff --git a/src/Reflection/MethodPublisher.php b/src/Reflection/MethodPublisher.php new file mode 100644 index 00000000..03566725 --- /dev/null +++ b/src/Reflection/MethodPublisher.php @@ -0,0 +1,548 @@ + + * + * 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\Reflection; + +use FFI\CData; +use ZEngine\Core; +use ZEngine\Generated\zend_function; +use ZEngine\OpCache\SharedMemoryException; +use ZEngine\Type\HashTable; +use ZEngine\Type\StringEntry; +use ZEngine\Type\StructArray; + +/** + * Publishes function bodies under additional names in method and function tables + * + * Two operations, both built from the FunctionBodySwap primitives: + * + * - an ALIAS of an existing entry: a second entry executing the entry's current body, + * published in the declaring class as a private method (or as a second global function). + * This is the backup `redefine($newBody, preserveAs: ...)` publishes before it swaps the + * body, so the previous body stays callable and can be swapped back in later; + * - an OVERRIDE of an inherited method: an own entry of a class adopting a donor body under + * a name the class only inherited so far (`addMethod()` with a method donor). + * + * Containers are allocated from request memory (`Core::new(..., owned: false)`): the engine + * never frees user zend_function containers itself (op_arrays are arena-allocated), so a + * request-lifetime block matches its own arena semantics, and every container minted during + * a request goes away with the request - which keeps per-request publication (an FPM worker + * re-publishing on every request) memory-flat across requests. The body behind a container is + * refcounted independently, exactly like for the methods ClassDelta adds. + * + * Both operations stage their table writes with undo actions: a failure in the middle unwinds + * everything published so far before the exception propagates. + * + * @internal used by FunctionLikeTrait::redefine() and ReflectionClass::addMethod() + */ +final class MethodPublisher +{ + private function __construct() {} + + /** + * Refuses an alias publication that a subclass linked against shared memory could never see + * + * Must run BEFORE the declaring class is copied out of shared memory: the copy-out leaves + * every subclass linked earlier on the shared parent entry, so the alias published into + * the copy would never reach them while the swapped body would still call it. + * + * @throws SharedMemoryException + */ + public static function assertAliasPublishable(FunctionLikeInterface $entry): void + { + $scope = $entry->getCommonPointer()->scope; + if ($scope === null) { + return; + } + $declaringClass = ReflectionClass::fromCData($scope); + if (!$declaringClass->isImmutable()) { + return; + } + foreach (FunctionBodySwap::publishedShareHolders($entry) as $holder) { + if ($holder->getAddress() !== $declaringClass->getAddress()) { + throw SharedMemoryException::sharedEntryHasLinkedSubclasses( + $declaringClass->getName(), + self::entryName($entry), + $holder->getName(), + ); + } + } + } + + /** + * Refuses an override publication that a subclass linked against shared memory could never see + * + * Same timing rule as assertAliasPublishable(): a subclass linked against the shared + * entry of $class keeps inheriting the ancestor's method after the copy-out. + * + * @throws SharedMemoryException + */ + public static function assertOverridePublishable(ReflectionClass $class, string $methodName): void + { + if (!$class->isImmutable()) { + return; + } + $inherited = $class->getMethodTable()->find(strtolower($methodName)); + if ($inherited === null) { + return; + } + $inheritedMethod = ReflectionMethod::fromRawEntry($inherited->getRawFunction()); + foreach (self::descendantsHolding($class, $inheritedMethod) as $descendant) { + throw SharedMemoryException::sharedEntryHasLinkedSubclasses( + $class->getName(), + $methodName, + $descendant->getName(), + ); + } + } + + /** + * Publishes the current body of a user method under $alias as a private method of its + * declaring class + * + * The alias is what the engine would have produced for `use Trait { m as private alias; }`: + * an own entry of the class with the same body, signature and static-ness, private, never + * a constructor and never final. When the class inherits a private method of that name + * from an ancestor the alias carries ZEND_ACC_CHANGED, exactly like a redeclared private + * method, so the ancestor's own code still resolves its own alias on instances of this + * class. Subclasses that were linked before and inherit the entry receive the alias in + * their method tables too (private methods are inherited into child tables at link time), + * with the ownership an inherited bucket holds. + * + * @return array{ReflectionMethod, \Closure(): void} The published alias and the action + * unpublishing it again (rollback of a + * failed swap) + */ + public static function publishMethodAlias(FunctionLikeInterface $entry, string $alias): array + { + self::assertValidName($alias); + $scope = $entry->getCommonPointer()->scope; + assert($scope !== null); + $class = ReflectionClass::fromCData($scope); + $className = $class->getName(); + $table = $class->getMethodTable(); + $lowerAlias = strtolower($alias); + $inheritedAlias = self::findInheritedEntry($class, $lowerAlias); + if ($inheritedAlias === false) { + throw RedefinitionException::aliasAlreadyDeclared($className, $alias); + } + if ($inheritedAlias !== null && !self::hasFlag($inheritedAlias, Core::ZEND_ACC_PRIVATE)) { + throw RedefinitionException::aliasCollidesWithInheritedMethod( + $className, + $alias, + self::declaringClassName($inheritedAlias), + ); + } + + // Subclasses whose tables share the entry inherit the alias as well - collected before + // anything is published, while the tables are untouched + $subclasses = array_values(array_filter( + FunctionBodySwap::publishedShareHolders($entry), + static fn(ReflectionClass $holder): bool => $holder->getAddress() !== $class->getAddress(), + )); + + $container = self::newContainer(); + $aliasMethod = self::adopt($container, $entry, $class, $alias); + // A preserved body is a private implementation detail of the class: not a + // constructor, not final, and an override marker only where an ancestor declared a + // private method of the same name + $aliasMethod->setPrivate(); + $aliasMethod->setFinal(false); + $aliasMethod->setConstructorFlag(false); + $aliasMethod->setChangedFlag($inheritedAlias !== null); + $aliasMethod->setPrototype(null); + + /** @var list<\Closure(): void> $undoStack */ + $undoStack = []; + try { + $published = self::publishOwnEntry($table, $lowerAlias, $container, $inheritedAlias, $undoStack); + foreach ($subclasses as $subclass) { + self::shareIntoSubclass($subclass, $lowerAlias, $published, $undoStack); + } + } catch (\Throwable $error) { + self::unwind($undoStack); + self::releaseContainer($container); + throw $error; + } + + $undo = static function () use ($undoStack, $container): void { + self::unwind($undoStack); + self::releaseContainer($container); + }; + + return [ReflectionMethod::fromCData($published->getEntryPointer()), $undo]; + } + + /** + * Publishes the current body of a user function under $alias as a second global function + * + * @return array{ReflectionFunction, \Closure(): void} The published alias and the action + * unpublishing it again + */ + public static function publishFunctionAlias(FunctionLikeInterface $entry, string $alias): array + { + self::assertValidName($alias); + $table = Core::$executor->functionTable; + $lowerAlias = strtolower($alias); + if ($table->find($lowerAlias) !== null) { + throw RedefinitionException::functionAlreadyExists($alias); + } + + $container = self::newContainer(); + self::adopt($container, $entry, null, $alias); + try { + $stored = $table->addFunctionEntry($lowerAlias, $container); + } catch (\Throwable $error) { + self::releaseContainer($container); + throw $error; + } + + $undo = static function () use ($table, $lowerAlias, $container): void { + $table->deleteWithoutDestructor($lowerAlias); + self::releaseContainer($container); + }; + + return [ReflectionFunction::fromCData($stored), $undo]; + } + + /** + * Publishes a donor body as an own method of $class, overriding an inherited entry + * of that name when there is one + * + * The override follows the engine's inheritance rules: it keeps the inherited method's + * prototype (so reflection and `parent::` keep working), may not override a final + * method, may not narrow visibility, must agree on static-ness, and redeclaring a + * method that is private in the ancestor only marks the new entry ZEND_ACC_CHANGED. + * The replaced inherited bucket returns the ownership it held; the class's magic + * slots (constructor, __get, ...) that referenced the inherited entry follow the + * override, and so do the method tables and magic slots of subclasses that were linked + * before and still inherited the ancestor's entry. + */ + public static function publishOverride(ReflectionClass $class, string $methodName, FunctionLikeInterface $donor): ReflectionMethod + { + self::assertValidName($methodName); + $className = $class->getName(); + $table = $class->getMethodTable(); + $lowerName = strtolower($methodName); + $inherited = self::findInheritedEntry($class, $lowerName); + if ($inherited === false) { + throw RedefinitionException::methodAlreadyDeclared($className, $methodName); + } + + $descendants = []; + if ($inherited !== null) { + $declaringClass = self::declaringClassName($inherited); + if (self::hasFlag($inherited, Core::ZEND_ACC_FINAL)) { + throw RedefinitionException::finalMethodOverride($className, $methodName, $declaringClass); + } + if (self::hasFlag($inherited, Core::ZEND_ACC_STATIC) !== self::hasFlag($donor, Core::ZEND_ACC_STATIC)) { + throw RedefinitionException::staticModifierMismatch("{$declaringClass}::{$methodName}", self::entryName($donor)); + } + $isInheritedPrivate = self::hasFlag($inherited, Core::ZEND_ACC_PRIVATE); + if (!$isInheritedPrivate && self::visibilityRank($donor) < self::visibilityRank($inherited)) { + throw RedefinitionException::visibilityNarrowed($className, $methodName, $declaringClass); + } + $descendants = self::descendantsHolding($class, $inherited); + } + + $container = self::newContainer(); + $method = self::adopt($container, $donor, $class, $methodName); + if ($inherited !== null) { + if (self::hasFlag($inherited, Core::ZEND_ACC_PRIVATE)) { + // The engine marks a redeclared parent-private method CHANGED and links no prototype + $method->setChangedFlag(true); + $method->setPrototype(null); + } else { + $method->setChangedFlag(false); + $method->setPrototype(self::prototypeOf($inherited)); + } + } + + /** @var list<\Closure(): void> $undoStack */ + $undoStack = []; + try { + $published = self::publishOwnEntry($table, $lowerName, $container, $inherited, $undoStack); + if ($inherited !== null) { + self::followMagicSlot($class, $inherited, $published, $undoStack); + foreach ($descendants as $descendant) { + self::shareIntoSubclass($descendant, $lowerName, $published, $undoStack); + self::followMagicSlot($descendant, $inherited, $published, $undoStack); + } + } + } catch (\Throwable $error) { + self::unwind($undoStack); + self::releaseContainer($container); + throw $error; + } + + return ReflectionMethod::fromCData($published->getEntryPointer()); + } + + /** + * Allocates a zeroed zend_function container from request memory + * + * Not owned by FFI (never freed when the handle dies) and not persistent: the block is + * reclaimed with the request, like the arena the engine allocates op_arrays from. + * Handed around as the raw handle: the publication APIs take the container by handle + * and the typed view is only ever taken through ReflectionMethod::fromRawEntry(). + */ + private static function newContainer(): CData + { + $container = Core::new(zend_function::class, false); + assert($container instanceof CData); + + return $container; + } + + /** + * Fills a container with the donor body under the given name and scope + */ + private static function adopt(CData $container, FunctionLikeInterface $donor, ?ReflectionClass $scope, string $name): ReflectionMethod + { + FunctionBodySwap::adoptFunctionForPublishing($container, $donor, $scope); + $method = ReflectionMethod::fromRawEntry(Core::cast(zend_function::class, Core::addr($container))); + $method->setFunctionName($name); + + return $method; + } + + /** + * Returns the resources an adopted container took (rollback only: the body stays alive + * through its other holders) + */ + private static function releaseContainer(CData $container): void + { + $containerFunction = ReflectionFunction::fromCData(Core::cast(zend_function::class, Core::addr($container))); + $namePointer = $containerFunction->getCommonPointer()->function_name; + assert($namePointer instanceof CData); + StringEntry::fromCData($namePointer)->releaseReference(); + + FunctionBodySwap::releaseSwappedInBody($containerFunction, 1); + Core::free($container); + } + + /** + * Publishes a container under $lowerName in $table, replacing an inherited bucket + * + * The inherited bucket is unpublished without the destructor and returns the one name + * reference and one body share it held (its ancestor still owns the entry). + * + * @param list<\Closure(): void> $undoStack + */ + private static function publishOwnEntry( + HashTable $table, + string $lowerName, + CData $container, + ?ReflectionMethod $inherited, + array &$undoStack, + ): ReflectionMethod { + if ($inherited !== null) { + $table->deleteWithoutDestructor($lowerName); + FunctionBodySwap::releaseBucketOwnership($inherited); + $undoStack[] = static function () use ($table, $lowerName, $inherited): void { + FunctionBodySwap::acquireBucketOwnership($inherited); + $table->addFunctionEntry($lowerName, self::entryStruct($inherited)); + }; + } + $stored = $table->addFunctionEntry($lowerName, $container); + $undoStack[] = static function () use ($table, $lowerName): void { + $table->deleteWithoutDestructor($lowerName); + }; + + return ReflectionMethod::fromRawEntry($stored); + } + + /** + * Publishes an entry pointer into the method table of a subclass that inherited the + * replaced entry, with the ownership an inherited bucket holds (zend_duplicate_function) + * + * A subclass declaring its own method of that name keeps it. + * + * @param list<\Closure(): void> $undoStack + */ + private static function shareIntoSubclass( + ReflectionClass $subclass, + string $lowerName, + ReflectionMethod $published, + array &$undoStack, + ): void { + $subTable = $subclass->getMethodTable(); + $previous = self::findInheritedEntry($subclass, $lowerName); + if ($previous === false) { + return; + } + if ($previous !== null) { + $subTable->deleteWithoutDestructor($lowerName); + FunctionBodySwap::releaseBucketOwnership($previous); + $undoStack[] = static function () use ($subTable, $lowerName, $previous): void { + FunctionBodySwap::acquireBucketOwnership($previous); + $subTable->addFunctionEntry($lowerName, self::entryStruct($previous)); + }; + } + FunctionBodySwap::acquireBucketOwnership($published); + $subTable->addFunctionEntry($lowerName, self::entryStruct($published)); + $undoStack[] = static function () use ($subTable, $lowerName, $published): void { + $subTable->deleteWithoutDestructor($lowerName); + FunctionBodySwap::releaseBucketOwnership($published); + }; + } + + /** + * The dereferenced zend_function struct of a published entry, which is what a table + * publication needs: newEntry(IS_PTR) stores the ADDRESS of the CData it receives, and + * FFI refuses to reinterpret the 8-byte pointer variable as a zval + * + * @return zend_function + */ + private static function entryStruct(FunctionLikeInterface $entry): object + { + /** @var zend_function $struct The element view of a single-entry pointer */ + $struct = (new StructArray($entry->getEntryPointer(), 1))[0]; + + return $struct; + } + + /** + * Repoints the magic slot of $class that references $previous (constructor, __get, ...) + * at the published override + * + * @param list<\Closure(): void> $undoStack + */ + private static function followMagicSlot( + ReflectionClass $class, + ReflectionMethod $previous, + ReflectionMethod $published, + array &$undoStack, + ): void { + $slot = $class->getMagicSlotFor($previous); + if ($slot === null) { + return; + } + $class->setMagicSlot($slot, $published); + $undoStack[] = static function () use ($class, $slot, $previous): void { + $class->setMagicSlot($slot, $previous); + }; + } + + /** + * Subclasses of $class (linked before) whose tables still hold the inherited entry + * + * @return list + */ + private static function descendantsHolding(ReflectionClass $class, ReflectionMethod $inherited): array + { + $className = $class->getName(); + + return array_values(array_filter( + FunctionBodySwap::publishedShareHolders($inherited), + static fn(ReflectionClass $holder): bool => $holder->getAddress() !== $class->getAddress() + && is_subclass_of($holder->getName(), $className, true), + )); + } + + /** + * Looks a name up in the method table of a class + * + * @return ReflectionMethod|null|false The inherited entry, null when the name is free, + * false when the class declares the method itself + */ + private static function findInheritedEntry(ReflectionClass $class, string $lowerName): ReflectionMethod|null|false + { + $bucket = $class->getMethodTable()->find($lowerName); + if ($bucket === null) { + return null; + } + $method = ReflectionMethod::fromRawEntry($bucket->getRawFunction()); + $scope = $method->getCommonPointer()->scope; + if ($scope === null || Core::addressOf($scope) === $class->getAddress()) { + return false; + } + + return $method; + } + + /** + * The prototype an override of the inherited entry must carry + * (the engine rule: the parent's prototype, or the parent itself) + */ + private static function prototypeOf(ReflectionMethod $inherited): FunctionLikeInterface + { + $prototype = $inherited->getCommonPointer()->prototype; + if ($prototype === null) { + return $inherited; + } + + return ReflectionMethod::fromRawEntry($prototype); + } + + /** + * Visibility ordered for narrowing checks: private < protected < public + */ + private static function visibilityRank(FunctionLikeInterface $function): int + { + if (self::hasFlag($function, Core::ZEND_ACC_PRIVATE)) { + return 1; + } + if (self::hasFlag($function, Core::ZEND_ACC_PROTECTED)) { + return 2; + } + + return 3; + } + + private static function hasFlag(FunctionLikeInterface $function, int $flag): bool + { + $flags = $function->getCommonPointer()->fn_flags; + assert(is_int($flags)); + + return ($flags & $flag) !== 0; + } + + private static function declaringClassName(FunctionLikeInterface $function): string + { + $scope = $function->getCommonPointer()->scope; + if ($scope === null) { + return ''; + } + + return ReflectionClass::fromCData($scope)->getName(); + } + + /** + * Display name of an entry (Class::method or function), safe for pointer-level wrappers + */ + public static function entryName(FunctionLikeInterface $function): string + { + $name = $function->getFunctionName() ?? '{closure}'; + $scope = self::declaringClassName($function); + + return $scope === '' ? $name : "{$scope}::{$name}"; + } + + private static function assertValidName(string $name): void + { + if (preg_match('/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*$/', $name) !== 1) { + throw RedefinitionException::invalidName($name); + } + } + + /** + * @param list<\Closure(): void> $undoStack + */ + private static function unwind(array $undoStack): void + { + foreach (array_reverse($undoStack) as $undoAction) { + $undoAction(); + } + } +} diff --git a/src/Reflection/PendingBodySwap.php b/src/Reflection/PendingBodySwap.php index 2b5c7967..4ef6d828 100644 --- a/src/Reflection/PendingBodySwap.php +++ b/src/Reflection/PendingBodySwap.php @@ -40,6 +40,8 @@ final class PendingBodySwap * @param int $entryAddress Numeric identity of the entry * @param int $publishedShares Bucket shares held on both the previous and the new body * @param bool $destroyPrevious False for shared-memory previous bodies (never freed) + * @param CData|null $previousMintedCache Run-time cache an earlier swap minted for the previous + * body (released on commit, reinstated on rollback) */ public function __construct( private FunctionLikeInterface $entry, @@ -47,6 +49,7 @@ public function __construct( private int $entryAddress, private int $publishedShares, private bool $destroyPrevious, + private ?CData $previousMintedCache = null, ) {} /** @@ -57,7 +60,15 @@ public function commit(): void $this->assertUnresolved(); $this->isResolved = true; if ($this->destroyPrevious) { - FunctionBodySwap::destroyPreviousBody($this->previousBody, $this->entryAddress, $this->publishedShares); + FunctionBodySwap::destroyPreviousBody( + $this->previousBody, + $this->entryAddress, + $this->publishedShares, + $this->previousMintedCache, + ); + } else { + // A shared-memory body is never freed, but the cache z-engine minted for it is + FunctionBodySwap::releaseMintedRunTimeCache($this->entryAddress, $this->previousMintedCache); } } @@ -72,7 +83,7 @@ public function rollback(): void { $this->assertUnresolved(); $this->isResolved = true; - FunctionBodySwap::releaseSwappedInBody($this->entry, $this->publishedShares); + FunctionBodySwap::releaseSwappedInBody($this->entry, $this->publishedShares, $this->previousMintedCache); Core::memcpy($this->entry->getEntryPointer(), Core::addr($this->previousBody), Core::sizeof($this->previousBody)); } diff --git a/src/Reflection/RedefinitionException.php b/src/Reflection/RedefinitionException.php new file mode 100644 index 00000000..cfeb2489 --- /dev/null +++ b/src/Reflection/RedefinitionException.php @@ -0,0 +1,136 @@ + + * + * 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\Reflection; + +/** + * Raised when redefine() or addMethod() cannot accept the donor body, the alias name or the + * target entry. Every factory below is checked BEFORE engine memory is touched, so a caught + * instance always means "nothing was modified". + * + * Every failure mode has a named static constructor (project convention, see AGENTS.md). + */ +class RedefinitionException extends \ReflectionException +{ + public static function signatureMismatch(string $entrySignature, string $donorSignature): self + { + return new self( + 'Given function signature: "' . $donorSignature . '"' + . ' should be compatible with original "' . $entrySignature . '"', + ); + } + + public static function internalEntryRequiresClosure(string $entryName): self + { + return new self( + "Cannot redefine the internal function {$entryName}() with a method donor: an internal " + . 'entry has no op_array to adopt, only its handler can be replaced - pass a closure', + ); + } + + public static function aliasUnsupportedForInternalEntry(string $entryName): self + { + return new self( + "Cannot preserve the body of the internal function {$entryName}() under an alias: " + . 'an internal entry has no op_array to publish', + ); + } + + public static function donorIsInternal(string $donorName): self + { + return new self("Cannot adopt the body of {$donorName}(): it is an internal function without an op_array"); + } + + public static function donorIsAbstract(string $donorName): self + { + return new self("Cannot adopt the body of {$donorName}(): an abstract method has no body"); + } + + public static function donorIsEntry(string $entryName): self + { + return new self("Cannot redefine {$entryName}() with its own body"); + } + + public static function donorNotIntrospectable(string $donorName, \Throwable $reason): self + { + return new self( + "Cannot read the signature of the donor {$donorName}(): obtain the donor through " + . "ReflectionClass::getMethod() or `new ReflectionMethod()` ({$reason->getMessage()})", + 0, + $reason, + ); + } + + public static function staticModifierMismatch(string $entryName, string $donorName): self + { + return new self( + "Cannot use {$donorName}() as the body of {$entryName}(): one is static and the other " + . 'is not, so the receiver of the call would not match the body', + ); + } + + public static function invalidName(string $name): self + { + return new self("\"{$name}\" is not a valid function or method name"); + } + + public static function aliasAlreadyDeclared(string $className, string $alias): self + { + return new self( + "Cannot preserve the previous body as {$className}::{$alias}(): the class already " + . 'declares a method of that name (a body preserved by an earlier redefine()? restore ' + . 'or remove it first)', + ); + } + + public static function aliasCollidesWithInheritedMethod(string $className, string $alias, string $declaringClass): self + { + return new self( + "Cannot preserve the previous body as {$className}::{$alias}(): the class inherits a " + . "non-private method of that name from {$declaringClass}, which a private alias " + . 'cannot override', + ); + } + + public static function functionAlreadyExists(string $functionName): self + { + return new self( + "Cannot preserve the previous body as {$functionName}(): a function of that name " + . 'already exists in the engine', + ); + } + + public static function methodAlreadyDeclared(string $className, string $methodName): self + { + return new self( + "Cannot add {$className}::{$methodName}(): the class already declares that method - " + . 'use ReflectionMethod::redefine() to replace its body', + ); + } + + public static function finalMethodOverride(string $className, string $methodName, string $declaringClass): self + { + return new self( + "Cannot add {$className}::{$methodName}(): it would override the final method " + . "{$declaringClass}::{$methodName}()", + ); + } + + public static function visibilityNarrowed(string $className, string $methodName, string $declaringClass): self + { + return new self( + "Cannot add {$className}::{$methodName}(): its visibility is narrower than that of " + . "the inherited {$declaringClass}::{$methodName}()", + ); + } +} diff --git a/src/Reflection/ReflectionClass.php b/src/Reflection/ReflectionClass.php index c457ba79..ff857bee 100644 --- a/src/Reflection/ReflectionClass.php +++ b/src/Reflection/ReflectionClass.php @@ -66,6 +66,7 @@ use ZEngine\Core; use ZEngine\Generated\zend_class_entry; use ZEngine\Generated\zend_class_name; +use ZEngine\Generated\zend_function; use ZEngine\Generated\zend_object; use ZEngine\Generated\zend_trait_precedence; use ZEngine\Generated\zval; @@ -488,15 +489,49 @@ public function getAttributesTable(): ?HashTable } /** - * Adds a new method to the class in runtime + * Adds a method to the class at runtime, from a closure or from a donor method * - * Rejected for opcache-shared (immutable) classes: the method table lives inside - * the SHM class entry and cannot be written per-process (see docs/hot-swap.md). + * A closure becomes a public method of this class: its embedded zend_function is + * published directly (immortalized, see docs/memory-model.md) under a name the class + * does not know yet. * - * @internal + * A donor method (a user method of another class, obtained through + * ReflectionClass::getMethod()) has its body ADOPTED into an own entry of this class - + * declaration flags and signature follow the donor, `self::`/`parent::`/`$this` resolve + * against this class at run time. The name may be one this class only INHERITS so far: + * the own entry then overrides the inherited method with the engine's inheritance rules + * (prototype kept, no override of a final method, no narrowing of visibility, same + * static-ness; a method private in the ancestor is merely redeclared), the class's magic + * slots that referenced the inherited entry follow it, and so do subclasses that were + * linked before and still inherited the ancestor's entry. A method this class already + * declares itself is refused - that is ReflectionMethod::redefine()'s job. + * + * An opcache-shared (immutable) class is first copied out of shared memory + * (see docs/hot-swap.md); overriding an inherited method of a shared class is refused + * while a subclass linked against the shared entry exists. + * + * @throws RedefinitionException When the donor or the name cannot be accepted (nothing was modified) + * @throws SharedMemoryException When the class cannot be copied out of shared memory */ - public function addMethod(string $methodName, \Closure $method): ReflectionMethod + public function addMethod(string $methodName, \Closure|FunctionLikeInterface $method): ReflectionMethod { + if (!$method instanceof \Closure) { + if (!$method->isUserDefined()) { + throw RedefinitionException::donorIsInternal(MethodPublisher::entryName($method)); + } + $donorFlags = $method->getCommonPointer()->fn_flags; + assert(is_int($donorFlags)); + if (($donorFlags & Core::ZEND_ACC_ABSTRACT) !== 0) { + throw RedefinitionException::donorIsAbstract(MethodPublisher::entryName($method)); + } + // Checked before the copy-out: a subclass linked against the shared entry could + // never see an override published into the per-process copy + MethodPublisher::assertOverridePublishable($this, $methodName); + $this->ensureWritableEntry('add a method'); + + return MethodPublisher::publishOverride($this, $methodName, $method); + } + $this->ensureWritableEntry('add a method'); $closureEntry = new ClosureEntry($method); @@ -911,9 +946,9 @@ public function markHasStaticInMethods(): void * through (constructor/destructor/__get/...), or null when the method is not wired * as a magic slot * - * @internal used by ReflectionMethod::isRemovable() + * @internal used by ReflectionMethod::isRemovable() and the method publisher */ - public function getMagicSlotFor(ReflectionMethod $method): ?string + public function getMagicSlotFor(FunctionLikeInterface $method): ?string { $methodAddress = $method->getAddress(); @@ -931,6 +966,28 @@ function (string $fieldName) use ($methodAddress): bool { ); } + /** + * Points a magic shortcut field of the class entry (constructor/destructor/__get/..., + * see getMagicSlotFor()) at the given entry + * + * The engine dispatches constructors and magic methods through these fields rather + * than through the method table, so an entry that replaces the one a field references + * must be wired here as well. + * + * @param string $fieldName One of the INHERITED_FUNCTION_POINTERS field names + * + * @internal used by the method publisher (MethodPublisher) + */ + public function setMagicSlot(string $fieldName, ?FunctionLikeInterface $method): void + { + if (!in_array($fieldName, self::INHERITED_FUNCTION_POINTERS, true)) { + throw new \InvalidArgumentException("{$fieldName} is not a magic method slot of zend_class_entry"); + } + $this->pointer->{$fieldName} = $method === null + ? null + : Core::cast(zend_function::class, $method->getEntryPointer()); + } + /** * Returns the raw backed-enum table of a backed enum (backing value => case name) * diff --git a/src/Reflection/ReflectionMethod.php b/src/Reflection/ReflectionMethod.php index 21a0a093..d1c505b8 100644 --- a/src/Reflection/ReflectionMethod.php +++ b/src/Reflection/ReflectionMethod.php @@ -318,6 +318,19 @@ public function setDeclaringClass(string $className): void $this->getCommonPointer()->scope = $classEntryValue->getRawClass(); } + /** + * Links this method to the entry it overrides (the engine's prototype pointer), or + * detaches it + * + * @internal used by the method publisher (MethodPublisher) + */ + public function setPrototype(?FunctionLikeInterface $prototype): void + { + $this->getCommonPointer()->prototype = $prototype === null + ? null + : Core::cast(zend_function::class, $prototype->getEntryPointer()); + } + /** * Returns the method prototype or null if no prototype for this method */ diff --git a/tests/HotSwap/RedefineAliasMatrixTest.php b/tests/HotSwap/RedefineAliasMatrixTest.php new file mode 100644 index 00000000..a3aeda81 --- /dev/null +++ b/tests/HotSwap/RedefineAliasMatrixTest.php @@ -0,0 +1,92 @@ + + * + * 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\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * Child-process matrix for the preserved-alias redefine and the addMethod() override + * (tests/HotSwap/scripts/redefine-alias-matrix.php) + * + * Two dimensions, each in its own process so the probed state is exactly the one + * described: + * + * - opcache off / on: with opcache the stub classes are immutable shared-memory entries + * and every operation runs the copy-out path first; + * - the Zend allocator / the system allocator (USE_ZEND_ALLOC=0): with the system + * allocator the engine shuts down through its table destructors instead of discarding + * the request heap wholesale, which is the only shutdown that frees the run-time cache + * of an entry once per method table publishing it - the double free a heap-flagged + * cache shared by a parent and a subclass table used to produce. + */ +class RedefineAliasMatrixTest extends TestCase +{ + /** + * @return iterable + */ + public static function configurations(): iterable + { + yield 'opcache off, Zend allocator' => [false, false]; + yield 'opcache on, Zend allocator' => [true, false]; + yield 'opcache off, system allocator' => [false, true]; + yield 'opcache on, system allocator' => [true, true]; + } + + #[DataProvider('configurations')] + public function testAliasScenariosHoldAndShutDownCleanly(bool $opcache, bool $systemAllocator): void + { + if ($opcache && !extension_loaded('Zend OPcache')) { + self::markTestSkipped('Zend OPcache extension is not loaded'); + } + + $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', + '-d', 'opcache.enable=' . ($opcache ? '1' : '0'), + '-d', 'opcache.enable_cli=' . ($opcache ? '1' : '0'), + // The JIT rewrites the executor internals z-engine hooks into (AGENTS.md) + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + // Fresh stub files must still be published from shared memory + '-d', 'opcache.file_update_protection=0', + __DIR__ . '/scripts/redefine-alias-matrix.php', + $opcache ? '1' : '0', + ]; + $environment = getenv(); + if ($systemAllocator) { + $environment['USE_ZEND_ALLOC'] = '0'; + } + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $environment); + self::assertIsResource($process, 'Unable to spawn the matrix 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) { + self::markTestSkipped("The requested opcache mode could not be activated in the child process\n{$report}"); + } + self::assertSame(0, $exitCode, "Matrix child exited with code {$exitCode}\n{$report}"); + self::assertStringContainsString('redefine-alias-matrix: ok', $stdout, $report); + } +} diff --git a/tests/HotSwap/scripts/redefine-alias-matrix.php b/tests/HotSwap/scripts/redefine-alias-matrix.php new file mode 100644 index 00000000..2b671ffe --- /dev/null +++ b/tests/HotSwap/scripts/redefine-alias-matrix.php @@ -0,0 +1,118 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +use ZEngine\Core; +use ZEngine\Reflection\ReflectionClass; +use ZEngine\Stub\Alias\Ancestor; +use ZEngine\Stub\Alias\AncestorDonor; +use ZEngine\Stub\Alias\Constructed; +use ZEngine\Stub\Alias\ConstructedDonor; +use ZEngine\Stub\Alias\Descendant; +use ZEngine\Stub\Alias\DescendantDonor; +use ZEngine\Stub\Alias\DescendantOverrideDonor; +use ZEngine\Stub\Alias\GrandDescendant; +use ZEngine\Stub\Alias\Greeter; +use ZEngine\Stub\Alias\GreeterDonor; +use ZEngine\Stub\Alias\Secretive; +use ZEngine\Stub\Alias\SecretiveDonor; +use ZEngine\Stub\Alias\Statics; +use ZEngine\Stub\Alias\StaticsChild; +use ZEngine\Stub\Alias\StaticsDonor; + +/** + * Child-process matrix for redefine(preserveAs:) and addMethod() overrides + * + * Runs the alias scenarios in the order the engine supports (a class is redefined before + * its subclasses are loaded) and reports through the exit code: + * 0 - every check passed and the process shut down cleanly, + * 1 - a check failed (diagnostic on stderr), + * 2 - the requested opcache mode is not in effect (the shared-memory branch would be + * silently skipped). + * + * Run with opcache on, the stubs are immutable shared-memory classes and every operation + * copies them out first. Run with USE_ZEND_ALLOC=0, the engine's shutdown destroys every + * class and function through its table destructors - the path that double-frees a + * run-time cache published in more than one method table unless it is request memory + * without the heap flag. + */ +require __DIR__ . '/../../../vendor/autoload.php'; + +Core::init(); + +$expectOpcache = (int) ($argv[1] ?? '0') === 1; +$opcacheActive = function_exists('opcache_get_status') && opcache_get_status(false) !== false; +if ($expectOpcache !== $opcacheActive) { + fwrite(STDERR, 'opcache mode mismatch: expected ' . ($expectOpcache ? 'on' : 'off') . "\n"); + exit(2); +} + +$failures = 0; +$check = static function (string $label, mixed $expected, mixed $actual) use (&$failures): void { + if ($expected !== $actual) { + $failures++; + fwrite(STDERR, "FAIL {$label}: expected " . var_export($expected, true) . ', got ' . var_export($actual, true) . "\n"); + } +}; + +// --- Instance method, method donor, alias --------------------------------------------- +$greeter = new ReflectionClass(Greeter::class); +$check('immutable matches the opcache mode', $expectOpcache, $greeter->isImmutable()); +$greeter->getMethod('greet')->redefine((new ReflectionClass(GreeterDonor::class))->getMethod('greet'), preserveAs: 'greetOriginal'); +$instance = new Greeter('a'); +$check('greet dispatches through the donor body', '[hi x from a]', $instance->greet('x')); +$check('alias is private', true, (new ReflectionMethod(Greeter::class, 'greetOriginal'))->isPrivate()); +$check('class is writable after the copy-out', false, (new ReflectionClass(Greeter::class))->isImmutable()); + +// --- Static method with late static binding, subclass loaded afterwards ---------------- +(new ReflectionClass(Statics::class))->getMethod('label')->redefine((new ReflectionClass(StaticsDonor::class))->getMethod('label'), preserveAs: 'labelOriginal'); +$check('static dispatch', '', Statics::label('s')); +$check('static LSB through a subclass loaded later', '', StaticsChild::label('s')); + +// --- Private method --------------------------------------------------------------------- +(new ReflectionClass(Secretive::class))->getMethod('secret')->redefine((new ReflectionClass(SecretiveDonor::class))->getMethod('secret'), preserveAs: 'secretOriginal'); +$check('private dispatch', 86, (new Secretive())->reveal()); + +// --- Constructor -------------------------------------------------------------------------- +(new ReflectionClass(Constructed::class))->getMethod('__construct')->redefine((new ReflectionClass(ConstructedDonor::class))->getMethod('__construct'), preserveAs: '__constructOriginal'); +Constructed::$log = []; +$check('constructor dispatch', 'ABC', (new Constructed('abc'))->name); +$check('constructor slot', '__construct', (new ReflectionClass(Constructed::class))->getConstructor()?->getName()); + +// --- Inheritance chain: ancestor, then descendant, then grand-descendant ---------------- +(new ReflectionClass(Ancestor::class))->getMethod('greet')->redefine((new ReflectionClass(AncestorDonor::class))->getMethod('greet'), preserveAs: 'greetOriginal'); +$check('descendant through the redefined ancestor', 'child:[hi q]', (new Descendant())->greet('q')); +(new ReflectionClass(Descendant::class))->getMethod('greet')->redefine((new ReflectionClass(DescendantDonor::class))->getMethod('greet'), preserveAs: 'greetOriginal'); +$check('both redefined (CHANGED alias)', '{child:[hi w]}', (new Descendant())->greet('w')); +$check('grand-descendant', '{child:[hi g]}', (new GrandDescendant())->greet('g')); + +// --- Override of an inherited method, grand-descendant linked before -------------------- +(new ReflectionClass(Descendant::class))->addMethod('inheritedOnly', (new ReflectionClass(DescendantOverrideDonor::class))->getMethod('inheritedOnly')); +$check('override dispatch', 1002, (new Descendant())->inheritedOnly(1)); +$check('override reaches the grand-descendant linked before', 1002, (new GrandDescendant())->inheritedOnly(1)); +$check('ancestor untouched', 2, (new Ancestor())->inheritedOnly(1)); + +// --- Hot loop: warmed inline caches, no growth -------------------------------------------- +$before = memory_get_usage(); +$length = 0; +for ($iteration = 0; $iteration < 3000; $iteration++) { + $length += strlen($instance->greet('loop')); + $length += strlen(Statics::label('l')); + $length += strlen((new GrandDescendant())->greet('c')); +} +$check('hot loop produced output', true, $length > 0); +$check('hot loop stays memory-flat', true, memory_get_usage() - $before < 64 * 1024); + +if ($failures > 0) { + exit(1); +} +echo "redefine-alias-matrix: ok\n"; diff --git a/tests/Reflection/RedefinePreserveAliasTest.php b/tests/Reflection/RedefinePreserveAliasTest.php new file mode 100644 index 00000000..dc652569 --- /dev/null +++ b/tests/Reflection/RedefinePreserveAliasTest.php @@ -0,0 +1,333 @@ + + * + * 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\Reflection; + +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\OpCache\SharedMemoryException; +use ZEngine\Stub\Alias\Ancestor; +use ZEngine\Stub\Alias\AncestorDonor; +use ZEngine\Stub\Alias\Constructed; +use ZEngine\Stub\Alias\ConstructedDonor; +use ZEngine\Stub\Alias\Counter; +use ZEngine\Stub\Alias\CounterDonor; +use ZEngine\Stub\Alias\Descendant; +use ZEngine\Stub\Alias\DescendantDonor; +use ZEngine\Stub\Alias\DescendantOverrideDonor; +use ZEngine\Stub\Alias\EarlyBase; +use ZEngine\Stub\Alias\EarlyDonor; +use ZEngine\Stub\Alias\EarlyOverrideBase; +use ZEngine\Stub\Alias\EarlyOverrideDonor; +use ZEngine\Stub\Alias\EarlyOverrideLeaf; +use ZEngine\Stub\Alias\EarlyOverrideMid; +use ZEngine\Stub\Alias\EarlySub; +use ZEngine\Stub\Alias\GrandDescendant; +use ZEngine\Stub\Alias\Greeter; +use ZEngine\Stub\Alias\GreeterDonor; +use ZEngine\Stub\Alias\Invalid; +use ZEngine\Stub\Alias\InvalidAbstract; +use ZEngine\Stub\Alias\OverrideBase; +use ZEngine\Stub\Alias\OverrideDerived; +use ZEngine\Stub\Alias\OverrideDerivedSub; +use ZEngine\Stub\Alias\OverrideDonor; +use ZEngine\Stub\Alias\OverrideSealedDonor; +use ZEngine\Stub\Alias\Secretive; +use ZEngine\Stub\Alias\SecretiveDonor; +use ZEngine\Stub\Alias\Statics; +use ZEngine\Stub\Alias\StaticsChild; +use ZEngine\Stub\Alias\StaticsDonor; +use ZEngine\Stub\Alias\Twice; + +/** + * redefine() with a method donor and a preserved alias of the previous body, and + * addMethod() publishing a donor body as the override of an inherited method + * + * Every scenario owns its stub classes (tests/Stub/Alias): a redefinition changes the + * class for the rest of the process, so no two tests may share a target. Subclasses of a + * redefined class are autoloaded only AFTER the redefinition, which is the order the + * engine supports for opcache-shared classes (see the "linked before" tests for the + * refused order). With opcache active in the runner the stubs are shared-memory classes + * and every redefinition exercises the copy-out path. + */ +class RedefinePreserveAliasTest extends TestCase +{ + public function testPreviousBodyIsPreservedUnderAliasAndCanBeRestored(): void + { + $class = new ReflectionClass(Counter::class); + $method = $class->getMethod('counter'); + + $method->redefine((new ReflectionClass(CounterDonor::class))->getMethod('counter'), preserveAs: 'counterOriginal'); + + $instance = new Counter(); + self::assertSame(101, $instance->counter()); + self::assertSame(102, $instance->counter(), 'the preserved body keeps its own static variable'); + + $alias = new \ReflectionMethod(Counter::class, 'counterOriginal'); + self::assertTrue($alias->isPrivate()); + self::assertSame(Counter::class, $alias->getDeclaringClass()->getName()); + self::assertSame((new \ReflectionClass(Counter::class))->getFileName(), $alias->getFileName()); + + // Restore the previous body from the alias, then drop the alias again + $class = new ReflectionClass(Counter::class); + $class->getMethod('counter')->redefine($class->getMethod('counterOriginal')); + $class->removeMethods('counterOriginal'); + self::assertSame(1, (new Counter())->counter(), 'the restored body starts with fresh statics'); + self::assertFalse(method_exists(Counter::class, 'counterOriginal')); + } + + public function testMethodDonorAdoptsBodyAndSignature(): void + { + $class = new ReflectionClass(Greeter::class); + $donor = new ReflectionClass(GreeterDonor::class); + $class->getMethod('greet')->redefine($donor->getMethod('greet'), preserveAs: 'greetOriginal'); + $class = new ReflectionClass(Greeter::class); + $class->getMethod('itself')->redefine($donor->getMethod('itself'), preserveAs: 'itselfOriginal'); + + $instance = new Greeter('a'); + self::assertSame('[hi x from a]', $instance->greet('x')); + self::assertSame($instance, $instance->itself(), 'an explicit class type in the donor matches self of the entry'); + + $woven = new \ReflectionMethod(Greeter::class, 'greet'); + self::assertTrue($woven->isPublic()); + self::assertSame('string', (string) $woven->getReturnType()); + self::assertSame(Greeter::class, $woven->getDeclaringClass()->getName()); + self::assertSame((new \ReflectionClass(GreeterDonor::class))->getFileName(), $woven->getFileName(), 'the body (and its file) follow the donor'); + self::assertSame((new \ReflectionClass(Greeter::class))->getFileName(), (new \ReflectionMethod(Greeter::class, 'greetOriginal'))->getFileName()); + self::assertTrue((new \ReflectionMethod(Greeter::class, 'greetOriginal'))->isPrivate()); + } + + public function testStaticMethodAliasKeepsLateStaticBinding(): void + { + $class = new ReflectionClass(Statics::class); + $donor = new ReflectionClass(StaticsDonor::class); + $class->getMethod('label')->redefine($donor->getMethod('label'), preserveAs: 'labelOriginal'); + + self::assertSame('', Statics::label('s')); + self::assertSame('', StaticsChild::label('s')); + self::assertTrue((new \ReflectionMethod(Statics::class, 'labelOriginal'))->isStatic()); + self::assertTrue((new \ReflectionMethod(Statics::class, 'labelOriginal'))->isPrivate()); + } + + public function testPrivateMethodAlias(): void + { + $class = new ReflectionClass(Secretive::class); + $donor = new ReflectionClass(SecretiveDonor::class); + $class->getMethod('secret')->redefine($donor->getMethod('secret'), preserveAs: 'secretOriginal'); + + self::assertSame(86, (new Secretive())->reveal()); + self::assertTrue((new \ReflectionMethod(Secretive::class, 'secret'))->isPrivate(), 'declaration flags stay'); + } + + public function testConstructorAliasKeepsTheConstructorSlot(): void + { + $class = new ReflectionClass(Constructed::class); + $donor = new ReflectionClass(ConstructedDonor::class); + $class->getMethod('__construct')->redefine($donor->getMethod('__construct'), preserveAs: '__constructOriginal'); + + Constructed::$log = []; + $instance = new Constructed('abc'); + self::assertSame('ABC', $instance->name); + self::assertSame(['ctor:ABC'], Constructed::$log); + self::assertSame('__construct', (new \ReflectionClass(Constructed::class))->getConstructor()?->getName()); + self::assertFalse((new \ReflectionMethod(Constructed::class, '__constructOriginal'))->isConstructor()); + } + + public function testChildAliasShadowsTheParentAliasWithTheChangedFlag(): void + { + $ancestor = new ReflectionClass(Ancestor::class); + $ancestor->getMethod('greet')->redefine((new ReflectionClass(AncestorDonor::class))->getMethod('greet'), preserveAs: 'greetOriginal'); + + // Autoloaded now, i.e. linked against the redefined ancestor: inherits the alias bucket + self::assertSame('child:[hi q]', (new Descendant())->greet('q')); + self::assertTrue((new \ReflectionClass(Descendant::class))->hasMethod('greetOriginal')); + + $descendant = new ReflectionClass(Descendant::class); + $descendant->getMethod('greet')->redefine((new ReflectionClass(DescendantDonor::class))->getMethod('greet'), preserveAs: 'greetOriginal'); + + // The ancestor's body runs on a Descendant receiver and must reach ITS alias + self::assertSame('{child:[hi w]}', (new Descendant())->greet('w')); + self::assertSame('{child:[hi g]}', (new GrandDescendant())->greet('g')); + + $childAlias = (new ReflectionClass(Descendant::class))->getMethod('greetOriginal'); + $flags = $childAlias->getCommonPointer()->fn_flags; + self::assertSame(Core::ZEND_ACC_CHANGED, $flags & Core::ZEND_ACC_CHANGED, 'a redeclared parent-private method is marked CHANGED'); + self::assertSame(Descendant::class, $childAlias->getDeclaringClass()->getName()); + } + + public function testAliasIsSharedIntoASubclassLinkedBefore(): void + { + // Linked now, before the redefinition + self::assertSame('base', (new EarlySub())->m()); + $class = new ReflectionClass(EarlyBase::class); + + if ($class->isImmutable()) { + // A subclass linked against the shared-memory entry could never see the alias + $this->expectException(SharedMemoryException::class); + $this->expectExceptionMessageMatches('/subclass .*EarlySub was linked against the shared entry/'); + } + $class->getMethod('m')->redefine((new ReflectionClass(EarlyDonor::class))->getMethod('m'), preserveAs: 'mOriginal'); + + self::assertSame('[base]', (new EarlyBase())->m()); + self::assertSame('[base]', (new EarlySub())->m(), 'the subclass inherited the alias after the fact'); + } + + public function testSecondAliasOnTheSameMethodIsRefused(): void + { + $class = new ReflectionClass(Twice::class); + $class->getMethod('m')->redefine(fn(): int => 10, preserveAs: 'mOriginal'); + $instance = new Twice(); + self::assertSame(10, $instance->m()); + self::assertSame(1, (new \ReflectionMethod(Twice::class, 'mOriginal'))->invoke($instance), 'a closure donor preserves the body as well'); + + $this->expectException(RedefinitionException::class); + $this->expectExceptionMessageMatches('/already declares a method of that name/'); + (new ReflectionClass(Twice::class))->getMethod('m')->redefine(fn(): int => 0, preserveAs: 'mOriginal'); + } + + public function testDonorValidation(): void + { + $class = new ReflectionClass(Invalid::class); + + try { + $class->getMethod('dyn')->redefine($class->getMethod('stat')); + self::fail('static-ness mismatch not refused'); + } catch (RedefinitionException $exception) { + self::assertStringContainsString('one is static and the other is not', $exception->getMessage()); + } + try { + $class->getMethod('dyn')->redefine((new ReflectionClass(InvalidAbstract::class))->getMethod('dyn')); + self::fail('abstract donor not refused'); + } catch (RedefinitionException $exception) { + self::assertStringContainsString('abstract method has no body', $exception->getMessage()); + } + try { + $class->getMethod('dyn')->redefine($class->getMethod('typed')); + self::fail('signature mismatch not refused'); + } catch (RedefinitionException $exception) { + self::assertStringContainsString('should be compatible with original', $exception->getMessage()); + } + try { + $class->getMethod('dyn')->redefine($class->getMethod('dyn')); + self::fail('entry as its own donor not refused'); + } catch (RedefinitionException $exception) { + self::assertStringContainsString('with its own body', $exception->getMessage()); + } + try { + $class->getMethod('dyn')->redefine(new ReflectionFunction('strlen')); + self::fail('internal donor not refused'); + } catch (RedefinitionException $exception) { + self::assertStringContainsString('internal function', $exception->getMessage()); + } + try { + $class->getMethod('dyn')->redefine(fn(): int => 0, preserveAs: 'not a name'); + self::fail('invalid alias name not refused'); + } catch (RedefinitionException $exception) { + self::assertStringContainsString('not a valid function or method name', $exception->getMessage()); + } + self::assertSame(1, (new Invalid())->dyn(), 'nothing was modified by the refused calls'); + } + + public function testGlobalFunctionAlias(): void + { + require_once __DIR__ . '/../Stub/Alias/functions.php'; + self::assertTrue(function_exists('zengine_alias_fixture_fn')); + + $function = new ReflectionFunction('zengine_alias_fixture_fn'); + $function->redefine(fn(int $x): int => $x * 10, preserveAs: 'zengine_alias_fixture_fn_original'); + + self::assertSame(20, \zengine_alias_fixture_fn(2)); + self::assertTrue(function_exists('zengine_alias_fixture_fn_original')); + self::assertSame(3, \zengine_alias_fixture_fn_original(2)); + + $this->expectException(RedefinitionException::class); + (new ReflectionFunction('zengine_alias_fixture_fn'))->redefine(fn(int $x): int => 0, preserveAs: 'strlen'); + } + + public function testAddMethodPublishesADonorBodyAsTheOverrideOfAnInheritedMethod(): void + { + $derived = new ReflectionClass(OverrideDerived::class); + $donor = new ReflectionClass(OverrideDonor::class); + + $published = $derived->addMethod('hello', $donor->getMethod('hello')); + self::assertSame('hello', $published->getName()); + self::assertSame(OverrideDerived::class, $published->getDeclaringClass()->getName()); + + self::assertSame('derived+base', (new OverrideDerived())->hello()); + self::assertSame('base', (new OverrideBase())->hello(), 'the ancestor is untouched'); + $native = new \ReflectionMethod(OverrideDerived::class, 'hello'); + self::assertSame(OverrideDerived::class, $native->getDeclaringClass()->getName()); + self::assertSame(OverrideBase::class, $native->getPrototype()->getDeclaringClass()->getName()); + + // Autoloaded now, i.e. linked against the class with the override + self::assertSame('derived+base', (new OverrideDerivedSub())->hello()); + + // An inherited constructor can be overridden the same way: the constructor slot follows + $derived = new ReflectionClass(OverrideDerived::class); + $derived->addMethod('__construct', $donor->getMethod('__construct')); + self::assertSame('derived-x', (new OverrideDerived('x'))->tag); + self::assertSame('derived-x', (new OverrideDerivedSub('x'))->tag); + self::assertSame(OverrideDerived::class, (new \ReflectionClass(OverrideDerived::class))->getConstructor()?->getDeclaringClass()->getName()); + self::assertSame('base', (new OverrideBase())->tag); + } + + public function testAddMethodRefusesFinalOverridesAndDeclaredMethods(): void + { + $derived = new ReflectionClass(OverrideDerived::class); + $donor = new ReflectionClass(OverrideDonor::class); + + try { + $derived->addMethod('sealed', (new ReflectionClass(OverrideSealedDonor::class))->getMethod('sealed')); + self::fail('final override not refused'); + } catch (RedefinitionException $exception) { + self::assertStringContainsString('override the final method', $exception->getMessage()); + } + try { + $derived->addMethod('own', $donor->getMethod('own')); + self::fail('declared method not refused'); + } catch (RedefinitionException $exception) { + self::assertStringContainsString('already declares that method', $exception->getMessage()); + } + self::assertSame('sealed', (new OverrideDerived())->sealed()); + self::assertSame('derived-own', (new OverrideDerived())->own()); + } + + public function testOverridePropagatesToASubclassLinkedBefore(): void + { + // The whole chain is linked now, before the override is published on the middle class + self::assertSame('base', (new EarlyOverrideLeaf())->m()); + $middle = new ReflectionClass(EarlyOverrideMid::class); + + if ($middle->isImmutable()) { + $this->expectException(SharedMemoryException::class); + $this->expectExceptionMessageMatches('/subclass .*EarlyOverrideLeaf was linked against the shared entry/'); + } + $middle->addMethod('m', (new ReflectionClass(EarlyOverrideDonor::class))->getMethod('m')); + + self::assertSame('over+base', (new EarlyOverrideMid())->m()); + self::assertSame('over+base', (new EarlyOverrideLeaf())->m(), 'the leaf linked before follows the override'); + self::assertSame('base', (new EarlyOverrideBase())->m()); + } + + public function testInheritedMethodInterceptionThroughParentCall(): void + { + // Descendant inherits inheritedOnly() from Ancestor; the override donor reaches the + // ancestor body through parent::, which resolves against Descendant at run time + $descendant = new ReflectionClass(Descendant::class); + $descendant->addMethod('inheritedOnly', (new ReflectionClass(DescendantOverrideDonor::class))->getMethod('inheritedOnly')); + + self::assertSame(1002, (new Descendant())->inheritedOnly(1)); + self::assertSame(2, (new Ancestor())->inheritedOnly(1)); + self::assertSame(1002, (new GrandDescendant())->inheritedOnly(1), 'subclasses linked after the parent redefinition inherit the override'); + } +} diff --git a/tests/Stub/Alias/Ancestor.php b/tests/Stub/Alias/Ancestor.php new file mode 100644 index 00000000..135c797a --- /dev/null +++ b/tests/Stub/Alias/Ancestor.php @@ -0,0 +1,30 @@ + + * + * 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\Stub\Alias; + +/** + * Root of the inheritance scenario: woven first, then subclasses are loaded + */ +class Ancestor +{ + public function greet(string $who): string + { + return "hi {$who}"; + } + + public function inheritedOnly(int $x): int + { + return $x + 1; + } +} diff --git a/tests/Stub/Alias/AncestorDonor.php b/tests/Stub/Alias/AncestorDonor.php new file mode 100644 index 00000000..646a6fdd --- /dev/null +++ b/tests/Stub/Alias/AncestorDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for Ancestor::greet() + * + * @method string greetOriginal(string $who) the preserved Ancestor::greet() body + */ +class AncestorDonor +{ + public function greet(string $who): string + { + return '[' . $this->greetOriginal($who) . ']'; + } +} diff --git a/tests/Stub/Alias/Constructed.php b/tests/Stub/Alias/Constructed.php new file mode 100644 index 00000000..1a7cf0fd --- /dev/null +++ b/tests/Stub/Alias/Constructed.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Class whose constructor gets redefined with a preserved alias + */ +class Constructed +{ + /** @var list */ + public static array $log = []; + + public function __construct(public string $name = 'plain') + { + self::$log[] = 'ctor:' . $this->name; + } +} diff --git a/tests/Stub/Alias/ConstructedDonor.php b/tests/Stub/Alias/ConstructedDonor.php new file mode 100644 index 00000000..d397c6c7 --- /dev/null +++ b/tests/Stub/Alias/ConstructedDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor constructor: delegates to the preserved original constructor + * + * @method void __constructOriginal(string $name) the preserved Constructed::__construct() body + */ +class ConstructedDonor +{ + public function __construct(public string $name = 'donor') + { + $this->__constructOriginal(strtoupper($name)); + } +} diff --git a/tests/Stub/Alias/Counter.php b/tests/Stub/Alias/Counter.php new file mode 100644 index 00000000..71044c21 --- /dev/null +++ b/tests/Stub/Alias/Counter.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Method with a static variable: the preserved alias and the new body each get their own + */ +class Counter +{ + public function counter(): int + { + /** @var int $n */ + static $n = 0; + + return ++$n; + } +} diff --git a/tests/Stub/Alias/CounterDonor.php b/tests/Stub/Alias/CounterDonor.php new file mode 100644 index 00000000..28cc3e71 --- /dev/null +++ b/tests/Stub/Alias/CounterDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for Counter::counter(), delegating to the preserved alias + * + * @method int counterOriginal() the preserved Counter::counter() body + */ +class CounterDonor +{ + public function counter(): int + { + return 100 + $this->counterOriginal(); + } +} diff --git a/tests/Stub/Alias/Descendant.php b/tests/Stub/Alias/Descendant.php new file mode 100644 index 00000000..24a17f3a --- /dev/null +++ b/tests/Stub/Alias/Descendant.php @@ -0,0 +1,25 @@ + + * + * 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\Stub\Alias; + +/** + * Loaded after Ancestor::greet() was redefined; overrides greet() through parent:: + */ +class Descendant extends Ancestor +{ + public function greet(string $who): string + { + return 'child:' . parent::greet($who); + } +} diff --git a/tests/Stub/Alias/DescendantDonor.php b/tests/Stub/Alias/DescendantDonor.php new file mode 100644 index 00000000..a72fa0ff --- /dev/null +++ b/tests/Stub/Alias/DescendantDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for Descendant::greet(): its alias shadows the inherited Ancestor alias + * + * @method string greetOriginal(string $who) the preserved Descendant::greet() body + */ +class DescendantDonor +{ + public function greet(string $who): string + { + return '{' . $this->greetOriginal($who) . '}'; + } +} diff --git a/tests/Stub/Alias/DescendantOverrideDonor.php b/tests/Stub/Alias/DescendantOverrideDonor.php new file mode 100644 index 00000000..712bd4da --- /dev/null +++ b/tests/Stub/Alias/DescendantOverrideDonor.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Donor for an addMethod() override of the inherited Ancestor::inheritedOnly() on Descendant + * + * Extends Ancestor so that parent:: compiles; at run time parent:: resolves against + * the class the body is published in. + */ +class DescendantOverrideDonor extends Ancestor +{ + public function inheritedOnly(int $x): int + { + return 1000 + parent::inheritedOnly($x); + } +} diff --git a/tests/Stub/Alias/EarlyBase.php b/tests/Stub/Alias/EarlyBase.php new file mode 100644 index 00000000..ff180ac9 --- /dev/null +++ b/tests/Stub/Alias/EarlyBase.php @@ -0,0 +1,25 @@ + + * + * 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\Stub\Alias; + +/** + * Base class whose subclass is linked BEFORE the base method is redefined + */ +class EarlyBase +{ + public function m(): string + { + return 'base'; + } +} diff --git a/tests/Stub/Alias/EarlyDonor.php b/tests/Stub/Alias/EarlyDonor.php new file mode 100644 index 00000000..5033bee9 --- /dev/null +++ b/tests/Stub/Alias/EarlyDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for EarlyBase::m(), delegating to the preserved alias + * + * @method string mOriginal() the preserved EarlyBase::m() body + */ +class EarlyDonor +{ + public function m(): string + { + return '[' . $this->mOriginal() . ']'; + } +} diff --git a/tests/Stub/Alias/EarlyOverrideBase.php b/tests/Stub/Alias/EarlyOverrideBase.php new file mode 100644 index 00000000..8e86f856 --- /dev/null +++ b/tests/Stub/Alias/EarlyOverrideBase.php @@ -0,0 +1,25 @@ + + * + * 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\Stub\Alias; + +/** + * Three-level chain for the override-with-linked-subclass scenario + */ +class EarlyOverrideBase +{ + public function m(): string + { + return 'base'; + } +} diff --git a/tests/Stub/Alias/EarlyOverrideDonor.php b/tests/Stub/Alias/EarlyOverrideDonor.php new file mode 100644 index 00000000..b2619fff --- /dev/null +++ b/tests/Stub/Alias/EarlyOverrideDonor.php @@ -0,0 +1,25 @@ + + * + * 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\Stub\Alias; + +/** + * Donor for the EarlyOverrideMid::m() override + */ +class EarlyOverrideDonor extends EarlyOverrideBase +{ + public function m(): string + { + return 'over+' . parent::m(); + } +} diff --git a/tests/Stub/Alias/EarlyOverrideLeaf.php b/tests/Stub/Alias/EarlyOverrideLeaf.php new file mode 100644 index 00000000..89a18e9d --- /dev/null +++ b/tests/Stub/Alias/EarlyOverrideLeaf.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Leaf linked before the override is published on EarlyOverrideMid + */ +class EarlyOverrideLeaf extends EarlyOverrideMid {} diff --git a/tests/Stub/Alias/EarlyOverrideMid.php b/tests/Stub/Alias/EarlyOverrideMid.php new file mode 100644 index 00000000..9d173137 --- /dev/null +++ b/tests/Stub/Alias/EarlyOverrideMid.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Middle of the chain: receives the addMethod() override of m() + */ +class EarlyOverrideMid extends EarlyOverrideBase {} diff --git a/tests/Stub/Alias/EarlySub.php b/tests/Stub/Alias/EarlySub.php new file mode 100644 index 00000000..11dbcc2d --- /dev/null +++ b/tests/Stub/Alias/EarlySub.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Linked before EarlyBase::m() is redefined + */ +class EarlySub extends EarlyBase {} diff --git a/tests/Stub/Alias/GrandDescendant.php b/tests/Stub/Alias/GrandDescendant.php new file mode 100644 index 00000000..e6978b35 --- /dev/null +++ b/tests/Stub/Alias/GrandDescendant.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Loaded after both Ancestor and Descendant were redefined + */ +class GrandDescendant extends Descendant {} diff --git a/tests/Stub/Alias/Greeter.php b/tests/Stub/Alias/Greeter.php new file mode 100644 index 00000000..48921211 --- /dev/null +++ b/tests/Stub/Alias/Greeter.php @@ -0,0 +1,32 @@ + + * + * 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\Stub\Alias; + +/** + * Instance methods for the method-donor scenario (a `self` return type among them) + */ +class Greeter +{ + public function __construct(public string $name = 'greeter') {} + + public function greet(string $who): string + { + return "hi {$who} from {$this->name}"; + } + + public function itself(): self + { + return $this; + } +} diff --git a/tests/Stub/Alias/GreeterDonor.php b/tests/Stub/Alias/GreeterDonor.php new file mode 100644 index 00000000..39258d0f --- /dev/null +++ b/tests/Stub/Alias/GreeterDonor.php @@ -0,0 +1,36 @@ + + * + * 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\Stub\Alias; + +/** + * Donor bodies for Greeter: they call the preserved aliases, which exist on Greeter only + * + * The `itself()` return type spells Greeter explicitly: a `self` here would resolve to + * GreeterDonor at compile time and keep enforcing it after the swap. + * + * @method string greetOriginal(string $who) the preserved Greeter::greet() body + * @method Greeter itselfOriginal() the preserved Greeter::itself() body + */ +class GreeterDonor +{ + public function greet(string $who): string + { + return '[' . $this->greetOriginal($who) . ']'; + } + + public function itself(): Greeter + { + return $this->itselfOriginal(); + } +} diff --git a/tests/Stub/Alias/Invalid.php b/tests/Stub/Alias/Invalid.php new file mode 100644 index 00000000..5f5209ae --- /dev/null +++ b/tests/Stub/Alias/Invalid.php @@ -0,0 +1,35 @@ + + * + * 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\Stub\Alias; + +/** + * Shapes the donor validation must refuse + */ +class Invalid +{ + public function dyn(): int + { + return 1; + } + + public static function stat(): int + { + return 2; + } + + public function typed(int $x): int + { + return $x; + } +} diff --git a/tests/Stub/Alias/InvalidAbstract.php b/tests/Stub/Alias/InvalidAbstract.php new file mode 100644 index 00000000..1130098f --- /dev/null +++ b/tests/Stub/Alias/InvalidAbstract.php @@ -0,0 +1,22 @@ + + * + * 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\Stub\Alias; + +/** + * Abstract donor candidate + */ +abstract class InvalidAbstract +{ + abstract public function dyn(): int; +} diff --git a/tests/Stub/Alias/OverrideBase.php b/tests/Stub/Alias/OverrideBase.php new file mode 100644 index 00000000..132e93b4 --- /dev/null +++ b/tests/Stub/Alias/OverrideBase.php @@ -0,0 +1,37 @@ + + * + * 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\Stub\Alias; + +/** + * Base of the addMethod() override scenario (constructor, final and plain methods) + */ +class OverrideBase +{ + public function __construct(public string $tag = 'base') {} + + public function hello(): string + { + return 'base'; + } + + final public function sealed(): string + { + return 'sealed'; + } + + public function own(): string + { + return 'own'; + } +} diff --git a/tests/Stub/Alias/OverrideDerived.php b/tests/Stub/Alias/OverrideDerived.php new file mode 100644 index 00000000..1626ef72 --- /dev/null +++ b/tests/Stub/Alias/OverrideDerived.php @@ -0,0 +1,25 @@ + + * + * 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\Stub\Alias; + +/** + * Inherits hello()/sealed()/__construct() and declares own() itself + */ +class OverrideDerived extends OverrideBase +{ + public function own(): string + { + return 'derived-own'; + } +} diff --git a/tests/Stub/Alias/OverrideDerivedSub.php b/tests/Stub/Alias/OverrideDerivedSub.php new file mode 100644 index 00000000..d1b9eeef --- /dev/null +++ b/tests/Stub/Alias/OverrideDerivedSub.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Loaded after the overrides were published on OverrideDerived + */ +class OverrideDerivedSub extends OverrideDerived {} diff --git a/tests/Stub/Alias/OverrideDonor.php b/tests/Stub/Alias/OverrideDonor.php new file mode 100644 index 00000000..9f045a4a --- /dev/null +++ b/tests/Stub/Alias/OverrideDonor.php @@ -0,0 +1,35 @@ + + * + * 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\Stub\Alias; + +/** + * Donor bodies published on OverrideDerived as overrides of inherited methods + */ +class OverrideDonor extends OverrideBase +{ + public function __construct(public string $tag = 'donor') + { + parent::__construct('derived-' . $tag); + } + + public function hello(): string + { + return 'derived+' . parent::hello(); + } + + public function own(): string + { + return 'donor-own'; + } +} diff --git a/tests/Stub/Alias/OverrideSealedDonor.php b/tests/Stub/Alias/OverrideSealedDonor.php new file mode 100644 index 00000000..d80262d6 --- /dev/null +++ b/tests/Stub/Alias/OverrideSealedDonor.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Donor whose sealed() must be refused as an override of the final OverrideBase::sealed() + * + * Deliberately no parent class: extending OverrideBase with a sealed() method would be a + * compile-time error already. + */ +class OverrideSealedDonor +{ + public function sealed(): string + { + return 'unsealed'; + } +} diff --git a/tests/Stub/Alias/Secretive.php b/tests/Stub/Alias/Secretive.php new file mode 100644 index 00000000..c39033b4 --- /dev/null +++ b/tests/Stub/Alias/Secretive.php @@ -0,0 +1,35 @@ + + * + * 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\Stub\Alias; + +/** + * Private method reached through a public one + * + * secret() reads a property so the opcache optimizer cannot fold the call site + * away (a private method returning a literal is inlined at cache time). + */ +class Secretive +{ + public function __construct(private int $base = 40) {} + + private function secret(): int + { + return $this->base + 2; + } + + public function reveal(): int + { + return $this->secret() * 2; + } +} diff --git a/tests/Stub/Alias/SecretiveDonor.php b/tests/Stub/Alias/SecretiveDonor.php new file mode 100644 index 00000000..f2b9cd7e --- /dev/null +++ b/tests/Stub/Alias/SecretiveDonor.php @@ -0,0 +1,30 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for the private Secretive::secret() + * + * Public on purpose: the entry keeps its own (private) declaration flags, only the body is + * adopted, and an unused private method would be a static-analysis finding + * + * @method int secretOriginal() the preserved Secretive::secret() body + */ +class SecretiveDonor +{ + public function secret(): int + { + return $this->secretOriginal() + 1; + } +} diff --git a/tests/Stub/Alias/Statics.php b/tests/Stub/Alias/Statics.php new file mode 100644 index 00000000..f29e24f3 --- /dev/null +++ b/tests/Stub/Alias/Statics.php @@ -0,0 +1,25 @@ + + * + * 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\Stub\Alias; + +/** + * Static method with late static binding in its body + */ +class Statics +{ + public static function label(string $suffix): string + { + return 'label:' . static::class . ':' . $suffix; + } +} diff --git a/tests/Stub/Alias/StaticsChild.php b/tests/Stub/Alias/StaticsChild.php new file mode 100644 index 00000000..c7039a42 --- /dev/null +++ b/tests/Stub/Alias/StaticsChild.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Loaded only AFTER Statics::label() was redefined (late static binding through the alias) + */ +class StaticsChild extends Statics {} diff --git a/tests/Stub/Alias/StaticsDonor.php b/tests/Stub/Alias/StaticsDonor.php new file mode 100644 index 00000000..101fb4ad --- /dev/null +++ b/tests/Stub/Alias/StaticsDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for Statics::label(), calling the preserved alias through self:: + * + * @method static string labelOriginal(string $suffix) the preserved Statics::label() body + */ +class StaticsDonor +{ + public static function label(string $suffix): string + { + return '<' . self::labelOriginal($suffix) . '>'; + } +} diff --git a/tests/Stub/Alias/Twice.php b/tests/Stub/Alias/Twice.php new file mode 100644 index 00000000..980b8cd9 --- /dev/null +++ b/tests/Stub/Alias/Twice.php @@ -0,0 +1,25 @@ + + * + * 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\Stub\Alias; + +/** + * Target for the refused second alias + */ +class Twice +{ + public function m(): int + { + return 1; + } +} diff --git a/tests/Stub/Alias/functions.php b/tests/Stub/Alias/functions.php new file mode 100644 index 00000000..882c6ba2 --- /dev/null +++ b/tests/Stub/Alias/functions.php @@ -0,0 +1,20 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +/** + * Global function target for the preserved-alias redefine (required once by the test) + */ +function zengine_alias_fixture_fn(int $x): int +{ + return $x + 1; +} From 67e183b8cdb6e204aefcab62c76917c340e3d847 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 01:20:19 +0000 Subject: [PATCH 02/14] docs(reflection): describe the minted run-time cache in the FunctionBodySwap header The class header still described the fresh run-time cache as heap-flagged and freed by the engine; since the previous commit it is request memory without ZEND_ACC_HEAP_RT_CACHE that z-engine releases itself. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- src/Reflection/FunctionBodySwap.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Reflection/FunctionBodySwap.php b/src/Reflection/FunctionBodySwap.php index 1f6ae825..d473cf28 100644 --- a/src/Reflection/FunctionBodySwap.php +++ b/src/Reflection/FunctionBodySwap.php @@ -31,10 +31,12 @@ * bucket that points at the entry structure (the entry's own bucket plus every * inherited-method bucket of a subclass): each bucket's destructor decrements the * body refcount once, so the accounting must match or the body is freed early. - * - The entry gets a fresh HEAP_RT_CACHE run-time cache: the donor's cache is - * scope-dependent and owned by the donor; the engine frees a heap cache together - * with the entry (destroy_op_array), and every subsequent swap releases the - * previous one, so repeated swaps stay memory-flat. + * - The entry gets a fresh run-time cache: the donor's cache is scope-dependent and + * owned by the donor. The fresh cache is request memory WITHOUT the + * ZEND_ACC_HEAP_RT_CACHE flag (a heap-flagged cache shared by the entry's own + * bucket and the inherited-method buckets of subclasses would be freed once per + * bucket at shutdown), so z-engine releases it itself: every subsequent swap of + * the entry releases the previous cache, which keeps repeated swaps memory-flat. * - The entry never shares the donor's LIVE static-variables table: its ZEND_MAP_PTR * slot is dropped so the table materializes lazily on the first ZEND_BIND_STATIC, * exactly like a plain compiled function. The defaults table behind it stays @@ -43,11 +45,11 @@ * - The previous body is destroyed with engine semantics (destroy_op_array) when * 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 (the heap run-time cache) are + * swap minted for it (the run-time cache) 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 + * arg_info, static variables, the minted run-time cache) is released, and bodies still * shared with someone else (a template op_array, a fake closure) survive through * their own refcount. * From 91a170caeb06c86b6c87522b802cbcf828337fc6 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 01:26:52 +0000 Subject: [PATCH 03/14] fix(reflection): resolve self and parent per declaring class in the donor signature check PHP 8.4 reflection reports the self and parent keywords of a type verbatim, PHP 8.5 resolves them to the class names: comparing the stringified types made a method donor spelling the target class fail the signature check on 8.4 while it passed on 8.5. The signature now renders each side with the keywords resolved against its own scope (the declaring class, or the bound scope of a closure donor), which is what the types accept at run time on both versions; static stays as is. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- src/Reflection/FunctionLikeTrait.php | 55 ++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/Reflection/FunctionLikeTrait.php b/src/Reflection/FunctionLikeTrait.php index 3cf8e7fa..24dbec91 100644 --- a/src/Reflection/FunctionLikeTrait.php +++ b/src/Reflection/FunctionLikeTrait.php @@ -833,10 +833,18 @@ private function ensureCompatibleSignature(\ReflectionFunctionAbstract $donor): /** * Renders a comparable signature string of a function + * + * The `self` and `parent` keywords are rendered as the classes they name in the function's + * own scope (its declaring class, or the bound scope of a closure): PHP 8.5 reflection + * resolves them itself, PHP 8.4 reports the keywords verbatim, and a donor compiled in + * another class must be compared by what its types actually accept. `static` stays as is, + * it binds to the called class on both sides. */ private static function renderSignature(\ReflectionFunctionAbstract $function): string { - $renderType = static fn(\ReflectionType $type): string => (string) $type; + $scope = $function instanceof \ReflectionMethod + ? $function->getDeclaringClass() + : ($function instanceof \ReflectionFunction ? $function->getClosureScopeClass() : null); $signature = 'function '; if ($function->returnsReference()) { @@ -848,7 +856,7 @@ private static function renderSignature(\ReflectionFunctionAbstract $function): $parameter = ''; $type = $reflectionParameter->getType(); if ($type !== null) { - $parameter .= $renderType($type) . ' '; + $parameter .= self::renderType($type, $scope) . ' '; } if ($reflectionParameter->isPassedByReference()) { $parameter .= '&'; @@ -864,12 +872,53 @@ private static function renderSignature(\ReflectionFunctionAbstract $function): $signature .= ')'; $returnType = $function->getReturnType(); if ($returnType !== null) { - $signature .= ': ' . $renderType($returnType); + $signature .= ': ' . self::renderType($returnType, $scope); } return $signature; } + /** + * Renders a type with the class-scope keywords resolved against $scope (see renderSignature()) + * + * @param \ReflectionClass|null $scope + */ + private static function renderType(\ReflectionType $type, ?\ReflectionClass $scope): string + { + if ($type instanceof \ReflectionNamedType) { + $name = $type->getName(); + $lowerName = strtolower($name); + if ($scope !== null && $lowerName === 'self') { + $name = $scope->getName(); + } elseif ($scope !== null && $lowerName === 'parent') { + $parentClass = $scope->getParentClass(); + $name = $parentClass !== false ? $parentClass->getName() : $name; + } + $isNullable = $type->allowsNull() && $lowerName !== 'null' && $lowerName !== 'mixed'; + + return ($isNullable ? '?' : '') . $name; + } + if ($type instanceof \ReflectionUnionType) { + $members = []; + foreach ($type->getTypes() as $member) { + $rendered = self::renderType($member, $scope); + $members[] = $member instanceof \ReflectionIntersectionType ? '(' . $rendered . ')' : $rendered; + } + + return join('|', $members); + } + if ($type instanceof \ReflectionIntersectionType) { + $members = []; + foreach ($type->getTypes() as $member) { + $members[] = self::renderType($member, $scope); + } + + return join('&', $members); + } + + return (string) $type; + } + /** * Returns a pointer to the common structure (to work natively with zend_function and zend_internal_function) * From 3843b8585bfa6c59f838406b544d036d4378a582 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 01:42:07 +0000 Subject: [PATCH 04/14] test: keep the test suite clean under PHPStan 2.3 PHPStan 2.3 folds more constants through loops and casts: the nested iteration count of the get-iterator scenario and the property snapshot of the resolution-handlers test read as constant on the first iteration, which made their guards look always-true and the loops look endless, and an ArrayObject built without a type reads as holding nothing. - the scenario compares the nested iteration count with the item count of the iterated instance instead of a literal; - the resolution-handlers test asserts the snapshot with assertSame() instead of a guard ending the loop with fail(); - the error-callback test declares the element type of its log. No behaviour of the tests changes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 (cherry picked from commit 8fc1949a2a337e016840496aaa512d4b2c68aa57) --- tests/Memory/scenarios/get-iterator-hook.php | 2 +- tests/Reflection/ReflectionClassResolutionHandlersTest.php | 4 +--- tests/System/Hook/ErrorCallbackHookTest.php | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/Memory/scenarios/get-iterator-hook.php b/tests/Memory/scenarios/get-iterator-hook.php index 1d88383f..bd7dbc4e 100644 --- a/tests/Memory/scenarios/get-iterator-hook.php +++ b/tests/Memory/scenarios/get-iterator-hook.php @@ -57,7 +57,7 @@ } break; // and break out of the outer loop with the inner one completed } - if ($count !== 3) { + if ($count !== count($iterable->items)) { throw new RuntimeException('Unexpected nested iteration count'); } diff --git a/tests/Reflection/ReflectionClassResolutionHandlersTest.php b/tests/Reflection/ReflectionClassResolutionHandlersTest.php index 4748a007..778272d8 100644 --- a/tests/Reflection/ReflectionClassResolutionHandlersTest.php +++ b/tests/Reflection/ReflectionClassResolutionHandlersTest.php @@ -211,9 +211,7 @@ public function testGetPropertiesHandlerReflectsCurrentObjectState(): void // Stress the rebuild path: every iteration replaces the anchored table for ($index = 0; $index < 1000; $index++) { $proxy->subject = "value{$index}"; - if (self::propertySnapshot($proxy)['subject'] !== "value{$index}") { - self::fail('Unexpected property table snapshot'); - } + $this->assertSame("value{$index}", self::propertySnapshot($proxy)['subject'], 'Unexpected property table snapshot'); } } diff --git a/tests/System/Hook/ErrorCallbackHookTest.php b/tests/System/Hook/ErrorCallbackHookTest.php index 5ff81013..4c70e772 100644 --- a/tests/System/Hook/ErrorCallbackHookTest.php +++ b/tests/System/Hook/ErrorCallbackHookTest.php @@ -48,7 +48,6 @@ public function testHandlerObservesDiagnosticAndProceedsToEngineDefault(): void @trigger_error('observable diagnostic', E_USER_WARNING); $this->assertCount(1, $log); - /** @var array{type: int, file: string, line: int, message: string} $entry */ $entry = $log[0]; $this->assertSame(E_USER_WARNING, $entry['type']); $this->assertSame(__FILE__, $entry['file']); From b807faac1fa6db560be8147b10e60161aeab2b99 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 01:43:46 +0000 Subject: [PATCH 05/14] test(hook): type the error-callback log and check the entry before reading it Completes the previous commit for PHPStan 2.3: ArrayObject::offsetGet() may return null, so the entry is asserted before its keys are read. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- tests/System/Hook/ErrorCallbackHookTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/System/Hook/ErrorCallbackHookTest.php b/tests/System/Hook/ErrorCallbackHookTest.php index 4c70e772..5abed7a5 100644 --- a/tests/System/Hook/ErrorCallbackHookTest.php +++ b/tests/System/Hook/ErrorCallbackHookTest.php @@ -27,6 +27,7 @@ final class ErrorCallbackHookTest extends TestCase { public function testHandlerObservesDiagnosticAndProceedsToEngineDefault(): void { + /** @var ArrayObject $log */ $log = new ArrayObject(); $hook = Core::setErrorCallbackHandler(function (ErrorCallbackHook $hook) use ($log): void { $log->append([ @@ -49,6 +50,7 @@ public function testHandlerObservesDiagnosticAndProceedsToEngineDefault(): void $this->assertCount(1, $log); $entry = $log[0]; + $this->assertNotNull($entry); $this->assertSame(E_USER_WARNING, $entry['type']); $this->assertSame(__FILE__, $entry['file']); $this->assertSame($expectedLine, $entry['line']); From 5d3f07958b7b4306b94455883f6074489353e3b4 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 04:49:22 +0000 Subject: [PATCH 06/14] fix(reflection): keep engine copies of a swapped entry valid and release what the swap owns An in-place body swap and the publications built on it (the preserveAs alias, the addMethod() override) copied the entry structure where the engine also copies it, and freed or shared what those copies still used: - the run-time cache minted for an entry was freed by its next swap while a fake closure made over the entry (Closure::fromCallable, a first-class callable, ReflectionMethod::getClosure) or the engine clone a subclass implementing an interface holds still read it (use-after-free). The release now finds those copies first: a live frame on the block keeps it, a fake closure gets a private heap copy the engine frees with it, a clone gets a fresh minted cache (ObjectStore::instancesOf walks EG(objects_store) without letting its own handles alias the slots it reads); - the materialized live statics of a preserved body were orphaned: the alias now takes them over (the preserved body keeps its state), a swap destroys the previous body's table unless a fake closure or a clone borrowed it, and removeMethods() releases the live statics and the minted cache of an entry it unpublishes from the only table holding it; - the alias reached only subclasses sharing the entry pointer: every subclass linked before receives it now (a subclass declaring the name itself is marked ZEND_ACC_CHANGED), the shared-memory guard refuses any linked subclass, and an override carries ZEND_ACC_CHANGED whenever the inherited method does, as the engine propagates it; - engine clones of an inherited entry (subclass implementing an interface that declares the method) follow a redefinition in place and are replaced by an override like pointer-sharing buckets. Port of the 8.4 fix (ed9548b on feature/redefine-preserve-alias-8.4) without its 8.4-only statics-ownership parts: on 8.5 no donor kind owns the defaults table, so nothing is minted or duplicated for it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- docs/hot-swap.md | 106 ++++++-- src/Reflection/FunctionBodySwap.php | 322 ++++++++++++++++++++--- src/Reflection/FunctionLikeInterface.php | 11 + src/Reflection/FunctionLikeTrait.php | 58 +++- src/Reflection/MethodPublisher.php | 237 +++++++++++++---- src/Reflection/PendingBodySwap.php | 6 +- src/Reflection/ReflectionClass.php | 10 +- src/System/ExecutionData.php | 19 ++ src/System/ObjectStore.php | 77 ++++++ 9 files changed, 735 insertions(+), 111 deletions(-) diff --git a/docs/hot-swap.md b/docs/hot-swap.md index 5b3823f1..87de7b91 100644 --- a/docs/hot-swap.md +++ b/docs/hot-swap.md @@ -30,8 +30,10 @@ dispatch the new body. The implementation is centralized in **memory-flat** - the leak-plateau test drives 1000 cycles and measures zero overhead beyond the engine's own per-`eval` compile cost. - **The run-time cache is reset per swap.** Inline caches are scope-dependent - and sized for one specific body, so every swap installs a fresh zeroed - `ZEND_ACC_HEAP_RT_CACHE` cache that the engine (or the next swap) releases. + and sized for one specific body, so every swap installs a fresh zeroed cache + that the next swap releases - after detaching the engine copies of the entry + that still point at it (fake closures, interface clones), see "Run-time + caches of swapped and published entries" below. - **Static variables are unshared - the live table only.** The entry's `ZEND_MAP_PTR` slot is dropped so the live per-entry table materializes lazily on the first `ZEND_BIND_STATIC`, exactly like a plain compiled @@ -41,7 +43,9 @@ dispatch the new body. The implementation is centralized in slot only), so the last body holder frees it exactly once - see `FunctionBodySwap::unshareStaticVariables()` for the ownership rules. The class entry is flagged with `ZEND_HAS_STATIC_IN_METHODS` so the engine's - shutdown walk releases the live table. + shutdown walk releases the live table. The previous body's materialized live + table is destroyed with the swap unless a fake closure or an interface clone + still borrows it; a preserved alias takes it over instead (see `preserveAs`). - **Declaration identity is preserved.** The entry keeps its name, scope, prototype and declaration-level flags (visibility, static, final); body-level flags (variadic, generator, return type, strict types) follow the @@ -103,11 +107,24 @@ What the alias publication does, in engine terms: the alias carries `ZEND_ACC_CHANGED` exactly like a redeclared private method, so the ancestor's own body still resolves the ancestor's alias on instances of the subclass (`zend_get_parent_private_method`). -- Subclasses that were **linked before** and inherit the entry receive the - alias in their method tables too, with the ownership an inherited bucket - holds (`zend_duplicate_function` semantics) - private methods are inherited - into child tables at link time, and the swapped body propagating through the - shared entry pointer would otherwise call an alias the subclass lacks. +- **The alias takes over the live static variables** of the body it preserves: + the `static` state the previous body accumulated keeps counting when the alias + is called, while the entry's new body starts afresh (and so does the previous + body once it is swapped back in from the alias, as a method donor). +- **Every subclass linked before** receives the alias in its method table, with + the ownership an inherited bucket holds (`zend_duplicate_function` semantics) + - private methods are inherited into child tables at link time, and the + preserved body runs on instances of every subclass: through the shared entry + pointer, through an interface clone (see below) or through `parent::` from an + overriding subclass, and it would otherwise call an alias the subclass lacks. + A subclass that declares a private method of the alias name itself keeps it + and gets `ZEND_ACC_CHANGED`, the engine's marker for a redeclared + parent-private method. +- `removeMethods()` of the alias (the "drop the backup" step) releases what the + engine's bucket destructor does not reach: the alias' materialized live + statics and its minted run-time cache - unless a fake closure made over the + alias still shares them. The container itself stays request memory (the + documented baseline per publication). - Refused with `RedefinitionException` (nothing modified): an alias name the class already declares (a second preserving `redefine()` of the same method needs another name, or the first alias restored/removed first), an alias name @@ -115,9 +132,11 @@ What the alias publication does, in engine terms: function name that already exists, and an internal entry (no op_array to publish). - Under opcache the declaring class is copied out of shared memory first (see - below); publishing is refused with `SharedMemoryException` while a subclass - linked against the **shared** entry exists, because that subclass keeps the - shared parent and could never see the alias. Redefine before subclasses link. + below); publishing is refused with `SharedMemoryException` while **any** + subclass linked against the shared class exists - whether it inherits the + entry pointer, holds a clone of it or overrides the method - because that + subclass keeps the shared parent and could never see the alias. Redefine + before subclasses link. ### Overriding an inherited method (`addMethod()` with a donor) @@ -126,11 +145,15 @@ class only **inherits**, the donor body is published as an own entry that overrides the inherited one with the engine's inheritance rules: the prototype is kept (reflection and `parent::` keep working), a final method cannot be overridden, visibility cannot be narrowed, static-ness must agree, and a method -that is private in the ancestor is merely redeclared (`ZEND_ACC_CHANGED`). The -class's magic slots that referenced the inherited entry (constructor, `__get`, -...) follow the override, and so do the method tables and magic slots of -subclasses linked before that still inherited the ancestor's entry. A name the -class already declares itself is refused - that is `redefine()`'s job. +that is private in the ancestor is merely redeclared (`ZEND_ACC_CHANGED`) - a +bit the override also inherits from an inherited method that carries it, so an +ancestor's own code keeps resolving its own private method of that name on +instances of the class, as it does for compiled overrides. The class's magic +slots that referenced the inherited entry (constructor, `__get`, ...) follow +the override, and so do the method tables and magic slots of subclasses linked +before that still dispatch the ancestor's declaration - through the inherited +entry pointer or through an interface clone of it. A name the class already +declares itself is refused - that is `redefine()`'s job. ```php // Child inherits handle() from Base; the donor reaches the base body through parent:: @@ -152,6 +175,26 @@ therefore never left to the engine: the next swap of the same entry releases it never left `NULL` either: a call site whose inline cache already resolved the entry skips the lazy initialization. +The engine copies the entry structure - cache pointer included - wherever it +needs a `zend_function` of its own, so before a block is released its copies +are found and detached (`FunctionBodySwap::releaseRetiredRunTimeCache()`): + +- a **fake closure** made over the entry while the block was installed + (`Closure::fromCallable()`, a first-class callable `$obj->m(...)`, + `ReflectionMethod::getClosure()`) shares a scope-matching non-heap cache by + engine rule; it receives a private heap copy of its warmed slots, flagged + `ZEND_ACC_HEAP_RT_CACHE`, which `destroy_op_array` frees together with the + closure - exactly what the engine gives a closure over a heap-cached function; +- an **interface clone** of the entry (see "Subclass tables" below) gets a + fresh minted cache of its own; +- a **frame on the current stack** executing on the block (a fake closure of + the entry redefining it, or its caller chain) keeps the block allocated, + bounded to one block per such in-flight redefinition, like the previous body + itself. + +Finding those copies walks `EG(objects_store)` (`ObjectStore::instancesOf()`) +and the class table once per released block. + ## Class hot-swap (`HotSwap` / `ClassDelta`) ```php @@ -296,10 +339,15 @@ before such state exists: ## Memory footprint - `redefine()` and `ClassDelta` **body swaps are memory-flat**: each swap - releases the previous body, its heap run-time cache and its static tables. + releases the previous body, its minted 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. + arrays live in shared memory and are never freed, but the per-entry + run-time cache minted by the swap is released. Two bounded exceptions keep + memory of the previous body alive on purpose: a run-time cache a frame on + the current stack still executes on (one block per in-flight redefinition), + and a live statics table a fake closure still borrows (it would be freed from + under the closure otherwise; it leaks when the closure dies without being + the body's last holder). - 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 @@ -318,8 +366,11 @@ before such state exists: 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. + (also first-class callables and `ReflectionMethod::getClosure()`) share the + old body and keep it alive through its refcount; they continue to execute the + old body, with its static variables and a private copy of the inline caches + they warmed, until released. A fake closure made over a preserved alias keeps + the alias' live statics alive past `removeMethods()`. - **In-flight frames of the swapped entry**: the swap machinery walks the current VM call stack before destroying a previous body. If the redefined function is still executing (it redefined itself, directly or through a @@ -333,4 +384,15 @@ before such state exists: it is unshared. - **Subclass tables**: body swaps propagate to subclasses (shared structures); added methods do not; removed methods stay reachable through subclass - buckets that copied the pointer at link time. + buckets that copied the pointer at link time. One inheritance shape does not + share the structure: a subclass that implements an interface declaring an + inherited method gets an **interface clone** - its own `zend_function` copy of + the inherited entry (`do_inheritance_check_on_method`), with the ancestor's + scope and name, the interface method as prototype and a share on the body of + that moment. `FunctionBodySwap::cloneHolders()` finds those clones, and they + are kept in step: a `redefine()` of the ancestor's method swaps every clone in + place too, an `addMethod()` override replaces them like pointer-sharing + buckets, a preserved alias reaches their classes, and the shared-memory + guards count them. A clone inside a shared-memory subclass is never written - + that subclass keeps the previous body, like every subclass linked against a + shared entry. diff --git a/src/Reflection/FunctionBodySwap.php b/src/Reflection/FunctionBodySwap.php index d473cf28..9188fce4 100644 --- a/src/Reflection/FunctionBodySwap.php +++ b/src/Reflection/FunctionBodySwap.php @@ -15,7 +15,9 @@ use FFI\CData; use ZEngine\Core; +use ZEngine\Generated\HashTable as HashTableStruct; use ZEngine\Generated\zend_class_entry; +use ZEngine\Generated\zend_closure; use ZEngine\Generated\zend_function; use ZEngine\Type\StringEntry; @@ -37,11 +39,17 @@ * bucket and the inherited-method buckets of subclasses would be freed once per * bucket at shutdown), so z-engine releases it itself: every subsequent swap of * the entry releases the previous cache, which keeps repeated swaps memory-flat. + * The engine copies the entry structure, cache pointer included, into every fake + * closure made over the entry and into the clone a subclass implementing an + * interface gets of the inherited entry; such copies are found and given a cache of + * their own before the block is released (see releaseRetiredRunTimeCache()). * - The entry never shares the donor's LIVE static-variables table: its ZEND_MAP_PTR * slot is dropped so the table materializes lazily on the first ZEND_BIND_STATIC, * exactly like a plain compiled function. The defaults table behind it stays * shared and is guarded by the body refcount alone - no donor kind owns it, see - * unshareStaticVariables() for the PHP 8.5 ownership rules. + * unshareStaticVariables() for the PHP 8.5 ownership rules. A preserved alias takes + * over the live statics table of the body it preserves (the preserved body keeps + * its state). * - The previous body is destroyed with engine semantics (destroy_op_array) when * 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 @@ -94,6 +102,11 @@ final class FunctionBodySwap */ private static array $mintedRunTimeCaches = []; + /** + * Address of the engine's Closure class entry, resolved once (see liveClosureFunctions()) + */ + private static ?int $closureClassAddress = null; + /** * This is an utility class, no instances needed */ @@ -180,7 +193,6 @@ public static function swapUserFunctionBody( return new PendingBodySwap( $entryFunction, $previousBody, - $entryAddress, $publishedShares, $destroyPrevious, $previousMintedCache, @@ -209,24 +221,80 @@ public static function countPublishedShares(FunctionLikeInterface $entryFunction * entry at link time (see countPublishedShares() for the accounting this implies) * * A structure published in no class table (a plain function entry) has no holders. - * Class aliases resolve to the same class entry and are deduplicated. + * Class aliases resolve to the same class entry and are deduplicated. A subclass + * holding an engine CLONE of the entry instead of the pointer is not a holder, see + * cloneHolders(). * * @return list * * @internal */ public static function publishedShareHolders(FunctionLikeInterface $entryFunction): array + { + $holders = []; + foreach (self::bucketsNamedLike($entryFunction) as [$reflectionClass, $bucketFunction]) { + if ($bucketFunction->getAddress() === $entryFunction->getAddress()) { + $holders[] = $reflectionClass; + } + } + + return $holders; + } + + /** + * Returns the engine clones of a method entry published in the tables of its subclasses, + * each with the class holding it + * + * Inheritance shares the parent's zend_function POINTER into a subclass table (see + * publishedShareHolders()) - except when the subclass implements an interface that + * declares the method: the engine then separates the inherited entry into a struct copy + * of its own in the subclass table (do_inheritance_check_on_method), carrying the entry's + * scope and name, a prototype of its own and a share on the body the entry had at link + * time. Such a clone follows neither an in-place swap of the entry nor its publications + * unless it is handled explicitly, which is what the callers of this method do. + * + * @return list + * + * @internal + */ + public static function cloneHolders(FunctionLikeInterface $entryFunction): array + { + $entryScope = $entryFunction->getCommonPointer()->scope; + if ($entryScope === null) { + return []; + } + $scopeAddress = Core::addressOf($entryScope); + $entryAddress = $entryFunction->getAddress(); + + $clones = []; + foreach (self::bucketsNamedLike($entryFunction) as [$reflectionClass, $bucketFunction]) { + if ($bucketFunction->getAddress() === $entryAddress || $reflectionClass->getAddress() === $scopeAddress) { + continue; + } + $bucketScope = $bucketFunction->getCommonPointer()->scope; + if ($bucketScope !== null && Core::addressOf($bucketScope) === $scopeAddress) { + $clones[] = [$reflectionClass, $bucketFunction]; + } + } + + return $clones; + } + + /** + * Enumerates the method-table buckets of every user class that carry the entry's name + * + * @return iterable + */ + private static function bucketsNamedLike(FunctionLikeInterface $entryFunction): iterable { $entryCommon = $entryFunction->getCommonPointer(); if ($entryCommon->scope === null) { - return []; + return; } $namePointer = $entryCommon->function_name; assert($namePointer instanceof CData); - $lowerName = strtolower(StringEntry::fromCData($namePointer)->getStringValue()); - $entryAddress = $entryFunction->getAddress(); + $lowerName = strtolower(StringEntry::fromCData($namePointer)->getStringValue()); - $holders = []; $seenClasses = []; foreach (Core::$executor->classTable as $classValue) { try { @@ -246,32 +314,40 @@ public static function publishedShareHolders(FunctionLikeInterface $entryFunctio continue; } $bucketValue = $reflectionClass->getMethodTable()->find($lowerName); - if ($bucketValue !== null && Core::addressOf($bucketValue->getRawFunction()) === $entryAddress) { - $holders[] = $reflectionClass; + if ($bucketValue !== null) { + yield [$reflectionClass, ReflectionMethod::fromRawEntry($bucketValue->getRawFunction())]; } } - - return $holders; } /** * Prepares a standalone writable zend_function container from a donor method for - * publishing into a method table (the ClassDelta "new method" operation) + * publishing into a method table (the ClassDelta "new method" operation, the aliases + * and overrides of MethodPublisher) * * The container adopts the donor body with its own owned resources: one body * reference, one owned reference on the function name (every published bucket * releases a name reference when the engine destroys it), a fresh run-time cache - * and lazily-materialized statics. The donor may be destroyed afterwards. + * and lazily-materialized statics - or, for an alias preserving the donor's body, + * the donor's materialized live statics table, which moves over to the container + * (the preserved body keeps its state; the donor entry, about to execute another + * body, starts afresh). The donor may be destroyed afterwards. * * @param CData $container zend_function container to fill (writable, zeroed or reused) * @param FunctionLikeInterface $donor Donor method whose body is adopted * @param ReflectionClass|null $newScope Class the published method will belong to, or null * for a global function entry + * @param bool $inheritLiveStatics True when the container takes over the donor's live + * statics table (alias publications) * * @internal */ - public static function adoptFunctionForPublishing(object $container, FunctionLikeInterface $donor, ?ReflectionClass $newScope): void - { + public static function adoptFunctionForPublishing( + object $container, + FunctionLikeInterface $donor, + ?ReflectionClass $newScope, + bool $inheritLiveStatics = false, + ): void { Core::memcpy($container, $donor->getEntryPointer(), Core::sizeOfType(zend_function::class)); $containerFunction = ReflectionFunction::fromCData(Core::cast(zend_function::class, Core::addr($container))); @@ -287,7 +363,11 @@ public static function adoptFunctionForPublishing(object $container, FunctionLik self::addBodyReference($containerFunction); self::installFreshRunTimeCache($containerFunction); + $liveStatics = $inheritLiveStatics ? self::detachLiveStatics($donor) : null; self::unshareStaticVariables($containerFunction); + if ($liveStatics !== null) { + $containerFunction->getOpArrayPointer()->static_variables_ptr__ptr = $liveStatics; + } } /** @@ -386,9 +466,10 @@ private static function counterValue(object $counterPointer): int * subclass linked after a swap, every published alias inherited by a subclass) would * have the same block freed once per table at shutdown. So the block is never left * to the engine; the next swap of the same entry releases it (keeping repeated swaps - * memory-flat) and the request end reclaims whatever is left. It is never left NULL - * either: a call site whose inline cache already resolved the entry skips the lazy - * initialization and dereferences the cache unconditionally. + * memory-flat, see releaseRetiredRunTimeCache() for the copies of the entry that may + * still point at it) and the request end reclaims whatever is left. It is never left + * NULL either: a call site whose inline cache already resolved the entry skips the + * lazy initialization and dereferences the cache unconditionally. */ private static function installFreshRunTimeCache(FunctionLikeInterface $entryFunction): void { @@ -418,12 +499,60 @@ private static function installFreshRunTimeCache(FunctionLikeInterface $entryFun * * @internal called by PendingBodySwap::commit() */ - public static function releaseMintedRunTimeCache(int $entryAddress, ?CData $previousMintedCache): void + public static function releaseMintedRunTimeCache(FunctionLikeInterface $entryFunction, ?CData $previousMintedCache): void { - if ($previousMintedCache === null || self::hasLiveFrame($entryAddress)) { + if ($previousMintedCache === null || self::hasLiveFrame($entryFunction->getAddress())) { return; } - Core::free($previousMintedCache); + self::releaseRetiredRunTimeCache($entryFunction, $previousMintedCache); + } + + /** + * Releases a run-time cache block the entry no longer executes on, once nothing else reads it + * + * The block is request memory without the heap flag, so nothing but this release frees it - + * and the engine copies the entry structure, cache pointer included, wherever it needs a + * zend_function of its own. Three kinds of copies may still point at the block when the + * entry moves on, and each is handled before the free: + * + * - a frame on the current stack executing on the block (a fake closure of the entry, or + * its caller chain, redefining the entry): the block stays allocated, bounded to one + * block per such in-flight redefinition exactly like the previous body itself; + * - a live fake closure made over the entry while the block was installed + * (Closure::fromCallable(), a first-class callable, ReflectionMethod::getClosure()): + * zend_create_closure_ex shares a scope-matching non-heap cache, so the closure gets a + * private heap copy of its warmed slots, flagged ZEND_ACC_HEAP_RT_CACHE - what the engine + * gives a closure over a heap-cached function, and freed with the closure by + * destroy_op_array; + * - an engine clone of the entry in a subclass table (see cloneHolders()) gets a fresh + * minted cache of its own. + */ + private static function releaseRetiredRunTimeCache(FunctionLikeInterface $entryFunction, CData $cache): void + { + $cacheAddress = Core::addressOf($cache); + if (self::hasLiveFrameUsingCache($cacheAddress)) { + return; + } + foreach (self::liveClosureFunctions() as $closureFunction) { + $closureOpArray = $closureFunction->getOpArrayPointer(); + $closureCache = $closureOpArray->run_time_cache__ptr; + if ($closureCache === null || Core::addressOf($closureCache) !== $cacheAddress) { + continue; + } + $cacheSize = $closureOpArray->cache_size; + $privateCopy = Core::new("char[{$cacheSize}]", false); + Core::memcpy($privateCopy, $cache, $cacheSize); + $closureOpArray->run_time_cache__ptr = Core::cast('void **', $privateCopy); + $closureCommon = $closureFunction->getCommonPointer(); + $closureCommon->fn_flags = $closureCommon->fn_flags | Core::ZEND_ACC_HEAP_RT_CACHE; + } + foreach (self::cloneHolders($entryFunction) as [, $clone]) { + $cloneCache = $clone->getOpArrayPointer()->run_time_cache__ptr; + if ($cloneCache !== null && Core::addressOf($cloneCache) === $cacheAddress) { + self::installFreshRunTimeCache($clone); + } + } + Core::free($cache); } /** @@ -464,15 +593,33 @@ private static function unshareStaticVariables(FunctionLikeInterface $entryFunct } } + /** + * Takes the materialized live statics table away from an entry, for the container + * that preserves the entry's current body under an alias + * + * @return HashTableStruct|null The table (plain `HashTable *`), null when none materialized yet + */ + private static function detachLiveStatics(FunctionLikeInterface $entryFunction): ?object + { + $liveTable = $entryFunction->getLiveStaticsPointer(); + if ($liveTable === null) { + return null; + } + $entryFunction->getOpArrayPointer()->static_variables_ptr__ptr = null; + + return $liveTable; + } + /** * Destroys the previous body of a swapped entry with engine semantics * * The snapshot shares the function name with the entry (which keeps its owned * reference), so the name pointer is detached before destroy_op_array runs. The * materialized static-variables table is exclusively owned by this entry and is - * released explicitly for the last holder; everything else follows the body - * refcount - shared bodies (a live template op_array, a fake closure over the - * old body) keep their arrays until the last holder dies. + * released explicitly - unless a copy of the entry still points at it, see + * isLiveStaticsBorrowed(); everything else follows the body refcount - shared bodies + * (a live template op_array, a fake closure over the old body) keep their arrays + * until the last holder dies. * * @param CData $previousBody zend_function snapshot taken by the swap * @param int $releasedShares Total bucket shares the entry held on the previous body @@ -482,11 +629,12 @@ private static function unshareStaticVariables(FunctionLikeInterface $entryFunct * @internal called by PendingBodySwap::commit() */ public static function destroyPreviousBody( + FunctionLikeInterface $entryFunction, object $previousBody, - int $entryAddress, int $releasedShares, ?CData $previousMintedCache = null, ): void { + $entryAddress = $entryFunction->getAddress(); $previousFunction = ReflectionFunction::fromCData(Core::cast('zend_function *', Core::addr($previousBody))); $previousOpArray = $previousFunction->getOpArrayPointer(); @@ -501,15 +649,15 @@ public static function destroyPreviousBody( } if ($previousMintedCache !== null) { // The cache of the previous body was minted by z-engine (no heap flag, so - // destroy_op_array below leaves it alone) and nobody reads it anymore - Core::free($previousMintedCache); + // destroy_op_array below leaves it alone) + self::releaseRetiredRunTimeCache($entryFunction, $previousMintedCache); } // 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 - // resource the swap minted for it - the HEAP_RT_CACHE run-time cache - is - // ordinary request memory though, and is released below exactly like for a + // resource the swap minted for it - the run-time cache (released above) - is + // ordinary request memory though, and is released 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). @@ -528,11 +676,12 @@ public static function destroyPreviousBody( } $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 (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 + $liveTable = $previousOpArray->static_variables_ptr__ptr; + if ($liveTable !== null && ($isLastHolder || !self::isLiveStaticsBorrowed($entryFunction, $liveTable))) { + // Frees the materialized live static-variables table and clears the map + // slot. The table was reachable through this entry only - the slot now + // belongs to the new body - unless a fake closure over the old body or an + // engine clone of the entry borrowed it: then it must survive for them Core::call('zend_destroy_static_vars', $rawOpArray); } @@ -550,6 +699,89 @@ public static function destroyPreviousBody( Core::call('destroy_op_array', $rawOpArray); } + /** + * Releases what the engine never will once the bucket of a published entry is deleted + * from the only table holding it: the materialized live statics table (the engine + * destroys those through class tables only) and a minted run-time cache + * + * Nothing is released while a frame of the entry runs, while another class table still + * publishes the structure (the engine reaches the statics through that table) or while a + * fake closure still shares the resource. + * + * @internal called by ReflectionClass::removeMethods() before the bucket is deleted + */ + public static function retireUnpublishedEntry(FunctionLikeInterface $entryFunction): void + { + if (!$entryFunction->isUserDefined()) { + return; + } + $entryAddress = $entryFunction->getAddress(); + if (self::hasLiveFrame($entryAddress) || count(self::publishedShareHolders($entryFunction)) > 1) { + return; + } + $liveTable = $entryFunction->getLiveStaticsPointer(); + if ($liveTable !== null && !self::isLiveStaticsBorrowed($entryFunction, $liveTable)) { + Core::call('zend_destroy_static_vars', Core::cast('zend_op_array *', $entryFunction->getEntryPointer())); + } + + $mintedCache = self::$mintedRunTimeCaches[$entryAddress] ?? null; + if ($mintedCache !== null) { + unset(self::$mintedRunTimeCaches[$entryAddress]); + self::releaseRetiredRunTimeCache($entryFunction, $mintedCache); + $entryFunction->getOpArrayPointer()->run_time_cache__ptr = null; + } + } + + /** + * Whether a copy of the entry still reads the materialized live statics table: a + * fake closure borrows the table of the function it was made over (zend_create_closure_ex + * initializes its slot to it), an engine clone copied the slot with the structure + * + * @param HashTableStruct $liveTable + */ + private static function isLiveStaticsBorrowed(FunctionLikeInterface $entryFunction, object $liveTable): bool + { + $tableAddress = Core::addressOf($liveTable); + foreach (self::liveClosureFunctions() as $closureFunction) { + $closureSlot = $closureFunction->getOpArrayPointer()->static_variables_ptr__ptr; + if ($closureSlot !== null && Core::addressOf($closureSlot) === $tableAddress) { + return true; + } + } + foreach (self::cloneHolders($entryFunction) as [, $clone]) { + $cloneSlot = $clone->getOpArrayPointer()->static_variables_ptr__ptr; + if ($cloneSlot !== null && Core::addressOf($cloneSlot) === $tableAddress) { + return true; + } + } + + return false; + } + + /** + * Enumerates the zend_function of every live closure object as a pointer-level view + * + * Only user-function closures are of interest (an internal function's closure carries + * no op_array), so the enumeration is narrowed to those. + * + * @return iterable + */ + private static function liveClosureFunctions(): iterable + { + if (self::$closureClassAddress === null) { + $closureClass = Core::$executor->classTable->find('closure'); + assert($closureClass !== null); + self::$closureClassAddress = Core::addressOf($closureClass->getRawClass()); + } + foreach (Core::$executor->objectStore->instancesOf(self::$closureClassAddress) as $closureObject) { + $closure = Core::cast(zend_closure::class, $closureObject->getRawValue()); + $closureFunction = ReflectionFunction::fromCData(Core::addr($closure->func)); + if ($closureFunction->isUserDefined()) { + yield $closureFunction; + } + } + } + /** * Checks if any frame of the current VM call stack executes the given entry * @@ -573,6 +805,26 @@ private static function hasLiveFrame(int $entryAddress): bool } } + /** + * Checks if any user-function frame of the current VM call stack reads its inline + * caches from the given cache block (a fake closure of the entry running right now, + * for instance) + */ + private static function hasLiveFrameUsingCache(int $cacheAddress): bool + { + $frame = Core::$executor->getExecutionState(); + while (true) { + $frameFunction = $frame->getFunctionEntry(); + if ($frameFunction !== null && $frameFunction->isUserDefined() && $frame->getRunTimeCacheAddress() === $cacheAddress) { + return true; + } + if (!$frame->hasPrevious()) { + return false; + } + $frame = $frame->getPrevious(); + } + } + /** * Returns the resources taken by an uncommitted swap and forgets its bookkeeping * @@ -590,7 +842,7 @@ public static function releaseSwappedInBody( $entryAddress = $entryFunction->getAddress(); $mintedCache = self::$mintedRunTimeCaches[$entryAddress] ?? null; if ($mintedCache !== null) { - Core::free($mintedCache); + self::releaseRetiredRunTimeCache($entryFunction, $mintedCache); } if ($previousMintedCache !== null) { self::$mintedRunTimeCaches[$entryAddress] = $previousMintedCache; diff --git a/src/Reflection/FunctionLikeInterface.php b/src/Reflection/FunctionLikeInterface.php index 6a1b7ebb..28fa986c 100644 --- a/src/Reflection/FunctionLikeInterface.php +++ b/src/Reflection/FunctionLikeInterface.php @@ -13,6 +13,7 @@ namespace ZEngine\Reflection; +use ZEngine\Generated\HashTable; use ZEngine\Generated\zend_function; use ZEngine\Generated\zend_function_common; use ZEngine\Generated\zend_internal_function; @@ -52,6 +53,16 @@ public function getEntryPointer(): object; public function isUserDefined(): bool; + /** + * Materialized live static-variables table of a user function, null while no call bound the + * statics yet (see FunctionLikeTrait::getLiveStaticsPointer()) + * + * @return HashTable|null + * + * @internal + */ + public function getLiveStaticsPointer(): ?object; + /** * Pointer-based name of the entry, null for unnamed entries (see FunctionLikeTrait::getFunctionName()) */ diff --git a/src/Reflection/FunctionLikeTrait.php b/src/Reflection/FunctionLikeTrait.php index 24dbec91..c5904390 100644 --- a/src/Reflection/FunctionLikeTrait.php +++ b/src/Reflection/FunctionLikeTrait.php @@ -15,6 +15,7 @@ use FFI\CData; use ZEngine\Core; +use ZEngine\Generated\HashTable as HashTableStruct; use ZEngine\Generated\zend_class_entry; use ZEngine\Generated\zend_function; use ZEngine\Generated\zend_function_common; @@ -262,8 +263,10 @@ public function redefine(\Closure|FunctionLikeInterface $newBody, ?string $prese ? MethodPublisher::publishMethodAlias($entryFunction, $preserveAs) : MethodPublisher::publishFunctionAlias($entryFunction, $preserveAs); } + /** @var list $pendingSwaps */ + $pendingSwaps = []; try { - $pendingSwap = FunctionBodySwap::swapUserFunctionBody( + $pendingSwaps[] = FunctionBodySwap::swapUserFunctionBody( $entryFunction, $donorFunction, preserveDeclaration: true, @@ -273,13 +276,36 @@ public function redefine(\Closure|FunctionLikeInterface $newBody, ?string $prese // bucket releases one body reference when the engine destroys it publishedShares: FunctionBodySwap::countPublishedShares($entryFunction), ); + // A subclass implementing an interface that declares the method holds an engine + // CLONE of the entry instead of the entry pointer (FunctionBodySwap::cloneHolders()): + // the clone dispatches for that subclass, so it follows the redefinition in place + foreach (FunctionBodySwap::cloneHolders($entryFunction) as [, $clone]) { + if ($clone->isImmutable()) { + // A clone inside a shared-memory subclass is never written: that subclass + // keeps the previous body, like every subclass linked against a shared entry + continue; + } + $pendingSwaps[] = FunctionBodySwap::swapUserFunctionBody( + $clone, + $donorFunction, + preserveDeclaration: true, + // The clone was copied from the entry: a shared-memory body (no refcount) is immortal + destroyPrevious: $clone->getOpArrayPointer()->refcount !== null, + publishedShares: FunctionBodySwap::countPublishedShares($clone), + ); + } } catch (\Throwable $error) { + foreach (array_reverse($pendingSwaps) as $pendingSwap) { + $pendingSwap->rollback(); + } if ($unpublishAlias !== null) { $unpublishAlias(); } throw $error; } - $pendingSwap->commit(); + foreach ($pendingSwaps as $pendingSwap) { + $pendingSwap->commit(); + } } /** @@ -733,6 +759,34 @@ public function getStaticVariables(): ?HashTable * @return CData|null HashTable* stored in the slot, or null while the slot is not yet * materialized (or the map-ptr area cannot vouch for it) */ + /** + * Returns the materialized live static-variables table of this user function as the raw + * `HashTable *`, or null while no call has bound the statics yet (both slot forms, see + * getStaticVariables()) + * + * @return HashTableStruct|null + * + * @internal used by FunctionBodySwap, which moves the table to the alias preserving the body + */ + public function getLiveStaticsPointer(): ?object + { + $liveTable = $this->getOpArrayPointer()->static_variables_ptr__ptr; + if ($liveTable === null) { + return null; + } + $rawFieldValue = Core::addressOf($liveTable); + if (($rawFieldValue & 1) === 0) { + return $liveTable; + } + $materialized = $this->dereferenceMapPointerSlot($rawFieldValue); + if ($materialized === null) { + return null; + } + + // The map-ptr slot holds the materialized `HashTable *`: same pointer, stub-typed view + return Core::cast(HashTableStruct::class, $materialized); + } + private function dereferenceMapPointerSlot(int $slotOffset): ?CData { $mapPointerBase = Core::$compiler->getMapPointerBaseAddress(); diff --git a/src/Reflection/MethodPublisher.php b/src/Reflection/MethodPublisher.php index 03566725..55f616eb 100644 --- a/src/Reflection/MethodPublisher.php +++ b/src/Reflection/MethodPublisher.php @@ -68,14 +68,15 @@ public static function assertAliasPublishable(FunctionLikeInterface $entry): voi if (!$declaringClass->isImmutable()) { return; } - foreach (FunctionBodySwap::publishedShareHolders($entry) as $holder) { - if ($holder->getAddress() !== $declaringClass->getAddress()) { - throw SharedMemoryException::sharedEntryHasLinkedSubclasses( - $declaringClass->getName(), - self::entryName($entry), - $holder->getName(), - ); - } + // Every subclass linked so far keeps dispatching the shared entry after the copy-out, + // whether it inherited the pointer, holds an engine clone of it or overrides the + // method and reaches the shared body through parent:: + foreach (self::subclassesOf($declaringClass) as $subclass) { + throw SharedMemoryException::sharedEntryHasLinkedSubclasses( + $declaringClass->getName(), + self::entryName($entry), + $subclass->getName(), + ); } } @@ -97,7 +98,7 @@ public static function assertOverridePublishable(ReflectionClass $class, string return; } $inheritedMethod = ReflectionMethod::fromRawEntry($inherited->getRawFunction()); - foreach (self::descendantsHolding($class, $inheritedMethod) as $descendant) { + foreach (self::descendantsHolding($class, strtolower($methodName), $inheritedMethod) as $descendant) { throw SharedMemoryException::sharedEntryHasLinkedSubclasses( $class->getName(), $methodName, @@ -115,9 +116,12 @@ public static function assertOverridePublishable(ReflectionClass $class, string * a constructor and never final. When the class inherits a private method of that name * from an ancestor the alias carries ZEND_ACC_CHANGED, exactly like a redeclared private * method, so the ancestor's own code still resolves its own alias on instances of this - * class. Subclasses that were linked before and inherit the entry receive the alias in - * their method tables too (private methods are inherited into child tables at link time), - * with the ownership an inherited bucket holds. + * class. Every subclass linked before receives the alias in its method table too, the way + * the engine copies a parent's private methods into child tables at link time (the + * preserved body runs on instances of overriding subclasses as well, through parent::), + * with the ownership an inherited bucket holds; a subclass declaring the alias name itself + * keeps its own method, which gets ZEND_ACC_CHANGED like any redeclared parent-private + * method. The alias takes over the live static variables of the body it preserves. * * @return array{ReflectionMethod, \Closure(): void} The published alias and the action * unpublishing it again (rollback of a @@ -144,15 +148,25 @@ public static function publishMethodAlias(FunctionLikeInterface $entry, string $ ); } - // Subclasses whose tables share the entry inherit the alias as well - collected before - // anything is published, while the tables are untouched - $subclasses = array_values(array_filter( - FunctionBodySwap::publishedShareHolders($entry), - static fn(ReflectionClass $holder): bool => $holder->getAddress() !== $class->getAddress(), - )); + // Every subclass linked before inherits the alias as well, or redeclares it - collected + // before anything is published, while the tables are untouched + $inheritors = []; + $redeclarers = []; + foreach (self::subclassesOf($class) as $subclass) { + $subclassEntry = self::findInheritedEntry($subclass, $lowerAlias); + if ($subclassEntry === false) { + $redeclarers[] = $subclass; + } elseif ($subclassEntry === null + || ($inheritedAlias !== null && $subclassEntry->getAddress() === $inheritedAlias->getAddress()) + ) { + // The name is free in the subclass, or it inherits the very ancestor-private + // method the alias shadows: the nearer declaration wins, as at link time + $inheritors[] = $subclass; + } + } $container = self::newContainer(); - $aliasMethod = self::adopt($container, $entry, $class, $alias); + $aliasMethod = self::adopt($container, $entry, $class, $alias, inheritLiveStatics: true); // A preserved body is a private implementation detail of the class: not a // constructor, not final, and an override marker only where an ancestor declared a // private method of the same name @@ -164,16 +178,22 @@ public static function publishMethodAlias(FunctionLikeInterface $entry, string $ /** @var list<\Closure(): void> $undoStack */ $undoStack = []; + /** @var list $retired */ + $retired = []; try { $published = self::publishOwnEntry($table, $lowerAlias, $container, $inheritedAlias, $undoStack); - foreach ($subclasses as $subclass) { - self::shareIntoSubclass($subclass, $lowerAlias, $published, $undoStack); + foreach ($inheritors as $subclass) { + self::shareIntoSubclass($subclass, $lowerAlias, $published, $undoStack, $retired); + } + foreach ($redeclarers as $subclass) { + self::markRedeclaration($subclass, $lowerAlias, $entry, $undoStack); } } catch (\Throwable $error) { self::unwind($undoStack); self::releaseContainer($container); throw $error; } + self::retire($retired); $undo = static function () use ($undoStack, $container): void { self::unwind($undoStack); @@ -199,7 +219,7 @@ public static function publishFunctionAlias(FunctionLikeInterface $entry, string } $container = self::newContainer(); - self::adopt($container, $entry, null, $alias); + self::adopt($container, $entry, null, $alias, inheritLiveStatics: true); try { $stored = $table->addFunctionEntry($lowerAlias, $container); } catch (\Throwable $error) { @@ -222,11 +242,14 @@ public static function publishFunctionAlias(FunctionLikeInterface $entry, string * The override follows the engine's inheritance rules: it keeps the inherited method's * prototype (so reflection and `parent::` keep working), may not override a final * method, may not narrow visibility, must agree on static-ness, and redeclaring a - * method that is private in the ancestor only marks the new entry ZEND_ACC_CHANGED. - * The replaced inherited bucket returns the ownership it held; the class's magic + * method that is private in the ancestor only marks the new entry ZEND_ACC_CHANGED - a + * bit that also travels down from an inherited method carrying it, so an ancestor's + * own code keeps resolving its own private method of that name on instances of this + * class. The replaced inherited bucket returns the ownership it held; the class's magic * slots (constructor, __get, ...) that referenced the inherited entry follow the * override, and so do the method tables and magic slots of subclasses that were linked - * before and still inherited the ancestor's entry. + * before and still dispatch the ancestor's declaration (the inherited entry pointer, or + * an engine clone of it, see FunctionBodySwap::cloneHolders()). */ public static function publishOverride(ReflectionClass $class, string $methodName, FunctionLikeInterface $donor): ReflectionMethod { @@ -252,30 +275,33 @@ public static function publishOverride(ReflectionClass $class, string $methodNam if (!$isInheritedPrivate && self::visibilityRank($donor) < self::visibilityRank($inherited)) { throw RedefinitionException::visibilityNarrowed($className, $methodName, $declaringClass); } - $descendants = self::descendantsHolding($class, $inherited); + $descendants = self::descendantsHolding($class, $lowerName, $inherited); } $container = self::newContainer(); $method = self::adopt($container, $donor, $class, $methodName); if ($inherited !== null) { - if (self::hasFlag($inherited, Core::ZEND_ACC_PRIVATE)) { - // The engine marks a redeclared parent-private method CHANGED and links no prototype - $method->setChangedFlag(true); - $method->setPrototype(null); - } else { - $method->setChangedFlag(false); - $method->setPrototype(self::prototypeOf($inherited)); - } + // The engine marks a redeclared parent-private method CHANGED and links no + // prototype; a CHANGED inherited method passes the bit on (do_inheritance_check_on_method) + $isInheritedPrivate = self::hasFlag($inherited, Core::ZEND_ACC_PRIVATE); + $method->setChangedFlag($isInheritedPrivate || self::hasFlag($inherited, Core::ZEND_ACC_CHANGED)); + $method->setPrototype($isInheritedPrivate ? null : self::prototypeOf($inherited)); + } else { + // A method overriding nothing carries neither the donor's prototype nor its CHANGED bit + $method->setChangedFlag(false); + $method->setPrototype(null); } /** @var list<\Closure(): void> $undoStack */ $undoStack = []; + /** @var list $retired */ + $retired = []; try { $published = self::publishOwnEntry($table, $lowerName, $container, $inherited, $undoStack); if ($inherited !== null) { self::followMagicSlot($class, $inherited, $published, $undoStack); foreach ($descendants as $descendant) { - self::shareIntoSubclass($descendant, $lowerName, $published, $undoStack); + self::shareIntoSubclass($descendant, $lowerName, $published, $undoStack, $retired); self::followMagicSlot($descendant, $inherited, $published, $undoStack); } } @@ -284,6 +310,7 @@ public static function publishOverride(ReflectionClass $class, string $methodNam self::releaseContainer($container); throw $error; } + self::retire($retired); return ReflectionMethod::fromCData($published->getEntryPointer()); } @@ -307,9 +334,14 @@ private static function newContainer(): CData /** * Fills a container with the donor body under the given name and scope */ - private static function adopt(CData $container, FunctionLikeInterface $donor, ?ReflectionClass $scope, string $name): ReflectionMethod - { - FunctionBodySwap::adoptFunctionForPublishing($container, $donor, $scope); + private static function adopt( + CData $container, + FunctionLikeInterface $donor, + ?ReflectionClass $scope, + string $name, + bool $inheritLiveStatics = false, + ): ReflectionMethod { + FunctionBodySwap::adoptFunctionForPublishing($container, $donor, $scope, $inheritLiveStatics); $method = ReflectionMethod::fromRawEntry(Core::cast(zend_function::class, Core::addr($container))); $method->setFunctionName($name); @@ -366,15 +398,20 @@ private static function publishOwnEntry( * Publishes an entry pointer into the method table of a subclass that inherited the * replaced entry, with the ownership an inherited bucket holds (zend_duplicate_function) * - * A subclass declaring its own method of that name keeps it. + * A subclass declaring its own method of that name keeps it. A replaced bucket that + * published nothing but an engine clone of the inherited entry leaves that clone + * unpublished: it is listed in $retired for the release of its resources once the + * publication stands (see retire()). * * @param list<\Closure(): void> $undoStack + * @param list $retired */ private static function shareIntoSubclass( ReflectionClass $subclass, string $lowerName, ReflectionMethod $published, array &$undoStack, + array &$retired, ): void { $subTable = $subclass->getMethodTable(); $previous = self::findInheritedEntry($subclass, $lowerName); @@ -388,6 +425,7 @@ private static function shareIntoSubclass( FunctionBodySwap::acquireBucketOwnership($previous); $subTable->addFunctionEntry($lowerName, self::entryStruct($previous)); }; + $retired[] = $previous; } FunctionBodySwap::acquireBucketOwnership($published); $subTable->addFunctionEntry($lowerName, self::entryStruct($published)); @@ -435,19 +473,124 @@ private static function followMagicSlot( } /** - * Subclasses of $class (linked before) whose tables still hold the inherited entry + * Subclasses of $class (linked before) whose tables still dispatch the declaration of the + * inherited entry under $lowerName: the entry pointer itself or an engine clone of it + * (a subclass implementing an interface that declares the method holds its own struct + * copy of the inherited entry, see FunctionBodySwap::cloneHolders()) * * @return list */ - private static function descendantsHolding(ReflectionClass $class, ReflectionMethod $inherited): array + private static function descendantsHolding(ReflectionClass $class, string $lowerName, ReflectionMethod $inherited): array { - $className = $class->getName(); + $declaringScope = $inherited->getCommonPointer()->scope; + assert($declaringScope !== null); + $scopeAddress = Core::addressOf($declaringScope); + + $descendants = []; + foreach (self::subclassesOf($class) as $subclass) { + $bucket = self::findInheritedEntry($subclass, $lowerName); + if (!$bucket instanceof ReflectionMethod) { + continue; + } + $bucketScope = $bucket->getCommonPointer()->scope; + if ($bucketScope !== null && Core::addressOf($bucketScope) === $scopeAddress) { + $descendants[] = $subclass; + } + } + + return $descendants; + } + + /** + * Marks the own method of a subclass that declares the alias name itself as redeclaring + * a parent-private method (ZEND_ACC_CHANGED), so the preserved body - private in the + * declaring class - resolves its alias on instances of that subclass as the engine would + * + * @param list<\Closure(): void> $undoStack + */ + private static function markRedeclaration( + ReflectionClass $subclass, + string $lowerName, + FunctionLikeInterface $entry, + array &$undoStack, + ): void { + $bucket = $subclass->getMethodTable()->find($lowerName); + assert($bucket !== null); + $ownMethod = ReflectionMethod::fromRawEntry($bucket->getRawFunction()); + if (self::hasFlag($ownMethod, Core::ZEND_ACC_CHANGED)) { + return; + } + if ($ownMethod->isImmutable()) { + // The flag cannot be written into shared memory, and without it the preserved + // body could not reach its alias on instances of that subclass + throw SharedMemoryException::sharedEntryHasLinkedSubclasses( + self::declaringClassName($entry), + self::entryName($entry), + $subclass->getName(), + ); + } + $ownMethod->setChangedFlag(true); + $undoStack[] = static function () use ($ownMethod): void { + $ownMethod->setChangedFlag(false); + }; + } + + /** + * Releases the resources of entries a publication left unpublished (replaced engine + * clones): nothing in any table reaches them anymore, see FunctionBodySwap::retireUnpublishedEntry() + * + * @param list $retired + */ + private static function retire(array $retired): void + { + foreach ($retired as $entry) { + FunctionBodySwap::retireUnpublishedEntry($entry); + } + } + + /** + * Every linked user class descending from $class, class aliases deduplicated + * + * @return list + */ + private static function subclassesOf(ReflectionClass $class): array + { + $classAddress = $class->getAddress(); + $subclasses = []; + $seenClasses = []; + foreach (Core::$executor->classTable as $classValue) { + try { + $rawClass = $classValue->getRawClass(); + } catch (\UnexpectedValueException $e) { + // Class alias buckets (IS_ALIAS_PTR) resolve to an already-visited entry + continue; + } + $candidateAddress = Core::addressOf($rawClass); + if ($candidateAddress === $classAddress || isset($seenClasses[$candidateAddress])) { + continue; + } + $seenClasses[$candidateAddress] = true; + $candidate = ReflectionClass::fromCData($rawClass); + if ($candidate->isUserDefined() && self::descendsFrom($candidate, $classAddress)) { + $subclasses[] = $candidate; + } + } + + return $subclasses; + } + + /** + * Whether the class entry at $ancestorAddress is on the parent chain of $candidate + */ + private static function descendsFrom(ReflectionClass $candidate, int $ancestorAddress): bool + { + for ($parent = $candidate->getParentClass(); $parent !== null; $parent = $parent->getParentClass()) { + if ($parent->getAddress() === $ancestorAddress) { + return true; + } + } - return array_values(array_filter( - FunctionBodySwap::publishedShareHolders($inherited), - static fn(ReflectionClass $holder): bool => $holder->getAddress() !== $class->getAddress() - && is_subclass_of($holder->getName(), $className, true), - )); + return false; } /** diff --git a/src/Reflection/PendingBodySwap.php b/src/Reflection/PendingBodySwap.php index 4ef6d828..4627abc5 100644 --- a/src/Reflection/PendingBodySwap.php +++ b/src/Reflection/PendingBodySwap.php @@ -37,7 +37,6 @@ final class PendingBodySwap /** * @param FunctionLikeInterface $entry Swapped entry (published function/method) * @param CData $previousBody zend_function snapshot of the previous body - * @param int $entryAddress Numeric identity of the entry * @param int $publishedShares Bucket shares held on both the previous and the new body * @param bool $destroyPrevious False for shared-memory previous bodies (never freed) * @param CData|null $previousMintedCache Run-time cache an earlier swap minted for the previous @@ -46,7 +45,6 @@ final class PendingBodySwap public function __construct( private FunctionLikeInterface $entry, private CData $previousBody, - private int $entryAddress, private int $publishedShares, private bool $destroyPrevious, private ?CData $previousMintedCache = null, @@ -61,14 +59,14 @@ public function commit(): void $this->isResolved = true; if ($this->destroyPrevious) { FunctionBodySwap::destroyPreviousBody( + $this->entry, $this->previousBody, - $this->entryAddress, $this->publishedShares, $this->previousMintedCache, ); } else { // A shared-memory body is never freed, but the cache z-engine minted for it is - FunctionBodySwap::releaseMintedRunTimeCache($this->entryAddress, $this->previousMintedCache); + FunctionBodySwap::releaseMintedRunTimeCache($this->entry, $this->previousMintedCache); } } diff --git a/src/Reflection/ReflectionClass.php b/src/Reflection/ReflectionClass.php index ff857bee..690c4d3c 100644 --- a/src/Reflection/ReflectionClass.php +++ b/src/Reflection/ReflectionClass.php @@ -1036,7 +1036,15 @@ public function removeMethods(string ...$methodNames): void { $this->ensureWritableEntry('remove methods'); foreach ($methodNames as $methodName) { - $this->methodTable->delete(strtolower($methodName)); + $lowerName = strtolower($methodName); + $bucket = $this->methodTable->find($lowerName); + if ($bucket !== null) { + // The bucket destructor frees the body for its last holder only: the materialized + // statics of the entry and whatever z-engine minted for it (a published alias + // carries a run-time cache and may carry a statics duplicate) are released here + FunctionBodySwap::retireUnpublishedEntry(ReflectionMethod::fromRawEntry($bucket->getRawFunction())); + } + $this->methodTable->delete($lowerName); } } diff --git a/src/System/ExecutionData.php b/src/System/ExecutionData.php index 50cd5f24..68cd5adb 100644 --- a/src/System/ExecutionData.php +++ b/src/System/ExecutionData.php @@ -166,6 +166,25 @@ public function getFunctionEntry(): ?ReflectionFunction return ReflectionFunction::fromCData($rawFunction); } + /** + * Returns the address of the run-time cache block this frame reads its inline caches + * from, or null when the frame has none + * + * Meaningful for user-function frames only: the engine leaves the field alone for + * internal calls, so callers check the frame function first. + * + * @internal used by FunctionBodySwap to keep a cache block alive while a frame runs on it + */ + public function getRunTimeCacheAddress(): ?int + { + $cache = $this->pointer->run_time_cache; + if ($cache === null) { + return null; + } + + return Core::addressOf($cache); + } + /** * Returns the class scope of the code executing in this frame as a framework * wrapper, or null when there is none (plain function, unbound closure, main diff --git a/src/System/ObjectStore.php b/src/System/ObjectStore.php index d01ed1f0..75349b67 100644 --- a/src/System/ObjectStore.php +++ b/src/System/ObjectStore.php @@ -18,6 +18,7 @@ use FFI\CData; use ZEngine\Core; use ZEngine\Generated\zend_object; +use ZEngine\Generated\zend_objects_store; use ZEngine\Type\ObjectEntry; final class ObjectStore implements Countable, ArrayAccess @@ -109,6 +110,82 @@ public function offsetUnset($offset): void throw new \LogicException('Object store is read-only structure'); } + /** + * Enumerates the live objects of one class, as borrowed entries + * + * Walking the store allocates objects of its own (every FFI handle is one), and those + * take free slots of the very store being walked. So the slot values are read through + * one integer view of the bucket array that allocates nothing per step, a slot found to + * hold the handle of the previous step is skipped before that handle is released, the + * view is renewed when the store grows (its bucket array moves then), and the garbage + * collector stays off for the walk: no object an entry is created for can die between + * the read of its slot and the read of its class. Slots added during the walk (the + * handles of the walk and of its consumer) are not visited. + * + * @param int $classAddress Address of the zend_class_entry whose instances are wanted + * + * @return iterable + * + * @internal used by FunctionBodySwap to find the closures made over a swapped entry + */ + public function instancesOf(int $classAddress): iterable + { + $gcWasEnabled = gc_enabled(); + gc_disable(); + try { + // EG(objects_store) is an embedded struct: its address, as the stub-typed pointer view + $store = Core::cast(zend_objects_store::class, Core::addr($this->pointer)); + $top = $store->top; + $size = $store->size; + $slots = self::slotAddresses($store); + $object = null; + for ($handle = 1; $handle < $top; $handle++) { + if ($store->size !== $size) { + // The store grew during the walk: the bucket array was reallocated + $size = $store->size; + $slots = self::slotAddresses($store); + } + $address = $slots[$handle]; + assert(is_int($address)); + if (($address & self::OBJ_BUCKET_INVALID) !== 0) { + continue; + } + if ($object !== null && spl_object_id($object) === $handle) { + // The slot holds the handle of the previous step, which the next assignment releases + continue; + } + $object = Core::pointerAtAddress('zend_object *', $address); + /** @var zend_object $object */ + $classEntry = $object->ce; + if ($classEntry === null || Core::addressOf($classEntry) !== $classAddress) { + continue; + } + + yield ObjectEntry::fromCData($object); + } + } finally { + if ($gcWasEnabled) { + gc_enable(); + } + } + } + + /** + * Integer view (`uintptr_t *`) of the store's bucket array: the tagged slot values, readable + * without allocating a handle per slot + * + * @param zend_objects_store $store + */ + private static function slotAddresses(object $store): CData + { + $buckets = $store->object_buckets; + assert($buckets !== null); + $slots = Core::pointerAtAddress('uintptr_t *', Core::addressOf($buckets)); + assert($slots instanceof CData); + + return $slots; + } + /** * Returns the free head (aka next handle) */ From 5cd9baf71f032aec06b13c3b1631fde88e51bfd3 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 05:01:57 +0000 Subject: [PATCH 07/14] test(reflection): pin the run-time cache release against fake closures, clones and live frames Fake closures of every kind made over a redefined method or function keep executing the previous body on a private heap copy of the block the next swap retires, an interface clone of a subclass linked after a swap follows the next swap with a minted cache of its own, a fake closure redefining its own method mid-flight keeps the block it runs on, a hundred swaps next to a live fake closure stay memory-flat, and a fake closure over a preserved alias survives a redefinition of the alias. The mid-flight scenario keeps its state in static properties of the test class: on PHP 8.5 a closure donor's bound variables stay with the closure object, so the entry adopting the body would read them unbound. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- tests/Reflection/RedefineRunTimeCacheTest.php | 403 ++++++++++++++++++ .../cachefake-fake-closure-plateau.php | 106 +++++ tests/Stub/Alias/CacheFake-functions.php | 29 ++ tests/Stub/Alias/CacheFakeAliasDonor.php | 27 ++ tests/Stub/Alias/CacheFakeAliasTarget.php | 28 ++ tests/Stub/Alias/CacheFakeCloneBase.php | 26 ++ tests/Stub/Alias/CacheFakeCloneChild.php | 21 + tests/Stub/Alias/CacheFakeCloneContract.php | 24 ++ tests/Stub/Alias/CacheFakeClosureTarget.php | 29 ++ tests/Stub/Alias/CacheFakeLiveFrameTarget.php | 26 ++ tests/Stub/Alias/CacheFakeMethodDonor.php | 28 ++ tests/Stub/Alias/CacheFakeMethodTarget.php | 28 ++ tests/Stub/Alias/CacheFakePlateauTarget.php | 26 ++ 13 files changed, 801 insertions(+) create mode 100644 tests/Reflection/RedefineRunTimeCacheTest.php create mode 100644 tests/Reflection/scenarios/cachefake-fake-closure-plateau.php create mode 100644 tests/Stub/Alias/CacheFake-functions.php create mode 100644 tests/Stub/Alias/CacheFakeAliasDonor.php create mode 100644 tests/Stub/Alias/CacheFakeAliasTarget.php create mode 100644 tests/Stub/Alias/CacheFakeCloneBase.php create mode 100644 tests/Stub/Alias/CacheFakeCloneChild.php create mode 100644 tests/Stub/Alias/CacheFakeCloneContract.php create mode 100644 tests/Stub/Alias/CacheFakeClosureTarget.php create mode 100644 tests/Stub/Alias/CacheFakeLiveFrameTarget.php create mode 100644 tests/Stub/Alias/CacheFakeMethodDonor.php create mode 100644 tests/Stub/Alias/CacheFakeMethodTarget.php create mode 100644 tests/Stub/Alias/CacheFakePlateauTarget.php diff --git a/tests/Reflection/RedefineRunTimeCacheTest.php b/tests/Reflection/RedefineRunTimeCacheTest.php new file mode 100644 index 00000000..982a220a --- /dev/null +++ b/tests/Reflection/RedefineRunTimeCacheTest.php @@ -0,0 +1,403 @@ + + * + * 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\Reflection; + +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Stub\Alias\CacheFakeAliasDonor; +use ZEngine\Stub\Alias\CacheFakeAliasTarget; +use ZEngine\Stub\Alias\CacheFakeCloneBase; +use ZEngine\Stub\Alias\CacheFakeCloneChild; +use ZEngine\Stub\Alias\CacheFakeClosureTarget; +use ZEngine\Stub\Alias\CacheFakeLiveFrameTarget; +use ZEngine\Stub\Alias\CacheFakeMethodDonor; +use ZEngine\Stub\Alias\CacheFakeMethodTarget; +use ZEngine\Type\ClosureEntry; + +/** + * The run-time cache a body swap mints for an entry, and the engine copies of the entry that + * point at it when the next swap releases the block + * + * A swapped entry gets a fresh request-memory run-time cache WITHOUT ZEND_ACC_HEAP_RT_CACHE + * (a heap-flagged block shared by a parent and a subclass table would be freed once per + * table), so the block is released by z-engine itself: the next swap of the entry frees it. + * The engine, however, copies the entry structure - cache pointer included - wherever it + * needs a zend_function of its own: zend_create_closure_ex shares a scope-matching non-heap + * cache with every fake closure made over the entry (Closure::fromCallable(), a first-class + * callable, ReflectionMethod::getClosure()), and do_inheritance_check_on_method copies the + * inherited entry into the method table of a subclass implementing an interface that declares + * the method. Before the release found those copies, they kept reading the freed block (a + * use-after-free that only valgrind, or a reallocation of the block, reveals), so every + * scenario here pins both the dispatch results and the engine state the release establishes: + * a fake closure owns a private heap copy of its warmed slots, a clone a minted cache of its + * own, and a block a running frame reads stays allocated. + * + * Every scenario owns its stub classes (tests/Stub/Alias/CacheFake*): a redefinition changes + * the class for the rest of the process. Subclasses are autoloaded only AFTER the first + * redefinition, the order the engine supports for opcache-shared classes; with opcache + * active in the runner the first swap copies each target out of shared memory. + */ +class RedefineRunTimeCacheTest extends TestCase +{ + /** + * Tolerance (bytes) for the memory_get_usage() growth the plateau child measures over a + * hundred swap cycles: the fixed path is exactly flat, and a block leaked per swap (24 to + * 32 bytes for the donor bodies of the child) would cost at least five times this + */ + private const int FLATNESS_TOLERANCE_BYTES = 512; + + /** + * State of the mid-flight scenario, reached by the swapped-in body through this class + * (see testFakeClosureRedefiningItsOwnMethodMidFlightKeepsItsCacheBlock) + */ + public static ?ReflectionMethod $liveFrameEntry = null; + + public static ?\Closure $liveFrameNext = null; + + public static bool $liveFrameSwapped = false; + + public function testFakeClosuresOverARedefinedMethodSurviveTheNextSwap(): void + { + $method = (new ReflectionClass(CacheFakeClosureTarget::class))->getMethod('greet'); + $v1 = fn(string $who): string => 'v1:' . strtoupper($who) . str_repeat('!', 2); + $v2 = fn(string $who): string => 'v2:' . strtolower($who) . str_repeat('?', 2); + + $method->redefine($v1); + $firstCache = self::cacheAddress($method); + + $instance = new CacheFakeClosureTarget(); + $fakes = [ + 'first-class callable' => $instance->greet(...), + 'Closure::fromCallable' => \Closure::fromCallable([$instance, 'greet']), + 'ReflectionMethod::getClosure' => (new \ReflectionMethod(CacheFakeClosureTarget::class, 'greet'))->getClosure($instance), + ]; + foreach ($fakes as $kind => $fake) { + // zend_create_closure_ex copies the entry and shares its scope-matching non-heap cache + $fakeFunction = self::closureFunction($fake); + self::assertSame($firstCache, self::cacheAddress($fakeFunction), "{$kind} shares the minted cache of the entry"); + self::assertFalse(self::hasHeapCache($fakeFunction), "{$kind} does not own the shared block"); + self::assertSame('v1:A!!', $fake('a'), "{$kind} warms the shared block"); + } + + // The second swap retires the shared block: every fake closure gets a private heap copy first + $method->redefine($v2); + $secondCache = self::cacheAddress($method); + self::assertNotSame($firstCache, $secondCache); + self::assertSame('v2:b??', $instance->greet('B'), 'the entry dispatches the new body'); + + $privateCopies = []; + foreach ($fakes as $kind => $fake) { + $fakeFunction = self::closureFunction($fake); + self::assertTrue(self::hasHeapCache($fakeFunction), "{$kind} now owns a heap cache the engine frees with it"); + $fakeCache = self::cacheAddress($fakeFunction); + self::assertNotSame($firstCache, $fakeCache, "{$kind} no longer points at the released block"); + self::assertNotSame($secondCache, $fakeCache, "{$kind} does not share the current cache of the entry"); + self::assertSame('v1:C!!', $fake('c'), "{$kind} keeps executing the previous body on its own cache"); + self::assertSame($fakeCache, self::cacheAddress(self::closureFunction($fake)), "{$kind} keeps its private copy across calls"); + $privateCopies[$kind] = $fakeCache; + } + + // A third swap retires a block no closure points at: the private copies are left alone + $method->redefine($v1); + self::assertSame('v1:D!!', $instance->greet('d')); + foreach ($fakes as $kind => $fake) { + $fakeFunction = self::closureFunction($fake); + self::assertSame($privateCopies[$kind], self::cacheAddress($fakeFunction), "{$kind} keeps its single private copy across later swaps"); + self::assertTrue(self::hasHeapCache($fakeFunction)); + self::assertSame('v1:E!!', $fake('e')); + } + } + + public function testFakeClosuresOverAMethodDonorBodyOutliveTheRestoringSwap(): void + { + $method = (new ReflectionClass(CacheFakeMethodTarget::class))->getMethod('greet'); + $method->redefine((new ReflectionClass(CacheFakeMethodDonor::class))->getMethod('greet'), preserveAs: 'greetOriginal'); + $firstCache = self::cacheAddress($method); + + $instance = new CacheFakeMethodTarget('t'); + $fakes = [ + 'first-class callable' => $instance->greet(...), + 'Closure::fromCallable' => \Closure::fromCallable([$instance, 'greet']), + 'ReflectionMethod::getClosure' => (new \ReflectionMethod(CacheFakeMethodTarget::class, 'greet'))->getClosure($instance), + ]; + foreach ($fakes as $kind => $fake) { + self::assertSame($firstCache, self::cacheAddress(self::closureFunction($fake)), "{$kind} shares the minted cache of the entry"); + self::assertSame('donor:A from t..', $fake('a'), "{$kind} warms the shared block"); + } + + // Swap the preserved body back in: the alias is the donor now, the donor-body cache is retired + $method->redefine((new ReflectionClass(CacheFakeMethodTarget::class))->getMethod('greetOriginal')); + $secondCache = self::cacheAddress($method); + self::assertNotSame($firstCache, $secondCache); + self::assertSame('target:b from t', $instance->greet('b'), 'the entry dispatches the restored body'); + + foreach ($fakes as $kind => $fake) { + $fakeFunction = self::closureFunction($fake); + self::assertTrue(self::hasHeapCache($fakeFunction), "{$kind} now owns a heap cache"); + $fakeCache = self::cacheAddress($fakeFunction); + self::assertNotSame($firstCache, $fakeCache, "{$kind} no longer points at the released block"); + self::assertNotSame($secondCache, $fakeCache, "{$kind} does not share the current cache of the entry"); + self::assertSame('donor:C from t..', $fake('c'), "{$kind} keeps executing the donor body"); + } + } + + public function testFakeClosureOverARedefinedGlobalFunction(): void + { + require_once __DIR__ . '/../Stub/Alias/CacheFake-functions.php'; + self::assertTrue(function_exists('zengine_cachefake_fn')); + + // The block is minted by a closure-donor swap: a donor declared in an opcache-cached + // file carries ZEND_ACC_IMMUTABLE, a body-level flag the entry adopts, and a global + // function flagged so is copied out again by its next swap instead of swapped in place + $function = new ReflectionFunction('zengine_cachefake_fn'); + $function->redefine(fn(string $who): string => 'v1:' . strtoupper($who) . str_repeat('!', 2)); + $firstCache = self::cacheAddress($function); + + // A plain function has no scope: the fake closure shares the minted cache like a method's + $fake = \Closure::fromCallable('zengine_cachefake_fn'); + $fakeFunction = self::closureFunction($fake); + self::assertSame($firstCache, self::cacheAddress($fakeFunction)); + self::assertFalse(self::hasHeapCache($fakeFunction)); + self::assertSame('v1:A!!', $fake('a')); + + // The function donor retires the block + $function->redefine(new ReflectionFunction('zengine_cachefake_fn_donor')); + $secondCache = self::cacheAddress($function); + self::assertNotSame($firstCache, $secondCache); + self::assertSame('donor:b??', \zengine_cachefake_fn('B'), 'the function dispatches the donor body'); + + $fakeFunction = self::closureFunction($fake); + self::assertTrue(self::hasHeapCache($fakeFunction), 'the fake closure owns a heap cache now'); + $fakeCache = self::cacheAddress($fakeFunction); + self::assertNotSame($firstCache, $fakeCache, 'the released block is not referenced anymore'); + self::assertNotSame($secondCache, $fakeCache); + self::assertSame('v1:C!!', $fake('c'), 'the fake closure keeps executing the previous body'); + } + + public function testEngineCloneOfASubclassLinkedAfterTheSwapGetsItsOwnCache(): void + { + $entry = (new ReflectionClass(CacheFakeCloneBase::class))->getMethod('greet'); + $v1 = fn(string $who): string => 'v1:' . strtoupper($who) . str_repeat('!', 2); + $v2 = fn(string $who): string => 'v2:' . strtolower($who) . str_repeat('?', 2); + + $entry->redefine($v1); + $firstCache = self::cacheAddress($entry); + + // Autoloaded now, i.e. linked against the redefined base: the interface prototype makes + // the engine separate the inherited bucket into a struct copy of the entry, cache pointer + // included (do_inheritance_check_on_method) - a clone, not a share of the entry pointer + $child = new CacheFakeCloneChild(); + self::assertSame( + [CacheFakeCloneBase::class], + array_map(static fn(ReflectionClass $holder): string => $holder->getName(), FunctionBodySwap::publishedShareHolders($entry)), + 'the subclass table does not hold the entry pointer', + ); + $clones = FunctionBodySwap::cloneHolders($entry); + self::assertCount(1, $clones); + $cloneAddress = null; + foreach ($clones as [$holder, $clone]) { + self::assertSame(CacheFakeCloneChild::class, $holder->getName()); + self::assertNotSame($entry->getAddress(), $clone->getAddress(), 'the subclass holds a struct copy of the entry'); + self::assertSame($firstCache, self::cacheAddress($clone), 'the copy carries the minted cache pointer of the entry'); + self::assertFalse(self::hasHeapCache($clone)); + $cloneAddress = $clone->getAddress(); + } + self::assertSame('v1:A!!', $child->greet('a'), 'the clone dispatches for the subclass and warms the shared block'); + + // The second swap of the entry retires the shared block: the clone follows the swap and + // gets a minted cache of its own before the block is freed + $entry->redefine($v2); + $secondCache = self::cacheAddress($entry); + self::assertNotSame($firstCache, $secondCache); + self::assertSame('v2:b??', (new CacheFakeCloneBase())->greet('B')); + self::assertSame('v2:b??', $child->greet('B'), 'the subclass dispatches the new body through its clone'); + + $clones = FunctionBodySwap::cloneHolders($entry); + self::assertCount(1, $clones); + foreach ($clones as [$holder, $clone]) { + self::assertSame(CacheFakeCloneChild::class, $holder->getName()); + self::assertSame($cloneAddress, $clone->getAddress(), 'the clone was swapped in place'); + $cloneCache = self::cacheAddress($clone); + self::assertNotSame($firstCache, $cloneCache, 'the clone no longer points at the released block'); + self::assertNotSame($secondCache, $cloneCache, 'the clone does not share the current cache of the entry'); + self::assertFalse(self::hasHeapCache($clone), 'a minted cache, released by the next swap of the clone'); + } + } + + public function testFakeClosureRedefiningItsOwnMethodMidFlightKeepsItsCacheBlock(): void + { + $entry = (new ReflectionClass(CacheFakeLiveFrameTarget::class))->getMethod('greet'); + // The state the swapped-in body needs lives in static properties of this test class: on + // PHP 8.5 a closure donor's bound variables (its live statics) stay with the closure + // object, so the entry adopting the body would read them unbound - and the target class + // itself is copied out of shared memory by the swap under an opcache-active runner + self::$liveFrameEntry = $entry; + self::$liveFrameNext = fn(string $who): string => 'v2:' . strtolower($who); + self::$liveFrameSwapped = false; + $v1 = function (string $who): string { + $head = 'v1:' . strtoupper($who); + if (!RedefineRunTimeCacheTest::$liveFrameSwapped) { + RedefineRunTimeCacheTest::$liveFrameSwapped = true; + // The frame of the fake closure below runs on the minted block being retired + RedefineRunTimeCacheTest::$liveFrameEntry?->redefine(RedefineRunTimeCacheTest::$liveFrameNext ?? static fn(string $who): string => $who); + } + + // Inline-cache reads after the swap: the block this frame runs on must still be there + return $head . str_repeat('.', 2) . strtolower($who); + }; + + $entry->redefine($v1); + $firstCache = self::cacheAddress($entry); + $instance = new CacheFakeLiveFrameTarget(); + $fake = $instance->greet(...); + self::assertSame($firstCache, self::cacheAddress(self::closureFunction($fake)), 'the fake closure shares the minted cache'); + + // hasLiveFrame() cannot see this frame (it executes &closure->func, not the entry): + // the block is kept because a frame on the stack reads its inline caches from it + self::assertSame('v1:A..a', $fake('a'), 'the frame completes on the previous body'); + self::assertTrue(self::$liveFrameSwapped); + self::assertSame('v2:b', $instance->greet('B'), 'the entry dispatches the new body once the frame returned'); + self::assertNotSame($firstCache, self::cacheAddress($entry)); + + $fakeFunction = self::closureFunction($fake); + self::assertSame($firstCache, self::cacheAddress($fakeFunction), 'the block a live frame ran on is kept allocated, not copied'); + self::assertFalse(self::hasHeapCache($fakeFunction)); + self::assertSame('v1:C..c', $fake('c'), 'the kept block still serves the fake closure'); + } + + public function testRetiredCachesAreStillReleasedWhileAFakeClosureIsAlive(): void + { + // The swap loop runs in a child (scenarios/cachefake-fake-closure-plateau.php): every + // swap walks the class table for the holders of the entry, which costs ten times more + // in a PHPUnit process than in a bare one. The child follows the runner's opcache mode, + // so with opcache active the stub and the donor bodies are shared-memory ones there too + $isOpcacheActive = function_exists('opcache_get_status') && opcache_get_status(false) !== false; + $command = [ + PHP_BINARY, + '-d', 'ffi.enable=1', + '-d', 'zend.assertions=1', + '-d', 'assert.exception=1', + '-d', 'display_errors=on', + '-d', 'error_reporting=-1', + '-d', 'opcache.enable=' . ($isOpcacheActive ? '1' : '0'), + '-d', 'opcache.enable_cli=' . ($isOpcacheActive ? '1' : '0'), + // The JIT rewrites the executor internals z-engine hooks into (AGENTS.md) + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + // Freshly checked-out stubs must still be served from shared memory + '-d', 'opcache.file_update_protection=0', + __DIR__ . '/scenarios/cachefake-fake-closure-plateau.php', + ]; + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process, 'Unable to spawn the plateau 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}"; + // A use-after-free surfaces here as a signal exit code, not as a metric + self::assertSame(0, $exitCode, "Plateau child exited with code {$exitCode}\n{$report}"); + + $stdoutLines = array_values(array_filter(array_map('trim', explode("\n", $stdout)))); + $lastLine = $stdoutLines === [] ? '' : end($stdoutLines); + $metrics = json_decode($lastLine, true); + self::assertIsArray($metrics, "Plateau child produced no metrics\n{$report}"); + self::assertIsInt($metrics['cycles']); + self::assertIsInt($metrics['cacheSize']); + self::assertIsInt($metrics['growth']); + self::assertGreaterThanOrEqual(100, $metrics['cycles']); + self::assertGreaterThan(0, $metrics['cacheSize']); + + // The engine shape the loop starts from: the closure shares the block minted by the swap + self::assertTrue($metrics['sharedBeforeSwap'], "the fake closure shares the minted cache of the entry\n{$report}"); + self::assertSame('v1:AB!!baAbaba', $metrics['warmed'], $report); + + // The first swap after the closure was made gives it a private heap copy; no later swap + // touches that copy, and every retired block is freed: a leaked block per swap would cost + // cycles * cacheSize bytes, at least five times the tolerance + self::assertTrue($metrics['heapAfterFirst'], "the fake closure owns a heap cache after the first swap\n{$report}"); + self::assertTrue($metrics['privateCopyIsOwn'], "the private copy is neither the released block nor the entry's cache\n{$report}"); + self::assertTrue($metrics['privateCopyKept'], "later swaps leave the private copy of the fake closure alone\n{$report}"); + self::assertTrue($metrics['heapAfterCycles'], $report); + self::assertLessThan( + self::FLATNESS_TOLERANCE_BYTES, + $metrics['growth'], + "{$metrics['cycles']} swaps grew the heap by {$metrics['growth']} bytes (a leaked block per swap would cost " + . ($metrics['cycles'] * $metrics['cacheSize']) . ")\n{$report}", + ); + self::assertSame('v1:CD!!dcCdcdc', $metrics['entryResult'], "the entry dispatches the last donor body\n{$report}"); + self::assertSame('v1:EF!!feEfefe', $metrics['fakeResult'], "the fake closure keeps executing its body\n{$report}"); + } + + public function testFakeClosureOverThePreservedAliasSurvivesARedefinitionOfTheAlias(): void + { + $class = new ReflectionClass(CacheFakeAliasTarget::class); + $class->getMethod('greet')->redefine((new ReflectionClass(CacheFakeAliasDonor::class))->getMethod('greet'), preserveAs: 'greetOriginal'); + + // The alias container got a minted cache of its own when it was published + $alias = (new ReflectionClass(CacheFakeAliasTarget::class))->getMethod('greetOriginal'); + $aliasCache = self::cacheAddress($alias); + self::assertFalse(self::hasHeapCache($alias)); + + $instance = new CacheFakeAliasTarget('t'); + $fake = (new \ReflectionMethod(CacheFakeAliasTarget::class, 'greetOriginal'))->getClosure($instance); + $fakeFunction = self::closureFunction($fake); + self::assertSame($aliasCache, self::cacheAddress($fakeFunction), 'a fake closure over a published container shares its minted cache too'); + self::assertSame('target:A from t', $fake('a')); + + // Redefining the alias retires the container's minted cache through the same release + $alias->redefine(fn(string $who): string => 'alias-v2:' . strtolower($who)); + self::assertNotSame($aliasCache, self::cacheAddress($alias)); + self::assertSame('alias-v2:b', (new \ReflectionMethod(CacheFakeAliasTarget::class, 'greetOriginal'))->invoke($instance, 'B')); + + $fakeFunction = self::closureFunction($fake); + self::assertTrue(self::hasHeapCache($fakeFunction)); + self::assertNotSame($aliasCache, self::cacheAddress($fakeFunction)); + self::assertSame('target:C from t', $fake('c'), 'the fake closure keeps executing the preserved body'); + self::assertSame('donor:d from t', $instance->greet('d'), 'the entry itself is untouched by the alias swap'); + } + + /** + * Pointer-level view of the zend_function embedded in a closure object (borrowed: the + * closure must stay alive while the view is used) + */ + private static function closureFunction(\Closure $closure): ReflectionFunction + { + $closureEntry = new ClosureEntry($closure); + + return ReflectionFunction::fromCData(Core::addr($closureEntry->getRawFunction())); + } + + /** + * Address of the run-time cache block a user function reads its inline caches from + */ + private static function cacheAddress(FunctionLikeInterface $function): int + { + $cache = $function->getOpArrayPointer()->run_time_cache__ptr; + self::assertNotNull($cache, 'a swapped entry is never left without a run-time cache'); + + return Core::addressOf($cache); + } + + /** + * Whether the function owns its run-time cache (ZEND_ACC_HEAP_RT_CACHE: destroy_op_array frees it) + */ + private static function hasHeapCache(FunctionLikeInterface $function): bool + { + return ($function->getCommonPointer()->fn_flags & Core::ZEND_ACC_HEAP_RT_CACHE) !== 0; + } +} diff --git a/tests/Reflection/scenarios/cachefake-fake-closure-plateau.php b/tests/Reflection/scenarios/cachefake-fake-closure-plateau.php new file mode 100644 index 00000000..5f005ebf --- /dev/null +++ b/tests/Reflection/scenarios/cachefake-fake-closure-plateau.php @@ -0,0 +1,106 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +use ZEngine\Core; +use ZEngine\Reflection\FunctionLikeInterface; +use ZEngine\Reflection\ReflectionClass; +use ZEngine\Reflection\ReflectionFunction; +use ZEngine\Stub\Alias\CacheFakePlateauTarget; +use ZEngine\Type\ClosureEntry; + +require __DIR__ . '/../../../vendor/autoload.php'; + +Core::init(); + +// Plateau child of RedefineRunTimeCacheTest::testRetiredCachesAreStillReleasedWhileAFakeClosureIsAlive: +// a method is swapped a hundred times while a fake closure made over it stays alive. The first +// swap after the closure was made gives it a private heap copy of the shared block; every later +// swap must find no copy pointing at the block it retires and free it, so the cycle stays +// memory-flat and the closure keeps its one copy. The loop runs in this child because every +// swap walks the class table for the holders of the entry: with the few hundred classes of a +// bare process a cycle costs a few tens of milliseconds, with the thousands of a PHPUnit +// process ten times that. + +/** Address of the run-time cache block a user function reads its inline caches from */ +function cachefake_cache_address(FunctionLikeInterface $function): int +{ + $cache = $function->getOpArrayPointer()->run_time_cache__ptr; + if ($cache === null) { + fwrite(STDERR, "a swapped entry was left without a run-time cache\n"); + exit(1); + } + + return Core::addressOf($cache); +} + +/** Whether the function owns its run-time cache (ZEND_ACC_HEAP_RT_CACHE) */ +function cachefake_has_heap_cache(FunctionLikeInterface $function): bool +{ + return ($function->getCommonPointer()->fn_flags & Core::ZEND_ACC_HEAP_RT_CACHE) !== 0; +} + +/** Pointer-level view of the zend_function embedded in a closure object (borrowed) */ +function cachefake_closure_function(\Closure $closure): ReflectionFunction +{ + $closureEntry = new ClosureEntry($closure); + + return ReflectionFunction::fromCData(Core::addr($closureEntry->getRawFunction())); +} + +$cycles = 100; +$method = (new ReflectionClass(CacheFakePlateauTarget::class))->getMethod('greet'); +// Six call sites per body: a 32-byte block per swap (24 under opcache, whose optimizer folds +// the constant call), a few KiB over the measured cycles +$v1 = fn(string $who): string => 'v1:' . strtoupper($who) . str_repeat('!', 2) . strrev($who) . ucfirst($who) . trim($who) . substr($who, 0, 1); +$v2 = fn(string $who): string => 'v2:' . strtolower($who) . str_repeat('?', 2) . strrev($who) . ucfirst($who) . trim($who) . substr($who, 0, 1); + +$method->redefine($v1); +$cacheSize = $method->getOpArrayPointer()->cache_size; +$firstCache = cachefake_cache_address($method); +// The instance is created after the first swap: under opcache that swap copies the class out +// of shared memory, and an instance created before would keep the shared class entry +$instance = new CacheFakePlateauTarget(); +$fake = $instance->greet(...); +$shared = cachefake_cache_address(cachefake_closure_function($fake)) === $firstCache; +$warmed = $fake('ab'); + +// The next swap gives the closure its private copy; a few more settle the allocator +for ($i = 0; $i < 10; $i++) { + $method->redefine($i % 2 === 0 ? $v2 : $v1); +} +$fakeFunction = cachefake_closure_function($fake); +$privateCopy = cachefake_cache_address($fakeFunction); +$heapAfterFirst = cachefake_has_heap_cache($fakeFunction); + +gc_collect_cycles(); +$before = memory_get_usage(); +for ($i = 0; $i < $cycles; $i++) { + $method->redefine($i % 2 === 0 ? $v2 : $v1); +} +gc_collect_cycles(); +$growth = memory_get_usage() - $before; + +$fakeFunction = cachefake_closure_function($fake); +echo json_encode([ + 'cycles' => $cycles, + 'cacheSize' => $cacheSize, + 'growth' => $growth, + 'sharedBeforeSwap' => $shared, + 'warmed' => $warmed, + 'heapAfterFirst' => $heapAfterFirst, + 'heapAfterCycles' => cachefake_has_heap_cache($fakeFunction), + 'privateCopyKept' => cachefake_cache_address($fakeFunction) === $privateCopy, + 'privateCopyIsOwn' => $privateCopy !== $firstCache && $privateCopy !== cachefake_cache_address($method), + 'entryResult' => $instance->greet('cd'), + 'fakeResult' => $fake('ef'), +]), PHP_EOL; diff --git a/tests/Stub/Alias/CacheFake-functions.php b/tests/Stub/Alias/CacheFake-functions.php new file mode 100644 index 00000000..a2e061d4 --- /dev/null +++ b/tests/Stub/Alias/CacheFake-functions.php @@ -0,0 +1,29 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +/** + * Global function target of RedefineRunTimeCacheTest::testFakeClosureOverARedefinedGlobalFunction + * (required once by the test): redefined twice while a Closure::fromCallable() over it stays alive + */ +function zengine_cachefake_fn(string $who): string +{ + return 'fn:' . $who; +} + +/** + * Function donor for zengine_cachefake_fn(): the swap adopting it retires the minted cache + */ +function zengine_cachefake_fn_donor(string $who): string +{ + return 'donor:' . strtolower($who) . str_repeat('?', 2); +} diff --git a/tests/Stub/Alias/CacheFakeAliasDonor.php b/tests/Stub/Alias/CacheFakeAliasDonor.php new file mode 100644 index 00000000..7cc08d76 --- /dev/null +++ b/tests/Stub/Alias/CacheFakeAliasDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Method donor for CacheFakeAliasTarget::greet() + */ +class CacheFakeAliasDonor +{ + public string $name = 'donor'; + + public function greet(string $who): string + { + return 'donor:' . $who . ' from ' . $this->name; + } +} diff --git a/tests/Stub/Alias/CacheFakeAliasTarget.php b/tests/Stub/Alias/CacheFakeAliasTarget.php new file mode 100644 index 00000000..8f51c662 --- /dev/null +++ b/tests/Stub/Alias/CacheFakeAliasTarget.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Target of RedefineRunTimeCacheTest::testFakeClosureOverThePreservedAliasSurvivesARedefinitionOfTheAlias: + * the previous greet() body is preserved under an alias that is then redefined itself + */ +class CacheFakeAliasTarget +{ + public function __construct(public string $name = 'target') {} + + public function greet(string $who): string + { + return 'target:' . strtoupper($who) . ' from ' . $this->name; + } +} diff --git a/tests/Stub/Alias/CacheFakeCloneBase.php b/tests/Stub/Alias/CacheFakeCloneBase.php new file mode 100644 index 00000000..7af2308a --- /dev/null +++ b/tests/Stub/Alias/CacheFakeCloneBase.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Target of RedefineRunTimeCacheTest::testEngineCloneOfASubclassLinkedAfterTheSwapGetsItsOwnCache: + * greet() is redefined twice, with CacheFakeCloneChild linked in between + */ +class CacheFakeCloneBase +{ + public function greet(string $who): string + { + return 'base:' . $who; + } +} diff --git a/tests/Stub/Alias/CacheFakeCloneChild.php b/tests/Stub/Alias/CacheFakeCloneChild.php new file mode 100644 index 00000000..dc0c80b5 --- /dev/null +++ b/tests/Stub/Alias/CacheFakeCloneChild.php @@ -0,0 +1,21 @@ + + * + * 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\Stub\Alias; + +/** + * Autoloaded AFTER the first redefinition of CacheFakeCloneBase::greet(): the interface + * forces an engine clone of the inherited entry into this method table + * (owned by RedefineRunTimeCacheTest::testEngineCloneOfASubclassLinkedAfterTheSwapGetsItsOwnCache) + */ +class CacheFakeCloneChild extends CacheFakeCloneBase implements CacheFakeCloneContract {} diff --git a/tests/Stub/Alias/CacheFakeCloneContract.php b/tests/Stub/Alias/CacheFakeCloneContract.php new file mode 100644 index 00000000..ed63919a --- /dev/null +++ b/tests/Stub/Alias/CacheFakeCloneContract.php @@ -0,0 +1,24 @@ + + * + * 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\Stub\Alias; + +/** + * Declares the method CacheFakeCloneChild inherits: an interface prototype makes the engine + * separate the inherited bucket into a struct copy of the parent entry at link time + * (owned by RedefineRunTimeCacheTest::testEngineCloneOfASubclassLinkedAfterTheSwapGetsItsOwnCache) + */ +interface CacheFakeCloneContract +{ + public function greet(string $who): string; +} diff --git a/tests/Stub/Alias/CacheFakeClosureTarget.php b/tests/Stub/Alias/CacheFakeClosureTarget.php new file mode 100644 index 00000000..b59d4280 --- /dev/null +++ b/tests/Stub/Alias/CacheFakeClosureTarget.php @@ -0,0 +1,29 @@ + + * + * 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\Stub\Alias; + +/** + * Target of RedefineRunTimeCacheTest::testFakeClosuresOverARedefinedMethodSurviveTheNextSwap: + * redefined twice from closure donors while three kinds of fake closures made over greet() + * stay alive + */ +class CacheFakeClosureTarget +{ + public function __construct(public string $name = 'target') {} + + public function greet(string $who): string + { + return 'target:' . $who . ' from ' . $this->name; + } +} diff --git a/tests/Stub/Alias/CacheFakeLiveFrameTarget.php b/tests/Stub/Alias/CacheFakeLiveFrameTarget.php new file mode 100644 index 00000000..e048881d --- /dev/null +++ b/tests/Stub/Alias/CacheFakeLiveFrameTarget.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Target of RedefineRunTimeCacheTest::testFakeClosureRedefiningItsOwnMethodMidFlightKeepsItsCacheBlock: + * greet() is redefined again from inside a fake closure that is executing it + */ +class CacheFakeLiveFrameTarget +{ + public function greet(string $who): string + { + return 'target:' . $who; + } +} diff --git a/tests/Stub/Alias/CacheFakeMethodDonor.php b/tests/Stub/Alias/CacheFakeMethodDonor.php new file mode 100644 index 00000000..094dfd15 --- /dev/null +++ b/tests/Stub/Alias/CacheFakeMethodDonor.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Method donor for CacheFakeMethodTarget::greet(): function calls and a property read, so the + * body owns several inline-cache slots in the run-time cache minted for the entry + */ +class CacheFakeMethodDonor +{ + public string $name = 'donor'; + + public function greet(string $who): string + { + return 'donor:' . strtoupper($who) . ' from ' . $this->name . str_repeat('.', 2); + } +} diff --git a/tests/Stub/Alias/CacheFakeMethodTarget.php b/tests/Stub/Alias/CacheFakeMethodTarget.php new file mode 100644 index 00000000..f8625797 --- /dev/null +++ b/tests/Stub/Alias/CacheFakeMethodTarget.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Target of RedefineRunTimeCacheTest::testFakeClosuresOverAMethodDonorBodyOutliveTheRestoringSwap: + * greet() adopts the CacheFakeMethodDonor body under a preserved alias and is swapped back from it + */ +class CacheFakeMethodTarget +{ + public function __construct(public string $name = 'target') {} + + public function greet(string $who): string + { + return 'target:' . $who . ' from ' . $this->name; + } +} diff --git a/tests/Stub/Alias/CacheFakePlateauTarget.php b/tests/Stub/Alias/CacheFakePlateauTarget.php new file mode 100644 index 00000000..2d3fbc20 --- /dev/null +++ b/tests/Stub/Alias/CacheFakePlateauTarget.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Target of RedefineRunTimeCacheTest::testRetiredCachesAreStillReleasedWhileAFakeClosureIsAlive: + * greet() is redefined a few hundred times while a fake closure over it stays alive + */ +class CacheFakePlateauTarget +{ + public function greet(string $who): string + { + return 'target:' . $who; + } +} From 75ca0c4e0aea57d6278716fe143768f3a2dc7446 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 05:16:44 +0000 Subject: [PATCH 08/14] test(reflection): pin the preserved alias in the tables of every subclass An alias reaches the subclasses linked before that override the redefined method (the preserved body runs on their instances through parent::), a subclass declaring a private method of the alias name keeps it and gets ZEND_ACC_CHANGED so the base scope still resolves the base alias, the shared-memory guard refuses both shapes when the base is immutable, and subclasses linked after inherit the alias the engine's way - with one body share per publishing table. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .../RedefineAliasInheritanceTest.php | 253 ++++++++++++++++++ tests/Stub/Alias/OverridingBase.php | 26 ++ tests/Stub/Alias/OverridingBase2.php | 27 ++ tests/Stub/Alias/OverridingDonor.php | 27 ++ tests/Stub/Alias/OverridingDonor2.php | 27 ++ tests/Stub/Alias/OverridingLateBase.php | 26 ++ tests/Stub/Alias/OverridingLateBase2.php | 26 ++ tests/Stub/Alias/OverridingLateDonor.php | 27 ++ tests/Stub/Alias/OverridingLateDonor2.php | 27 ++ tests/Stub/Alias/OverridingLateLeaf.php | 19 ++ tests/Stub/Alias/OverridingLateMid.php | 35 +++ tests/Stub/Alias/OverridingLateSub.php | 26 ++ tests/Stub/Alias/OverridingLeaf.php | 20 ++ tests/Stub/Alias/OverridingLeaf2.php | 20 ++ tests/Stub/Alias/OverridingMid.php | 35 +++ tests/Stub/Alias/OverridingSub.php | 26 ++ 16 files changed, 647 insertions(+) create mode 100644 tests/Reflection/RedefineAliasInheritanceTest.php create mode 100644 tests/Stub/Alias/OverridingBase.php create mode 100644 tests/Stub/Alias/OverridingBase2.php create mode 100644 tests/Stub/Alias/OverridingDonor.php create mode 100644 tests/Stub/Alias/OverridingDonor2.php create mode 100644 tests/Stub/Alias/OverridingLateBase.php create mode 100644 tests/Stub/Alias/OverridingLateBase2.php create mode 100644 tests/Stub/Alias/OverridingLateDonor.php create mode 100644 tests/Stub/Alias/OverridingLateDonor2.php create mode 100644 tests/Stub/Alias/OverridingLateLeaf.php create mode 100644 tests/Stub/Alias/OverridingLateMid.php create mode 100644 tests/Stub/Alias/OverridingLateSub.php create mode 100644 tests/Stub/Alias/OverridingLeaf.php create mode 100644 tests/Stub/Alias/OverridingLeaf2.php create mode 100644 tests/Stub/Alias/OverridingMid.php create mode 100644 tests/Stub/Alias/OverridingSub.php diff --git a/tests/Reflection/RedefineAliasInheritanceTest.php b/tests/Reflection/RedefineAliasInheritanceTest.php new file mode 100644 index 00000000..078548dd --- /dev/null +++ b/tests/Reflection/RedefineAliasInheritanceTest.php @@ -0,0 +1,253 @@ + + * + * 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\Reflection; + +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\OpCache\SharedMemoryException; +use ZEngine\Stub\Alias\OverridingBase; +use ZEngine\Stub\Alias\OverridingBase2; +use ZEngine\Stub\Alias\OverridingDonor; +use ZEngine\Stub\Alias\OverridingDonor2; +use ZEngine\Stub\Alias\OverridingLateBase; +use ZEngine\Stub\Alias\OverridingLateBase2; +use ZEngine\Stub\Alias\OverridingLateDonor; +use ZEngine\Stub\Alias\OverridingLateDonor2; +use ZEngine\Stub\Alias\OverridingLateLeaf; +use ZEngine\Stub\Alias\OverridingLateMid; +use ZEngine\Stub\Alias\OverridingLateSub; +use ZEngine\Stub\Alias\OverridingLeaf; +use ZEngine\Stub\Alias\OverridingLeaf2; +use ZEngine\Stub\Alias\OverridingMid; +use ZEngine\Stub\Alias\OverridingSub; + +/** + * The preserved alias of redefine(preserveAs:) in the method tables of subclasses + * + * A private method of a parent is copied into the method table of every subclass at link + * time (do_inherit_method -> zend_duplicate_function: the same zend_function pointer, one + * body share per table), and a subclass declaring a private method of the same name keeps + * its own entry marked ZEND_ACC_CHANGED, so that the parent's code, running on an instance + * of the subclass, still resolves the PARENT's private method through + * zend_get_parent_private_method(). An alias published after the subclasses were linked + * has to reproduce exactly that: the swapped-in body calls the alias on whatever receiver + * it runs - also on instances of a subclass that OVERRIDES the redefined method and reaches + * the base body through parent::, whose table never held the base entry at all. The alias + * used to be shared only into subclasses whose table pointed at the entry structure, so an + * overriding subclass ended in "Call to undefined method", and a subclass declaring the + * alias name itself in "Call to private method" (no CHANGED bit); the refusal for a + * shared-memory class considered the same too-narrow set of subclasses. + * + * Every scenario owns its stub classes (tests/Stub/Alias/Overriding*): a redefinition + * changes the class for the rest of the process. The "linked before" scenarios are the + * subject here, so under an opcache-active runner (every stub an immutable shared-memory + * class) they pin the refusal instead, which must happen BEFORE the copy-out and leave the + * shared class untouched; the "linked after" scenarios hold in both runner modes. + */ +class RedefineAliasInheritanceTest extends TestCase +{ + public function testAliasReachesOverridingSubclassesLinkedBefore(): void + { + // The whole chain is linked now: Sub overrides m() through parent::, Leaf inherits + // Sub's override - neither table holds the base entry the alias is published for + self::assertSame('sub:base', (new OverridingLeaf())->m()); + self::assertFalse((new \ReflectionClass(OverridingLeaf::class))->hasMethod('mOriginal')); + $class = new ReflectionClass(OverridingBase::class); + $donor = (new ReflectionClass(OverridingDonor::class))->getMethod('m'); + + if ($class->isImmutable()) { + // Refused before the copy-out: the overriding subclass keeps the shared parent + // and its parent:: call would run the swapped body without the alias + try { + $class->getMethod('m')->redefine($donor, preserveAs: 'mOriginal'); + self::fail('alias into a shared class with an overriding subclass linked before not refused'); + } catch (SharedMemoryException $exception) { + self::assertMatchesRegularExpression('/subclass .*Overriding(Sub|Leaf) was linked against the shared entry/', $exception->getMessage()); + } + self::assertTrue((new ReflectionClass(OverridingBase::class))->isImmutable(), 'refused before anything was copied out'); + self::assertFalse((new \ReflectionClass(OverridingBase::class))->hasMethod('mOriginal')); + self::assertSame('sub:base', (new OverridingLeaf())->m(), 'nothing was swapped'); + + return; + } + + $class->getMethod('m')->redefine($donor, preserveAs: 'mOriginal'); + + // The swapped-in body resolves $this->mOriginal() on every receiver it runs on + self::assertSame('[base]', (new OverridingBase())->m()); + self::assertSame('sub:[base]', (new OverridingSub())->m(), 'parent:: from the override runs the new body on a Sub receiver'); + self::assertSame('sub:[base]', (new OverridingLeaf())->m()); + + // The alias is in every subclass table as an inherited private method of the base, + // exactly what the engine copies in at link time + foreach ([OverridingSub::class, OverridingLeaf::class] as $subclass) { + self::assertTrue((new \ReflectionClass($subclass))->hasMethod('mOriginal'), $subclass); + $native = new \ReflectionMethod($subclass, 'mOriginal'); + self::assertSame(OverridingBase::class, $native->getDeclaringClass()->getName(), $subclass); + self::assertTrue($native->isPrivate(), $subclass); + } + self::assertSame(OverridingSub::class, (new \ReflectionMethod(OverridingSub::class, 'm'))->getDeclaringClass()->getName(), 'the override itself is untouched'); + + // One structure, published by three tables (zend_duplicate_function semantics): the + // subclass buckets point at the base's alias container ... + $alias = (new ReflectionClass(OverridingBase::class))->getMethod('mOriginal'); + self::assertSame($alias->getAddress(), (new ReflectionClass(OverridingSub::class))->getMethod('mOriginal')->getAddress()); + self::assertSame($alias->getAddress(), (new ReflectionClass(OverridingLeaf::class))->getMethod('mOriginal')->getAddress()); + self::assertSame( + [OverridingBase::class, OverridingLeaf::class, OverridingSub::class], + self::holderNames($alias), + ); + // ... and each bucket took the body share its destructor releases at shutdown: + // the entry's own share was released by the swap, the alias and the two subclass + // buckets hold one each - fewer would free the preserved body early, more leak it + $refCountPointer = $alias->getOpArrayPointer()->refcount; + self::assertNotNull($refCountPointer, 'a redefined runtime body is refcounted'); + self::assertSame(3, $refCountPointer[0]); + + $flags = $alias->getCommonPointer()->fn_flags; + self::assertSame(Core::ZEND_ACC_PRIVATE, $flags & Core::ZEND_ACC_PRIVATE); + self::assertSame(0, $flags & Core::ZEND_ACC_CHANGED, 'no ancestor declares the alias name: not a redeclaration'); + self::assertSame(0, $flags & Core::ZEND_ACC_HEAP_RT_CACHE, 'a cache shared by three tables is never heap-flagged'); + } + + public function testSubclassDeclaringTheAliasNameKeepsItAndGetsTheChangedFlag(): void + { + // Linked now: Mid declares a private mOriginal() of its own, Leaf2 inherited that + // bucket (the same pointer) at link time + self::assertSame('base2', (new OverridingLeaf2())->m()); + self::assertSame('mid-private', (new OverridingLeaf2())->ownAlias()); + $midMethod = (new ReflectionClass(OverridingMid::class))->getMethod('mOriginal'); + $midAddress = $midMethod->getAddress(); + self::assertSame($midAddress, (new ReflectionClass(OverridingLeaf2::class))->getMethod('mOriginal')->getAddress()); + self::assertSame(0, $midMethod->getCommonPointer()->fn_flags & Core::ZEND_ACC_CHANGED, 'nothing to redeclare yet'); + $class = new ReflectionClass(OverridingBase2::class); + $donor = (new ReflectionClass(OverridingDonor2::class))->getMethod('m'); + + if ($class->isImmutable()) { + // The CHANGED bit cannot be written into Mid's shared-memory entry either + try { + $class->getMethod('m')->redefine($donor, preserveAs: 'mOriginal'); + self::fail('alias into a shared class with a redeclaring subclass linked before not refused'); + } catch (SharedMemoryException $exception) { + self::assertMatchesRegularExpression('/subclass .*Overriding(Mid|Leaf2) was linked against the shared entry/', $exception->getMessage()); + } + self::assertTrue((new ReflectionClass(OverridingBase2::class))->isImmutable(), 'refused before anything was copied out'); + self::assertFalse((new \ReflectionClass(OverridingBase2::class))->hasMethod('mOriginal')); + $midFlags = (new ReflectionClass(OverridingMid::class))->getMethod('mOriginal')->getCommonPointer()->fn_flags; + self::assertSame(0, $midFlags & Core::ZEND_ACC_CHANGED, 'nothing was marked'); + self::assertSame('base2', (new OverridingLeaf2())->m(), 'nothing was swapped'); + + return; + } + + $class->getMethod('m')->redefine($donor, preserveAs: 'mOriginal'); + + // The base body, private alias and all, runs on Mid and Leaf2 receivers: with the + // CHANGED bit on Mid's own method, zend_std_get_method falls back to + // zend_get_parent_private_method() and finds the base's alias instead of refusing + // the call to Mid's private method from the base's scope + self::assertSame('[base2]', (new OverridingBase2())->m()); + self::assertSame('[base2]', (new OverridingMid())->m(), 'the base scope resolves the base alias on a Mid receiver'); + self::assertSame('[base2]', (new OverridingLeaf2())->m()); + self::assertSame('mid-private', (new OverridingMid())->ownAlias(), 'Mid keeps its own private method for its own scope'); + self::assertSame('mid-private', (new OverridingLeaf2())->ownAlias()); + + // Mid's method stayed Mid's own entry (not replaced by the alias), marked as the + // engine marks a redeclared parent-private method, and Leaf2 still dispatches it + $midMethod = (new ReflectionClass(OverridingMid::class))->getMethod('mOriginal'); + self::assertSame($midAddress, $midMethod->getAddress(), 'the own entry was kept in place'); + self::assertSame(OverridingMid::class, (new \ReflectionMethod(OverridingMid::class, 'mOriginal'))->getDeclaringClass()->getName()); + self::assertSame(OverridingMid::class, (new \ReflectionMethod(OverridingLeaf2::class, 'mOriginal'))->getDeclaringClass()->getName()); + self::assertSame(Core::ZEND_ACC_CHANGED, $midMethod->getCommonPointer()->fn_flags & Core::ZEND_ACC_CHANGED, 'redeclaration of the parent-private alias'); + self::assertSame(Core::ZEND_ACC_PRIVATE, $midMethod->getCommonPointer()->fn_flags & Core::ZEND_ACC_PRIVATE); + self::assertSame($midAddress, (new ReflectionClass(OverridingLeaf2::class))->getMethod('mOriginal')->getAddress(), 'Leaf2 kept the inherited Mid bucket'); + + // The base's alias is published in the base table only: the nearer declaration wins + $alias = (new ReflectionClass(OverridingBase2::class))->getMethod('mOriginal'); + self::assertSame([OverridingBase2::class], self::holderNames($alias)); + self::assertSame(OverridingBase2::class, (new \ReflectionMethod(OverridingBase2::class, 'mOriginal'))->getDeclaringClass()->getName()); + self::assertSame(0, $alias->getCommonPointer()->fn_flags & Core::ZEND_ACC_CHANGED, 'no ancestor of the base declares the name'); + } + + public function testSubclassesLinkedAfterInheritTheAlias(): void + { + self::assertFalse(class_exists(OverridingLateSub::class, false), 'the subclasses are not linked yet'); + $class = new ReflectionClass(OverridingLateBase::class); + $class->getMethod('m')->redefine((new ReflectionClass(OverridingLateDonor::class))->getMethod('m'), preserveAs: 'mOriginal'); + self::assertSame('[late-base]', (new OverridingLateBase())->m()); + + // Autoloaded now, i.e. linked against the class (or its writable copy) that already + // publishes the alias: the engine copies the private alias into their tables itself + self::assertSame('late:[late-base]', (new OverridingLateSub())->m()); + self::assertSame('late:[late-base]', (new OverridingLateLeaf())->m()); + + $alias = (new ReflectionClass(OverridingLateBase::class))->getMethod('mOriginal'); + foreach ([OverridingLateSub::class, OverridingLateLeaf::class] as $subclass) { + self::assertTrue((new \ReflectionClass($subclass))->hasMethod('mOriginal'), $subclass); + $native = new \ReflectionMethod($subclass, 'mOriginal'); + self::assertSame(OverridingLateBase::class, $native->getDeclaringClass()->getName(), $subclass); + self::assertTrue($native->isPrivate(), $subclass); + // zend_duplicate_function hands the child table the parent's pointer + self::assertSame($alias->getAddress(), (new ReflectionClass($subclass))->getMethod('mOriginal')->getAddress(), $subclass); + } + self::assertSame( + [OverridingLateBase::class, OverridingLateLeaf::class, OverridingLateSub::class], + self::holderNames($alias), + ); + // The alias took exactly one share for its own bucket, so the link-time shares of + // the subclasses add up to one per publishing table (a shared-memory body, as the + // copied-out class of the opcache runner keeps, is immortal and carries no counter) + $refCountPointer = $alias->getOpArrayPointer()->refcount; + if ($refCountPointer !== null) { + self::assertSame(3, $refCountPointer[0]); + } + } + + public function testSubclassDeclaringTheAliasNameLinkedAfterIsMarkedChangedByTheEngine(): void + { + // The engine reference for the "linked before" redeclaration: a subclass declaring + // a private method of the alias name and linking AFTER the alias exists must see a + // regular parent-private method, which do_inheritance_check_on_method marks CHANGED + self::assertFalse(class_exists(OverridingLateMid::class, false), 'the subclass is not linked yet'); + $class = new ReflectionClass(OverridingLateBase2::class); + $class->getMethod('m')->redefine((new ReflectionClass(OverridingLateDonor2::class))->getMethod('m'), preserveAs: 'mOriginal'); + + self::assertSame('[late-base2]', (new OverridingLateBase2())->m()); + self::assertSame('[late-base2]', (new OverridingLateMid())->m(), 'the base scope resolves the base alias on a Mid receiver'); + self::assertSame('late-mid-private', (new OverridingLateMid())->ownAlias(), 'Mid keeps its own private method for its own scope'); + + $midMethod = (new ReflectionClass(OverridingLateMid::class))->getMethod('mOriginal'); + self::assertSame(OverridingLateMid::class, $midMethod->getDeclaringClass()->getName()); + self::assertSame(Core::ZEND_ACC_CHANGED, $midMethod->getCommonPointer()->fn_flags & Core::ZEND_ACC_CHANGED, 'the engine marked the redeclaration at link time'); + $alias = (new ReflectionClass(OverridingLateBase2::class))->getMethod('mOriginal'); + self::assertSame([OverridingLateBase2::class], self::holderNames($alias), 'the nearer declaration wins in the subclass table'); + self::assertSame(0, $alias->getCommonPointer()->fn_flags & Core::ZEND_ACC_CHANGED); + } + + /** + * Sorted names of the classes whose method tables publish the entry structure + * + * @return list + */ + private static function holderNames(FunctionLikeInterface $entry): array + { + $names = array_map( + static fn(ReflectionClass $holder): string => $holder->getName(), + FunctionBodySwap::publishedShareHolders($entry), + ); + sort($names); + + return $names; + } +} diff --git a/tests/Stub/Alias/OverridingBase.php b/tests/Stub/Alias/OverridingBase.php new file mode 100644 index 00000000..8cc93aed --- /dev/null +++ b/tests/Stub/Alias/OverridingBase.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Base of the overriding-subclass chain (RedefineAliasInheritanceTest::testAliasReachesOverridingSubclassesLinkedBefore): + * m() is redefined with a preserved alias while OverridingSub and OverridingLeaf are already linked + */ +class OverridingBase +{ + public function m(): string + { + return 'base'; + } +} diff --git a/tests/Stub/Alias/OverridingBase2.php b/tests/Stub/Alias/OverridingBase2.php new file mode 100644 index 00000000..919da36b --- /dev/null +++ b/tests/Stub/Alias/OverridingBase2.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Base of the redeclared-alias chain (RedefineAliasInheritanceTest::testSubclassDeclaringTheAliasNameKeepsItAndGetsTheChangedFlag): + * m() is redefined with the alias mOriginal while OverridingMid, which declares a private + * mOriginal() of its own, and OverridingLeaf2 are already linked + */ +class OverridingBase2 +{ + public function m(): string + { + return 'base2'; + } +} diff --git a/tests/Stub/Alias/OverridingDonor.php b/tests/Stub/Alias/OverridingDonor.php new file mode 100644 index 00000000..71828a6c --- /dev/null +++ b/tests/Stub/Alias/OverridingDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for OverridingBase::m(), delegating to the preserved alias + * + * @method string mOriginal() the preserved OverridingBase::m() body + */ +class OverridingDonor +{ + public function m(): string + { + return '[' . $this->mOriginal() . ']'; + } +} diff --git a/tests/Stub/Alias/OverridingDonor2.php b/tests/Stub/Alias/OverridingDonor2.php new file mode 100644 index 00000000..25d5452b --- /dev/null +++ b/tests/Stub/Alias/OverridingDonor2.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for OverridingBase2::m(), delegating to the preserved alias + * + * @method string mOriginal() the preserved OverridingBase2::m() body + */ +class OverridingDonor2 +{ + public function m(): string + { + return '[' . $this->mOriginal() . ']'; + } +} diff --git a/tests/Stub/Alias/OverridingLateBase.php b/tests/Stub/Alias/OverridingLateBase.php new file mode 100644 index 00000000..4894b5a1 --- /dev/null +++ b/tests/Stub/Alias/OverridingLateBase.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Base of the linked-after chain (RedefineAliasInheritanceTest::testSubclassesLinkedAfterInheritTheAlias): + * m() is redefined with a preserved alias BEFORE OverridingLateSub and OverridingLateLeaf are autoloaded + */ +class OverridingLateBase +{ + public function m(): string + { + return 'late-base'; + } +} diff --git a/tests/Stub/Alias/OverridingLateBase2.php b/tests/Stub/Alias/OverridingLateBase2.php new file mode 100644 index 00000000..c8eebfdb --- /dev/null +++ b/tests/Stub/Alias/OverridingLateBase2.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Base of the engine-reference chain (RedefineAliasInheritanceTest::testSubclassDeclaringTheAliasNameLinkedAfterIsMarkedChangedByTheEngine): + * m() is redefined with the alias mOriginal BEFORE OverridingLateMid is autoloaded + */ +class OverridingLateBase2 +{ + public function m(): string + { + return 'late-base2'; + } +} diff --git a/tests/Stub/Alias/OverridingLateDonor.php b/tests/Stub/Alias/OverridingLateDonor.php new file mode 100644 index 00000000..be2901ce --- /dev/null +++ b/tests/Stub/Alias/OverridingLateDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for OverridingLateBase::m(), delegating to the preserved alias + * + * @method string mOriginal() the preserved OverridingLateBase::m() body + */ +class OverridingLateDonor +{ + public function m(): string + { + return '[' . $this->mOriginal() . ']'; + } +} diff --git a/tests/Stub/Alias/OverridingLateDonor2.php b/tests/Stub/Alias/OverridingLateDonor2.php new file mode 100644 index 00000000..b0c34892 --- /dev/null +++ b/tests/Stub/Alias/OverridingLateDonor2.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for OverridingLateBase2::m(), delegating to the preserved alias + * + * @method string mOriginal() the preserved OverridingLateBase2::m() body + */ +class OverridingLateDonor2 +{ + public function m(): string + { + return '[' . $this->mOriginal() . ']'; + } +} diff --git a/tests/Stub/Alias/OverridingLateLeaf.php b/tests/Stub/Alias/OverridingLateLeaf.php new file mode 100644 index 00000000..cfead0ad --- /dev/null +++ b/tests/Stub/Alias/OverridingLateLeaf.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Autoloaded AFTER OverridingLateBase::m() was redefined, through OverridingLateSub + */ +class OverridingLateLeaf extends OverridingLateSub {} diff --git a/tests/Stub/Alias/OverridingLateMid.php b/tests/Stub/Alias/OverridingLateMid.php new file mode 100644 index 00000000..3bf149e2 --- /dev/null +++ b/tests/Stub/Alias/OverridingLateMid.php @@ -0,0 +1,35 @@ + + * + * 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\Stub\Alias; + +/** + * Autoloaded AFTER OverridingLateBase2::m() was redefined; declares a PRIVATE method of the + * alias name, which the engine marks ZEND_ACC_CHANGED at link time - the reference for what + * MethodPublisher does to a subclass linked before + */ +class OverridingLateMid extends OverridingLateBase2 +{ + /** + * Resolves mOriginal() from this class's own scope + */ + public function ownAlias(): string + { + return $this->mOriginal(); + } + + private function mOriginal(): string + { + return 'late-mid-private'; + } +} diff --git a/tests/Stub/Alias/OverridingLateSub.php b/tests/Stub/Alias/OverridingLateSub.php new file mode 100644 index 00000000..d6deace9 --- /dev/null +++ b/tests/Stub/Alias/OverridingLateSub.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Autoloaded AFTER OverridingLateBase::m() was redefined: linked against the class that + * already publishes the alias, overrides m() through parent:: + */ +class OverridingLateSub extends OverridingLateBase +{ + public function m(): string + { + return 'late:' . parent::m(); + } +} diff --git a/tests/Stub/Alias/OverridingLeaf.php b/tests/Stub/Alias/OverridingLeaf.php new file mode 100644 index 00000000..0d895ae1 --- /dev/null +++ b/tests/Stub/Alias/OverridingLeaf.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Linked BEFORE OverridingBase::m() is redefined; inherits the override of OverridingSub, + * so its table never held the base entry at all + */ +class OverridingLeaf extends OverridingSub {} diff --git a/tests/Stub/Alias/OverridingLeaf2.php b/tests/Stub/Alias/OverridingLeaf2.php new file mode 100644 index 00000000..6dab08b8 --- /dev/null +++ b/tests/Stub/Alias/OverridingLeaf2.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Linked BEFORE OverridingBase2::m() is redefined; its table inherited the private + * OverridingMid::mOriginal() bucket at link time + */ +class OverridingLeaf2 extends OverridingMid {} diff --git a/tests/Stub/Alias/OverridingMid.php b/tests/Stub/Alias/OverridingMid.php new file mode 100644 index 00000000..04edcd3e --- /dev/null +++ b/tests/Stub/Alias/OverridingMid.php @@ -0,0 +1,35 @@ + + * + * 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\Stub\Alias; + +/** + * Linked BEFORE OverridingBase2::m() is redefined; declares a PRIVATE method of the alias + * name itself, which must stay its own and become a redeclaration (ZEND_ACC_CHANGED) of the + * alias the base publishes + */ +class OverridingMid extends OverridingBase2 +{ + /** + * Resolves mOriginal() from this class's own scope + */ + public function ownAlias(): string + { + return $this->mOriginal(); + } + + private function mOriginal(): string + { + return 'mid-private'; + } +} diff --git a/tests/Stub/Alias/OverridingSub.php b/tests/Stub/Alias/OverridingSub.php new file mode 100644 index 00000000..3dae6487 --- /dev/null +++ b/tests/Stub/Alias/OverridingSub.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Linked BEFORE OverridingBase::m() is redefined; overrides m() and reaches the base body + * through parent:: - the preserved body then runs on a receiver of this class + */ +class OverridingSub extends OverridingBase +{ + public function m(): string + { + return 'sub:' . parent::m(); + } +} From cae73421ef622264d8c1b1b2a7b57a7a5d060368 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 05:33:22 +0000 Subject: [PATCH 09/14] test(reflection): pin the live statics a preserved alias takes over and removeMethods() releases The alias of a method or function continues on the very live statics table the preserved body had materialized while the new body starts afresh, removing the alias destroys that table and releases the minted run-time cache the bucket destructor cannot reach, publish/call/restore/remove cycles stay within the container baseline, a table a fake closure borrows survives both a swap and the removal, and an alias still published by a subclass table keeps its statics until the last table drops it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .../RedefinePreservedStaticsTest.php | 403 ++++++++++++++++++ tests/Stub/Alias/LiveStatics-functions.php | 25 ++ tests/Stub/Alias/LiveStaticsBorrowedAlias.php | 30 ++ .../Alias/LiveStaticsBorrowedAliasDonor.php | 28 ++ tests/Stub/Alias/LiveStaticsBorrowedEntry.php | 30 ++ tests/Stub/Alias/LiveStaticsChurn.php | 29 ++ tests/Stub/Alias/LiveStaticsChurnDonor.php | 28 ++ tests/Stub/Alias/LiveStaticsCounter.php | 29 ++ tests/Stub/Alias/LiveStaticsCounterDonor.php | 30 ++ tests/Stub/Alias/LiveStaticsHeldBase.php | 30 ++ tests/Stub/Alias/LiveStaticsHeldChild.php | 20 + tests/Stub/Alias/LiveStaticsHeldDonor.php | 28 ++ tests/Stub/Alias/LiveStaticsRemoved.php | 32 ++ tests/Stub/Alias/LiveStaticsRemovedDonor.php | 28 ++ 14 files changed, 770 insertions(+) create mode 100644 tests/Reflection/RedefinePreservedStaticsTest.php create mode 100644 tests/Stub/Alias/LiveStatics-functions.php create mode 100644 tests/Stub/Alias/LiveStaticsBorrowedAlias.php create mode 100644 tests/Stub/Alias/LiveStaticsBorrowedAliasDonor.php create mode 100644 tests/Stub/Alias/LiveStaticsBorrowedEntry.php create mode 100644 tests/Stub/Alias/LiveStaticsChurn.php create mode 100644 tests/Stub/Alias/LiveStaticsChurnDonor.php create mode 100644 tests/Stub/Alias/LiveStaticsCounter.php create mode 100644 tests/Stub/Alias/LiveStaticsCounterDonor.php create mode 100644 tests/Stub/Alias/LiveStaticsHeldBase.php create mode 100644 tests/Stub/Alias/LiveStaticsHeldChild.php create mode 100644 tests/Stub/Alias/LiveStaticsHeldDonor.php create mode 100644 tests/Stub/Alias/LiveStaticsRemoved.php create mode 100644 tests/Stub/Alias/LiveStaticsRemovedDonor.php diff --git a/tests/Reflection/RedefinePreservedStaticsTest.php b/tests/Reflection/RedefinePreservedStaticsTest.php new file mode 100644 index 00000000..88f32d35 --- /dev/null +++ b/tests/Reflection/RedefinePreservedStaticsTest.php @@ -0,0 +1,403 @@ + + * + * 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\Reflection; + +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Generated\HashTable as HashTableStruct; +use ZEngine\OpCache\SharedMemoryException; +use ZEngine\Stub\Alias\LiveStaticsBorrowedAlias; +use ZEngine\Stub\Alias\LiveStaticsBorrowedAliasDonor; +use ZEngine\Stub\Alias\LiveStaticsBorrowedEntry; +use ZEngine\Stub\Alias\LiveStaticsChurn; +use ZEngine\Stub\Alias\LiveStaticsChurnDonor; +use ZEngine\Stub\Alias\LiveStaticsCounter; +use ZEngine\Stub\Alias\LiveStaticsCounterDonor; +use ZEngine\Stub\Alias\LiveStaticsHeldBase; +use ZEngine\Stub\Alias\LiveStaticsHeldChild; +use ZEngine\Stub\Alias\LiveStaticsHeldDonor; +use ZEngine\Stub\Alias\LiveStaticsRemoved; +use ZEngine\Stub\Alias\LiveStaticsRemovedDonor; +use ZEngine\Type\ClosureEntry; +use ZEngine\Type\HashTable; + +/** + * Ownership of the MATERIALIZED live static-variables table of a body preserved under an alias + * + * A function's statics live in two tables on 8.4: op_array.static_variables holds the compiled + * defaults, and the first ZEND_BIND_STATIC duplicates them into the live per-entry table the + * map pointer static_variables_ptr refers to. The engine destroys a live table only through the + * tables that publish its zend_function (the ZEND_HAS_STATIC_IN_METHODS walk over class method + * tables, the function-table walk at shutdown) or through destroy_op_array for the body's last + * holder. Two paths of the alias workflow used to lose that table on 8.4: + * + * - redefine($donor, preserveAs: ...) copied the entry into the alias container and then + * nulled the entry's slot for the new body, leaving the table the previous body had + * accumulated referenced by no zend_function: the alias restarted from the defaults and the + * table leaked, one per preserving swap. The alias now takes the table over + * (FunctionBodySwap::adoptFunctionForPublishing with inheritLiveStatics), which the tests pin + * by pointer identity of getLiveStaticsPointer() and by the continued count; + * - removeMethods() of the alias (the documented "drop the backup" step) deleted the bucket + * whose destructor only decrements the body refcount: the container stayed allocated in + * request memory with its live table, minted run-time cache and minted defaults duplicate + * reachable from nowhere. FunctionBodySwap::retireUnpublishedEntry() releases them now - + * unless another class table still publishes the container or a fake closure still shares + * the table by pointer, which would turn the release into a use-after-free. + * + * Every scenario owns its stub classes (tests/Stub/Alias/LiveStatics*): a redefinition changes + * the class for the rest of the process. With opcache active in the runner the stubs are + * shared-memory classes; a scenario that materializes statics BEFORE its first mutation copies + * the class out of shared memory explicitly first (a no-op otherwise), because statics bound + * on the shared entry stay with it (the copy-out caveats of docs/hot-swap.md). + */ +class RedefinePreservedStaticsTest extends TestCase +{ + /** + * Cycles of the memory-flatness scenario: each one publishes, calls, restores and removes an alias + * + * Every step walks the class table and the object store a few times (share holders, clones, + * fake closures), which costs close to a second per cycle inside the test process: the count + * is kept small, and the growth measured is exact (a fixed number of bytes per cycle). + */ + private const int MEASURED_CYCLES = 20; + + /** + * Allowed memory_get_usage() growth per cycle (bytes) + * + * The alias container itself (a 256-byte zend_function from request memory, see + * MethodPublisher::newContainer()) is the documented per-publication baseline; before the + * fix every cycle left the alias' live statics table behind as well - a 376-byte HashTable + * with its one-element data block - which this budget does not accommodate. + */ + private const int PER_CYCLE_BUDGET_BYTES = 512; + + public function testAliasTakesOverTheLiveStaticsOfTheMethodBodyItPreserves(): void + { + $class = new ReflectionClass(LiveStaticsCounter::class); + $class->copyOutOfSharedMemory(); + $method = $class->getMethod('counter'); + $instance = new LiveStaticsCounter(); + self::assertSame(1, $instance->counter()); + self::assertSame(2, $instance->counter()); + $liveTable = $method->getLiveStaticsPointer(); + self::assertNotNull($liveTable, 'the first call materialized the live statics table of the entry'); + $liveAddress = Core::addressOf($liveTable); + + // The alias publication detaches the table from the entry before the swap nulls the slot + // for the new body: the alias container's slot is the very same HashTable pointer + $method->redefine((new ReflectionClass(LiveStaticsCounterDonor::class))->getMethod('counter'), preserveAs: 'counterOriginal'); + $alias = (new ReflectionClass(LiveStaticsCounter::class))->getMethod('counterOriginal'); + self::assertSame($liveAddress, self::liveTableAddress($alias), 'the alias took over the table the entry materialized'); + self::assertSame(2, self::staticValue($liveTable, 'n'), 'with the state the preserved body accumulated'); + self::assertNull($method->getLiveStaticsPointer(), 'the entry starts afresh: its slot was handed over, not copied'); + + $aliasMethod = new \ReflectionMethod(LiveStaticsCounter::class, 'counterOriginal'); + self::assertSame(3, $aliasMethod->invoke($instance), 'the preserved body continues its count'); + self::assertSame(1001, $instance->counter(), 'the new body binds its own statics from its defaults'); + self::assertSame(1002, $instance->counter()); + self::assertSame(4, $aliasMethod->invoke($instance)); + self::assertNotSame($liveAddress, self::liveTableAddress($method), 'the new body materialized a live table of its own on its first call'); + self::assertSame($liveAddress, self::liveTableAddress($alias), 'the alias keeps its table across calls'); + self::assertSame(4, self::staticValue($liveTable, 'n')); + + // Swapping the preserved body back in adopts the alias as a method donor: the entry binds + // the statics afresh while the alias keeps the state (the previous body's table stays its own) + $method->redefine((new ReflectionClass(LiveStaticsCounter::class))->getMethod('counterOriginal')); + self::assertNull($method->getLiveStaticsPointer(), 'the restored body has no live table yet'); + self::assertSame(1, $instance->counter(), 'the restored body starts with fresh statics'); + self::assertSame(5, $aliasMethod->invoke($instance), 'the alias keeps counting on the table it took over'); + self::assertSame($liveAddress, self::liveTableAddress($alias)); + } + + public function testAliasTakesOverTheLiveStaticsOfTheFunctionBodyItPreserves(): void + { + require_once __DIR__ . '/../Stub/Alias/LiveStatics-functions.php'; + self::assertTrue(function_exists('zengine_live_statics_fn')); + self::assertSame(1, \zengine_live_statics_fn()); + self::assertSame(2, \zengine_live_statics_fn()); + + // Under opcache the entry is still the shared-memory function here: its slot holds the + // map-ptr offset form, which getLiveStaticsPointer() dereferences to the per-process table + $function = new ReflectionFunction('zengine_live_statics_fn'); + $liveTable = $function->getLiveStaticsPointer(); + self::assertNotNull($liveTable, 'two calls materialized the live statics table of the function'); + $liveAddress = Core::addressOf($liveTable); + + $function->redefine(static function (): int { + /** @var int $m */ + static $m = 1000; + + return ++$m; + }, preserveAs: 'zengine_live_statics_fn_original'); + self::assertTrue(function_exists('zengine_live_statics_fn_original')); + + $alias = new ReflectionFunction('zengine_live_statics_fn_original'); + self::assertSame($liveAddress, self::liveTableAddress($alias), 'the second function entry took over the table of the function it preserves'); + self::assertNull($function->getLiveStaticsPointer(), 'the entry (the writable copy under opcache) starts afresh'); + + // The entry is called by name: a call site that already resolved the function before the + // swap keeps the shared-memory entry under opcache (copy-out caveat of docs/hot-swap.md) + self::assertSame(3, \zengine_live_statics_fn_original(), 'the preserved body continues its count'); + self::assertSame(1001, self::callFunction('zengine_live_statics_fn'), 'the new body binds its own statics from its defaults'); + self::assertSame(1002, self::callFunction('zengine_live_statics_fn')); + self::assertSame(4, \zengine_live_statics_fn_original()); + self::assertNotSame($liveAddress, self::liveTableAddress($function), 'the new body materialized a table of its own'); + self::assertSame($liveAddress, self::liveTableAddress($alias), 'the alias keeps its table across calls'); + self::assertSame(4, self::staticValue($liveTable, 'n')); + } + + public function testRemovingTheAliasDestroysItsLiveStaticsAndReleasesItsMintedCache(): void + { + $class = new ReflectionClass(LiveStaticsRemoved::class); + $class->getMethod('counter')->redefine((new ReflectionClass(LiveStaticsRemovedDonor::class))->getMethod('counter'), preserveAs: 'counterOriginal'); + // Created after the first mutation: under opcache that mutation copied the class out + $instance = new LiveStaticsRemoved(); + self::assertSame(101, $instance->counter(), 'the donor body delegates to the alias, which binds its statics'); + self::assertSame(102, $instance->counter()); + + $class = new ReflectionClass(LiveStaticsRemoved::class); + $alias = $class->getMethod('counterOriginal'); + $aliasOpArray = $alias->getOpArrayPointer(); + $liveTable = $aliasOpArray->static_variables_ptr__ptr; + self::assertNotNull($liveTable, 'the alias materialized its live statics table on its first call'); + self::assertSame(2, self::staticValue($liveTable, 'n')); + self::assertGreaterThan(0, $aliasOpArray->cache_size, 'the preserved body has inline caches (the property fetch)'); + self::assertNotNull($aliasOpArray->run_time_cache__ptr, 'the publication minted a run-time cache for the alias'); + self::assertSame(0, $alias->getCommonPointer()->fn_flags & Core::ZEND_ACC_HEAP_RT_CACHE, 'a minted cache is request memory the engine never frees itself'); + self::assertCount(1, FunctionBodySwap::publishedShareHolders($alias), 'the declaring class is the only table publishing the alias'); + + // The documented workflow: swap the preserved body back in, then drop the backup + $class->getMethod('counter')->redefine($class->getMethod('counterOriginal')); + $class->removeMethods('counterOriginal'); + + self::assertFalse(method_exists(LiveStaticsRemoved::class, 'counterOriginal')); + self::assertCount(0, FunctionBodySwap::publishedShareHolders($alias), 'no table reaches the container anymore'); + // The container is request memory the engine never frees (op_arrays are arena-allocated), + // so its slots stay readable: the bucket destructor only decremented the body refcount, the + // retirement destroyed the live table and released the minted cache the destructor cannot reach + self::assertNull($aliasOpArray->static_variables_ptr__ptr, 'zend_destroy_static_vars freed the live table and cleared the slot'); + self::assertNull($aliasOpArray->run_time_cache__ptr, 'the minted run-time cache was released'); + self::assertSame(1, (new LiveStaticsRemoved())->counter(), 'the restored body starts with fresh statics'); + self::assertSame(2, $instance->counter()); + } + + public function testAliasPublishCallRestoreRemoveCyclesAreMemoryFlat(): void + { + $class = new ReflectionClass(LiveStaticsChurn::class); + $class->copyOutOfSharedMemory(); + $donor = (new ReflectionClass(LiveStaticsChurnDonor::class))->getMethod('counter'); + $instance = new LiveStaticsChurn(); + + // One cycle materializes the entry's table (first call), moves it to the alias (publication), + // continues it (the donor body delegates), restores the body and drops the alias: before the + // fix the table was orphaned by the publication, or by the removal when materialized later + $cycle = static function () use ($donor, $instance): int { + $class = new ReflectionClass(LiveStaticsChurn::class); + $checksum = $instance->counter(); + $class->getMethod('counter')->redefine($donor, preserveAs: 'counterOriginal'); + $checksum += $instance->counter(); + $class->getMethod('counter')->redefine($class->getMethod('counterOriginal')); + $class->removeMethods('counterOriginal'); + + return $checksum; + }; + + for ($i = 0; $i < 3; $i++) { + self::assertSame(1 + 102, $cycle(), 'warm-up: a fresh table (1) continued by the alias (100 + 2)'); + } + gc_collect_cycles(); + $before = memory_get_usage(); + $checksum = 0; + for ($i = 0; $i < self::MEASURED_CYCLES; $i++) { + $checksum += $cycle(); + } + gc_collect_cycles(); + $growth = memory_get_usage() - $before; + + self::assertSame(self::MEASURED_CYCLES * 103, $checksum, 'every cycle started from fresh statics and continued them through the alias'); + self::assertFalse(method_exists(LiveStaticsChurn::class, 'counterOriginal')); + self::assertLessThan( + self::MEASURED_CYCLES * self::PER_CYCLE_BUDGET_BYTES, + $growth, + sprintf( + 'memory grew by %d bytes over %d cycles (%.1f bytes per cycle; the alias container is 256 bytes, a leaked live statics table 376 more)', + $growth, + self::MEASURED_CYCLES, + $growth / self::MEASURED_CYCLES, + ), + ); + } + + public function testFakeClosureOverTheAliasKeepsItsLiveStaticsPastTheRemoval(): void + { + $class = new ReflectionClass(LiveStaticsBorrowedAlias::class); + $class->getMethod('counter')->redefine((new ReflectionClass(LiveStaticsBorrowedAliasDonor::class))->getMethod('counter'), preserveAs: 'counterOriginal'); + $instance = new LiveStaticsBorrowedAlias(); + self::assertSame(101, $instance->counter()); + self::assertSame(102, $instance->counter()); + + $class = new ReflectionClass(LiveStaticsBorrowedAlias::class); + $alias = $class->getMethod('counterOriginal'); + $liveTable = $alias->getOpArrayPointer()->static_variables_ptr__ptr; + self::assertNotNull($liveTable); + $liveAddress = Core::addressOf($liveTable); + + // zend_create_closure_ex points a fake closure's slot AT the live table of the function it + // is made over (no duplicate): the closure reads and writes the alias' table + $fake = (new \ReflectionMethod(LiveStaticsBorrowedAlias::class, 'counterOriginal'))->getClosure($instance); + self::assertSame($liveAddress, self::liveSlotAddress(self::closureFunction($fake)), 'the fake closure borrows the table by pointer'); + self::assertSame(3, $fake()); + + $class->getMethod('counter')->redefine($class->getMethod('counterOriginal')); + $class->removeMethods('counterOriginal'); + self::assertFalse(method_exists(LiveStaticsBorrowedAlias::class, 'counterOriginal')); + + // Destroying the table here would pull it out from under the closure: it stays with it + $slotAfter = $alias->getOpArrayPointer()->static_variables_ptr__ptr; + self::assertNotNull($slotAfter, 'the retirement left the borrowed table alone'); + self::assertSame($liveAddress, Core::addressOf($slotAfter)); + self::assertSame(4, $fake(), 'the fake closure keeps executing the preserved body on its table'); + self::assertSame(5, $fake()); + self::assertSame(5, self::staticValue($slotAfter, 'n')); + self::assertSame($liveAddress, self::liveSlotAddress(self::closureFunction($fake))); + self::assertSame(1, (new LiveStaticsBorrowedAlias())->counter(), 'the restored body binds fresh statics of its own'); + } + + public function testFakeClosureOverTheEntryKeepsBorrowingTheLiveStaticsThroughASwap(): void + { + $class = new ReflectionClass(LiveStaticsBorrowedEntry::class); + $class->copyOutOfSharedMemory(); + $method = $class->getMethod('counter'); + $instance = new LiveStaticsBorrowedEntry(); + self::assertSame(1, $instance->counter()); + self::assertSame(2, $instance->counter()); + $liveTable = $method->getLiveStaticsPointer(); + self::assertNotNull($liveTable); + $liveAddress = Core::addressOf($liveTable); + + $fake = $instance->counter(...); + self::assertSame($liveAddress, self::liveSlotAddress(self::closureFunction($fake)), 'a first-class callable borrows the live table of the entry'); + + // Without an alias the previous body is destroyed by the swap - but the fake closure + // still holds a share of it, so the body arrays survive, and the live table it borrows + // must survive with them (destroying it would be a use-after-free in the closure) + $method->redefine(static fn(): int => 1000); + self::assertSame(1000, $instance->counter()); + self::assertNull($method->getLiveStaticsPointer(), 'the new body declares no statics'); + self::assertSame(3, $fake(), 'the fake closure keeps counting on the table the swap left to it'); + self::assertSame(4, $fake()); + self::assertSame($liveAddress, self::liveSlotAddress(self::closureFunction($fake))); + self::assertSame(4, self::staticValue($liveTable, 'n')); + } + + public function testAliasHeldByASubclassTableKeepsItsLiveStaticsUntilTheLastTableDropsIt(): void + { + // Linked now, before the redefinition: the child table shares the entry pointer + self::assertSame(1, (new LiveStaticsHeldChild())->counter()); + $base = new ReflectionClass(LiveStaticsHeldBase::class); + + if ($base->isImmutable()) { + // A subclass linked against the shared-memory entry could never see the alias + $this->expectException(SharedMemoryException::class); + $this->expectExceptionMessageMatches('/subclass .*LiveStaticsHeldChild was linked against the shared entry/'); + } + $base->getMethod('counter')->redefine((new ReflectionClass(LiveStaticsHeldDonor::class))->getMethod('counter'), preserveAs: 'counterOriginal'); + + // The alias took over the table the child's call materialized, and the child inherited the alias + self::assertSame(102, (new LiveStaticsHeldChild())->counter(), 'the preserved body runs on the child and continues its count'); + $alias = $base->getMethod('counterOriginal'); + $liveTable = $alias->getOpArrayPointer()->static_variables_ptr__ptr; + self::assertNotNull($liveTable); + $liveAddress = Core::addressOf($liveTable); + self::assertCount(2, FunctionBodySwap::publishedShareHolders($alias), 'both tables publish the alias container'); + + $base->getMethod('counter')->redefine($base->getMethod('counterOriginal')); + $base->removeMethods('counterOriginal'); + self::assertFalse(method_exists(LiveStaticsHeldBase::class, 'counterOriginal')); + // method_exists() reports false for a shadow private method (one inherited from an ancestor): the table itself is checked + self::assertTrue((new \ReflectionClass(LiveStaticsHeldChild::class))->hasMethod('counterOriginal'), 'the child table still publishes the alias'); + + // The engine reaches the table through the child's method table (ZEND_HAS_STATIC_IN_METHODS + // walk), so the retirement must leave it alone: the alias keeps working on the child + $slotAfter = $alias->getOpArrayPointer()->static_variables_ptr__ptr; + self::assertNotNull($slotAfter, 'removing the alias from one of two tables keeps its live statics'); + self::assertSame($liveAddress, Core::addressOf($slotAfter)); + self::assertSame(3, (new \ReflectionMethod(LiveStaticsHeldChild::class, 'counterOriginal'))->invoke(new LiveStaticsHeldChild())); + self::assertCount(1, FunctionBodySwap::publishedShareHolders($alias)); + + (new ReflectionClass(LiveStaticsHeldChild::class))->removeMethods('counterOriginal'); + self::assertFalse((new \ReflectionClass(LiveStaticsHeldChild::class))->hasMethod('counterOriginal')); + self::assertNull($alias->getOpArrayPointer()->static_variables_ptr__ptr, 'the last table dropping the alias destroys its live statics'); + self::assertCount(0, FunctionBodySwap::publishedShareHolders($alias)); + self::assertSame(1, (new LiveStaticsHeldBase())->counter(), 'the restored body starts with fresh statics'); + self::assertSame(2, (new LiveStaticsHeldChild())->counter(), 'shared with the child through the entry pointer'); + } + + /** + * Current value of a static variable in a materialized live statics table + * + * @param HashTableStruct $liveTable `HashTable *` of the table (the stub-typed FFI handle) + */ + private static function staticValue(object $liveTable, string $variableName): mixed + { + $slot = HashTable::fromCData($liveTable)->find($variableName); + self::assertNotNull($slot, "the table binds the static variable \${$variableName}"); + // A bound slot holds the IS_REFERENCE the executing body shares: read, never written + $value = null; + $slot->getNativeValue($value); + + return $value; + } + + /** + * Pointer-level view of the zend_function embedded in a closure object (borrowed: the + * closure must stay alive while the view is used) + */ + private static function closureFunction(\Closure $closure): ReflectionFunction + { + $closureEntry = new ClosureEntry($closure); + + return ReflectionFunction::fromCData(Core::addr($closureEntry->getRawFunction())); + } + + /** + * Address of the materialized live statics table of an entry (fails while none is materialized) + */ + private static function liveTableAddress(FunctionLikeInterface $function): int + { + $liveTable = $function->getLiveStaticsPointer(); + self::assertNotNull($liveTable, 'the entry has a materialized live statics table'); + + return Core::addressOf($liveTable); + } + + /** + * Address the raw static_variables_ptr slot of a user function points at (plain pointer form) + */ + private static function liveSlotAddress(FunctionLikeInterface $function): int + { + $liveTable = $function->getOpArrayPointer()->static_variables_ptr__ptr; + self::assertNotNull($liveTable, 'the function has a materialized live statics table'); + + return Core::addressOf($liveTable); + } + + /** + * Calls a global function published at run time (unknown to static analysis) by name + */ + private static function callFunction(string $functionName): mixed + { + return (new \ReflectionFunction($functionName))->invoke(); + } +} diff --git a/tests/Stub/Alias/LiveStatics-functions.php b/tests/Stub/Alias/LiveStatics-functions.php new file mode 100644 index 00000000..e17fdc7c --- /dev/null +++ b/tests/Stub/Alias/LiveStatics-functions.php @@ -0,0 +1,25 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +/** + * Global function with a static variable whose materialized live table moves over to the + * preserved alias (required once by + * RedefinePreservedStaticsTest::testAliasTakesOverTheLiveStaticsOfTheFunctionBodyItPreserves) + */ +function zengine_live_statics_fn(): int +{ + /** @var int $n */ + static $n = 0; + + return ++$n; +} diff --git a/tests/Stub/Alias/LiveStaticsBorrowedAlias.php b/tests/Stub/Alias/LiveStaticsBorrowedAlias.php new file mode 100644 index 00000000..9cb6b33f --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsBorrowedAlias.php @@ -0,0 +1,30 @@ + + * + * 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\Stub\Alias; + +/** + * Method with a static variable whose preserved alias gets a fake closure made over it before + * the alias is removed - owned by + * RedefinePreservedStaticsTest::testFakeClosureOverTheAliasKeepsItsLiveStaticsPastTheRemoval + */ +class LiveStaticsBorrowedAlias +{ + public function counter(): int + { + /** @var int $n */ + static $n = 0; + + return ++$n; + } +} diff --git a/tests/Stub/Alias/LiveStaticsBorrowedAliasDonor.php b/tests/Stub/Alias/LiveStaticsBorrowedAliasDonor.php new file mode 100644 index 00000000..610022d7 --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsBorrowedAliasDonor.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Method donor (no static variables) for LiveStaticsBorrowedAlias::counter(), delegating to + * the preserved alias - owned by the same scenario as LiveStaticsBorrowedAlias + * + * @method int counterOriginal() the preserved LiveStaticsBorrowedAlias::counter() body + */ +class LiveStaticsBorrowedAliasDonor +{ + public function counter(): int + { + return 100 + $this->counterOriginal(); + } +} diff --git a/tests/Stub/Alias/LiveStaticsBorrowedEntry.php b/tests/Stub/Alias/LiveStaticsBorrowedEntry.php new file mode 100644 index 00000000..7b3e6155 --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsBorrowedEntry.php @@ -0,0 +1,30 @@ + + * + * 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\Stub\Alias; + +/** + * Method with a static variable that a fake closure borrows the live table of before the + * method is redefined without an alias - owned by + * RedefinePreservedStaticsTest::testFakeClosureOverTheEntryKeepsBorrowingTheLiveStaticsThroughASwap + */ +class LiveStaticsBorrowedEntry +{ + public function counter(): int + { + /** @var int $n */ + static $n = 0; + + return ++$n; + } +} diff --git a/tests/Stub/Alias/LiveStaticsChurn.php b/tests/Stub/Alias/LiveStaticsChurn.php new file mode 100644 index 00000000..d1760d08 --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsChurn.php @@ -0,0 +1,29 @@ + + * + * 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\Stub\Alias; + +/** + * Method with a static variable driven through repeated publish/call/restore/remove cycles - + * owned by RedefinePreservedStaticsTest::testAliasPublishCallRestoreRemoveCyclesAreMemoryFlat + */ +class LiveStaticsChurn +{ + public function counter(): int + { + /** @var int $n */ + static $n = 0; + + return ++$n; + } +} diff --git a/tests/Stub/Alias/LiveStaticsChurnDonor.php b/tests/Stub/Alias/LiveStaticsChurnDonor.php new file mode 100644 index 00000000..953f91f4 --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsChurnDonor.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Method donor (no static variables) for LiveStaticsChurn::counter(), delegating to the + * preserved alias - owned by the same scenario as LiveStaticsChurn + * + * @method int counterOriginal() the preserved LiveStaticsChurn::counter() body + */ +class LiveStaticsChurnDonor +{ + public function counter(): int + { + return 100 + $this->counterOriginal(); + } +} diff --git a/tests/Stub/Alias/LiveStaticsCounter.php b/tests/Stub/Alias/LiveStaticsCounter.php new file mode 100644 index 00000000..3f4718b5 --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsCounter.php @@ -0,0 +1,29 @@ + + * + * 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\Stub\Alias; + +/** + * Method with a static variable whose materialized live table moves over to the preserved + * alias - owned by RedefinePreservedStaticsTest::testAliasTakesOverTheLiveStaticsOfTheMethodBodyItPreserves + */ +class LiveStaticsCounter +{ + public function counter(): int + { + /** @var int $n */ + static $n = 0; + + return ++$n; + } +} diff --git a/tests/Stub/Alias/LiveStaticsCounterDonor.php b/tests/Stub/Alias/LiveStaticsCounterDonor.php new file mode 100644 index 00000000..2b0f449d --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsCounterDonor.php @@ -0,0 +1,30 @@ + + * + * 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\Stub\Alias; + +/** + * Method donor with a static variable of its own for LiveStaticsCounter::counter(): the entry + * binds it afresh while the alias keeps the previous body's state - owned by the same scenario + * as LiveStaticsCounter + */ +class LiveStaticsCounterDonor +{ + public function counter(): int + { + /** @var int $m */ + static $m = 1000; + + return ++$m; + } +} diff --git a/tests/Stub/Alias/LiveStaticsHeldBase.php b/tests/Stub/Alias/LiveStaticsHeldBase.php new file mode 100644 index 00000000..fde9cea6 --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsHeldBase.php @@ -0,0 +1,30 @@ + + * + * 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\Stub\Alias; + +/** + * Method with a static variable preserved under an alias that a subclass linked before + * (LiveStaticsHeldChild) shares - owned by + * RedefinePreservedStaticsTest::testAliasHeldByASubclassTableKeepsItsLiveStaticsUntilTheLastTableDropsIt + */ +class LiveStaticsHeldBase +{ + public function counter(): int + { + /** @var int $n */ + static $n = 0; + + return ++$n; + } +} diff --git a/tests/Stub/Alias/LiveStaticsHeldChild.php b/tests/Stub/Alias/LiveStaticsHeldChild.php new file mode 100644 index 00000000..8cb40ba9 --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsHeldChild.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Linked before LiveStaticsHeldBase::counter() is redefined: inherits the entry pointer and + * then the alias bucket - owned by the same scenario as LiveStaticsHeldBase + */ +class LiveStaticsHeldChild extends LiveStaticsHeldBase {} diff --git a/tests/Stub/Alias/LiveStaticsHeldDonor.php b/tests/Stub/Alias/LiveStaticsHeldDonor.php new file mode 100644 index 00000000..9e773767 --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsHeldDonor.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Method donor (no static variables) for LiveStaticsHeldBase::counter(), delegating to the + * preserved alias - owned by the same scenario as LiveStaticsHeldBase + * + * @method int counterOriginal() the preserved LiveStaticsHeldBase::counter() body + */ +class LiveStaticsHeldDonor +{ + public function counter(): int + { + return 100 + $this->counterOriginal(); + } +} diff --git a/tests/Stub/Alias/LiveStaticsRemoved.php b/tests/Stub/Alias/LiveStaticsRemoved.php new file mode 100644 index 00000000..58079029 --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsRemoved.php @@ -0,0 +1,32 @@ + + * + * 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\Stub\Alias; + +/** + * Method with a static variable and an inline cache (the property fetch), preserved under an + * alias that is dropped again with removeMethods() - owned by + * RedefinePreservedStaticsTest::testRemovingTheAliasDestroysItsLiveStaticsAndReleasesItsMintedCache + */ +class LiveStaticsRemoved +{ + public int $base = 0; + + public function counter(): int + { + /** @var int $n */ + static $n = 0; + + return $this->base + ++$n; + } +} diff --git a/tests/Stub/Alias/LiveStaticsRemovedDonor.php b/tests/Stub/Alias/LiveStaticsRemovedDonor.php new file mode 100644 index 00000000..0cfc6e4c --- /dev/null +++ b/tests/Stub/Alias/LiveStaticsRemovedDonor.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Method donor (no static variables) for LiveStaticsRemoved::counter(), delegating to the + * preserved alias - owned by the same scenario as LiveStaticsRemoved + * + * @method int counterOriginal() the preserved LiveStaticsRemoved::counter() body + */ +class LiveStaticsRemovedDonor +{ + public function counter(): int + { + return 100 + $this->counterOriginal(); + } +} From 9e97ad5af314fe2334db4c0d66eabb35e1689e9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:51:07 +0000 Subject: [PATCH 10/14] fix(reflection): judge immutability by the owning class, not the entry flags A method entry that adopted a body from an opcache-cached donor carries ZEND_ACC_IMMUTABLE as a body-level flag. An interface-induced engine clone copied from such an entry, or a subclass method redeclaring an alias, then looked immutable although its class lives in request memory, so redefine() skipped the clone (the subclass kept the previous body) and markRedeclaration() refused a perfectly writable class. Decide by the holder class: a clone inside a mutable class follows the swap, a redeclaring mutable subclass receives ZEND_ACC_CHANGED, and only a shared-memory class is refused. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- src/Reflection/FunctionLikeTrait.php | 8 +++++--- src/Reflection/MethodPublisher.php | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Reflection/FunctionLikeTrait.php b/src/Reflection/FunctionLikeTrait.php index c5904390..38ea0e24 100644 --- a/src/Reflection/FunctionLikeTrait.php +++ b/src/Reflection/FunctionLikeTrait.php @@ -279,10 +279,12 @@ public function redefine(\Closure|FunctionLikeInterface $newBody, ?string $prese // A subclass implementing an interface that declares the method holds an engine // CLONE of the entry instead of the entry pointer (FunctionBodySwap::cloneHolders()): // the clone dispatches for that subclass, so it follows the redefinition in place - foreach (FunctionBodySwap::cloneHolders($entryFunction) as [, $clone]) { - if ($clone->isImmutable()) { + foreach (FunctionBodySwap::cloneHolders($entryFunction) as [$holder, $clone]) { + if ($holder->isImmutable()) { // A clone inside a shared-memory subclass is never written: that subclass - // keeps the previous body, like every subclass linked against a shared entry + // keeps the previous body, like every subclass linked against a shared entry. + // (The class decides, not the clone's flags: a clone copied from an entry that + // adopted a cached donor body carries ZEND_ACC_IMMUTABLE as a body-level flag) continue; } $pendingSwaps[] = FunctionBodySwap::swapUserFunctionBody( diff --git a/src/Reflection/MethodPublisher.php b/src/Reflection/MethodPublisher.php index 55f616eb..54a184dc 100644 --- a/src/Reflection/MethodPublisher.php +++ b/src/Reflection/MethodPublisher.php @@ -520,7 +520,7 @@ private static function markRedeclaration( if (self::hasFlag($ownMethod, Core::ZEND_ACC_CHANGED)) { return; } - if ($ownMethod->isImmutable()) { + if ($subclass->isImmutable()) { // The flag cannot be written into shared memory, and without it the preserved // body could not reach its alias on instances of that subclass throw SharedMemoryException::sharedEntryHasLinkedSubclasses( From 8856a107350a697c0cd9143b9169591ff74cfc0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:51:07 +0000 Subject: [PATCH 11/14] test(reflection): cover ZEND_ACC_CHANGED propagation of addMethod() overrides An override installed over an inherited method must keep the engine's inheritance semantics: it carries ZEND_ACC_CHANGED when the inherited method was CHANGED or private (so an ancestor's private method still wins from the ancestor's own scope), takes the inherited prototype otherwise, and a fresh method gets neither. Covers: a three-level chain whose leaf overrides a CHANGED method and a subclass linked afterwards, an early-linked subclass tree, a private ancestor method shadowed by an override, and a plain override that must not acquire the flag. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- tests/Reflection/AddMethodChangedFlagTest.php | 242 ++++++++++++++++++ tests/Stub/Alias/ChangedEarlyDonor.php | 26 ++ tests/Stub/Alias/ChangedEarlyLeaf.php | 20 ++ tests/Stub/Alias/ChangedEarlyMid.php | 26 ++ tests/Stub/Alias/ChangedEarlyRoot.php | 37 +++ tests/Stub/Alias/ChangedEarlySub.php | 22 ++ tests/Stub/Alias/ChangedLeaf.php | 20 ++ tests/Stub/Alias/ChangedLeafDonor.php | 26 ++ tests/Stub/Alias/ChangedLeafSub.php | 20 ++ tests/Stub/Alias/ChangedMid.php | 27 ++ tests/Stub/Alias/ChangedPlainBase.php | 34 +++ tests/Stub/Alias/ChangedPlainChild.php | 20 ++ tests/Stub/Alias/ChangedPlainDonor.php | 26 ++ tests/Stub/Alias/ChangedPlainDonorBase.php | 35 +++ tests/Stub/Alias/ChangedRoot.php | 39 +++ tests/Stub/Alias/ChangedSecretBase.php | 38 +++ tests/Stub/Alias/ChangedSecretChild.php | 23 ++ tests/Stub/Alias/ChangedSecretDonor.php | 26 ++ 18 files changed, 707 insertions(+) create mode 100644 tests/Reflection/AddMethodChangedFlagTest.php create mode 100644 tests/Stub/Alias/ChangedEarlyDonor.php create mode 100644 tests/Stub/Alias/ChangedEarlyLeaf.php create mode 100644 tests/Stub/Alias/ChangedEarlyMid.php create mode 100644 tests/Stub/Alias/ChangedEarlyRoot.php create mode 100644 tests/Stub/Alias/ChangedEarlySub.php create mode 100644 tests/Stub/Alias/ChangedLeaf.php create mode 100644 tests/Stub/Alias/ChangedLeafDonor.php create mode 100644 tests/Stub/Alias/ChangedLeafSub.php create mode 100644 tests/Stub/Alias/ChangedMid.php create mode 100644 tests/Stub/Alias/ChangedPlainBase.php create mode 100644 tests/Stub/Alias/ChangedPlainChild.php create mode 100644 tests/Stub/Alias/ChangedPlainDonor.php create mode 100644 tests/Stub/Alias/ChangedPlainDonorBase.php create mode 100644 tests/Stub/Alias/ChangedRoot.php create mode 100644 tests/Stub/Alias/ChangedSecretBase.php create mode 100644 tests/Stub/Alias/ChangedSecretChild.php create mode 100644 tests/Stub/Alias/ChangedSecretDonor.php diff --git a/tests/Reflection/AddMethodChangedFlagTest.php b/tests/Reflection/AddMethodChangedFlagTest.php new file mode 100644 index 00000000..99824335 --- /dev/null +++ b/tests/Reflection/AddMethodChangedFlagTest.php @@ -0,0 +1,242 @@ + + * + * 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\Reflection; + +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\OpCache\SharedMemoryException; +use ZEngine\Stub\Alias\ChangedEarlyDonor; +use ZEngine\Stub\Alias\ChangedEarlyLeaf; +use ZEngine\Stub\Alias\ChangedEarlyMid; +use ZEngine\Stub\Alias\ChangedEarlySub; +use ZEngine\Stub\Alias\ChangedLeaf; +use ZEngine\Stub\Alias\ChangedLeafDonor; +use ZEngine\Stub\Alias\ChangedLeafSub; +use ZEngine\Stub\Alias\ChangedMid; +use ZEngine\Stub\Alias\ChangedPlainBase; +use ZEngine\Stub\Alias\ChangedPlainChild; +use ZEngine\Stub\Alias\ChangedPlainDonor; +use ZEngine\Stub\Alias\ChangedSecretBase; +use ZEngine\Stub\Alias\ChangedSecretChild; +use ZEngine\Stub\Alias\ChangedSecretDonor; + +/** + * The ZEND_ACC_CHANGED bit of the override addMethod() publishes over an inherited method + * + * The engine marks a method redeclaring a parent-private method ZEND_ACC_CHANGED and hands + * the bit down to every override of a method carrying it (do_inheritance_check_on_method: + * a parent flagged PRIVATE or CHANGED flags the child CHANGED). zend_std_get_method reads + * the bit when the calling scope is not the scope of the method it found: it looks the name + * up in the calling scope's own table (zend_get_parent_private_method) and dispatches that + * private method instead, which is how `$this->m()` in an ancestor keeps reaching the + * ancestor's own private m() on an instance of a class whose public m() shadows it. The + * override MethodPublisher::publishOverride() builds for addMethod() set the bit only when + * the inherited entry itself was private: over an inherited entry that merely CARRIED the + * bit the override had it cleared, and the ancestor's own code was routed to the donor body. + * + * Observing the dispatch takes care, twice. The `$this->m()` call site caches (class entry, + * resolved method) monomorphically in the run-time cache of the ancestor's method, so a + * call warmed before the publication keeps answering from the slot afterwards as long as + * the class entry is unchanged - hiding a wrong bit: every "after" dispatch asserted here + * is preceded by a call through an instance of another class, which evicts the slot and + * makes the asserted call a full lookup. And a `new X()` literal caches the class entry it + * resolved in the run-time cache slot of its class-name literal, one slot per literal that + * the optimizer shares between all `new X()` of a method, so after the copy-out of an + * opcache-shared class the literal keeps instantiating the shared-memory class: the classes + * a publication targets are instantiated through a run-time class lookup (instance()). The + * private bodies name their receiver so that the optimizer cannot inline the call at cache + * time (a constant-returning private method is). + * + * Every scenario owns its stub classes (tests/Stub/Alias/Changed*): a publication changes + * the class for the rest of the process. Subclasses are autoloaded AFTER the publication + * except where "linked before" is the subject, which the opcache-active runner (every stub + * an immutable shared-memory class) pins as the refusal instead. + */ +class AddMethodChangedFlagTest extends TestCase +{ + public function testOverrideOfAChangedInheritedMethodKeepsTheRootPrivateDispatch(): void + { + $midMethod = (new ReflectionClass(ChangedMid::class))->getMethod('m'); + $leafBefore = (new ReflectionClass(ChangedLeaf::class))->getMethod('m'); + // The premise: Mid::m redeclares the root's private m(), so the engine flagged it + // CHANGED at link time, and the leaf's table inherits that very entry + self::assertSame(Core::ZEND_ACC_CHANGED, self::flagsOf($midMethod) & Core::ZEND_ACC_CHANGED); + self::assertSame($midMethod->getAddress(), $leafBefore->getAddress()); + self::assertSame('root@' . ChangedLeaf::class, self::instance(ChangedLeaf::class)->viaRoot(), 'the root resolves its own private m() on a leaf instance'); + self::assertSame('mid', self::instance(ChangedLeaf::class)->m()); + + $donor = (new ReflectionClass(ChangedLeafDonor::class))->getMethod('m'); + $published = (new ReflectionClass(ChangedLeaf::class))->addMethod('m', $donor); + + // The bit the engine sets on a compiled override of a CHANGED method, on an own + // entry of the leaf whose prototype is the overridden Mid::m (which has none itself) + self::assertSame(Core::ZEND_ACC_CHANGED, self::flagsOf($published) & Core::ZEND_ACC_CHANGED); + self::assertSame(ChangedLeaf::class, $published->getDeclaringClass()->getName()); + self::assertNotSame($midMethod->getAddress(), $published->getAddress()); + self::assertSame($published->getAddress(), (new ReflectionClass(ChangedLeaf::class))->getMethod('m')->getAddress()); + self::assertSame($midMethod->getAddress(), $published->getPrototype()?->getAddress()); + $native = new \ReflectionMethod(ChangedLeaf::class, 'm'); + self::assertSame(ChangedLeaf::class, $native->getDeclaringClass()->getName()); + self::assertSame(ChangedMid::class, $native->getPrototype()->getDeclaringClass()->getName()); + self::assertTrue($native->isPublic()); + + // Mid is untouched - and the call evicts the leaf from the `$this->m()` slot of + // viaRoot(), so the leaf dispatch below is a full lookup reading the published flags + self::assertSame('root@' . ChangedMid::class, (new ChangedMid())->viaRoot()); + self::assertSame('mid', (new ChangedMid())->m()); + self::assertSame('root@' . ChangedLeaf::class, self::instance(ChangedLeaf::class)->viaRoot(), 'the root still resolves its own private m() on a leaf instance'); + self::assertSame('donor', self::instance(ChangedLeaf::class)->m(), 'every other caller reaches the donor body'); + + // Receivers alternating on the same call site: every call is a full lookup + for ($i = 0; $i < 200; $i++) { + self::assertSame('root@' . ChangedLeaf::class, self::instance(ChangedLeaf::class)->viaRoot()); + self::assertSame('root@' . ChangedMid::class, (new ChangedMid())->viaRoot()); + } + + // Autoloaded now, i.e. linked against the leaf with the override: the subclass + // inherits the published entry itself (do_inherit_method), bit included + $subMethod = (new ReflectionClass(ChangedLeafSub::class))->getMethod('m'); + self::assertSame($published->getAddress(), $subMethod->getAddress()); + self::assertSame(Core::ZEND_ACC_CHANGED, self::flagsOf($subMethod) & Core::ZEND_ACC_CHANGED); + self::assertSame('root@' . ChangedLeafSub::class, self::instance(ChangedLeafSub::class)->viaRoot()); + self::assertSame('donor', self::instance(ChangedLeafSub::class)->m()); + } + + public function testOverrideIsSharedIntoASubclassLinkedBeforeWithTheChangedFlag(): void + { + // The whole chain is linked now: Sub's table inherits Mid's CHANGED entry through Leaf + $midMethod = (new ReflectionClass(ChangedEarlyMid::class))->getMethod('m'); + self::assertSame($midMethod->getAddress(), (new ReflectionClass(ChangedEarlySub::class))->getMethod('m')->getAddress()); + self::assertSame('root@' . ChangedEarlySub::class, self::instance(ChangedEarlySub::class)->viaRoot()); + self::assertSame('mid', self::instance(ChangedEarlySub::class)->m()); + $leaf = new ReflectionClass(ChangedEarlyLeaf::class); + $donor = (new ReflectionClass(ChangedEarlyDonor::class))->getMethod('m'); + + if ($leaf->isImmutable()) { + // Refused before the copy-out: the subclass linked against the shared entry would + // keep dispatching Mid's entry while the leaf copy carried the override + try { + $leaf->addMethod('m', $donor); + self::fail('override on a shared class with a subclass linked before not refused'); + } catch (SharedMemoryException $exception) { + self::assertMatchesRegularExpression('/subclass .*ChangedEarlySub was linked against the shared entry/', $exception->getMessage()); + } + self::assertTrue((new ReflectionClass(ChangedEarlyLeaf::class))->isImmutable(), 'refused before anything was copied out'); + self::assertSame(ChangedEarlyMid::class, (new \ReflectionMethod(ChangedEarlyLeaf::class, 'm'))->getDeclaringClass()->getName()); + self::assertSame('mid', self::instance(ChangedEarlySub::class)->m(), 'nothing was published'); + self::assertSame('root@' . ChangedEarlySub::class, self::instance(ChangedEarlySub::class)->viaRoot()); + + return; + } + + $published = $leaf->addMethod('m', $donor); + + // The subclass table now holds the published entry (shareIntoSubclass): the same + // pointer, so the CHANGED bit travels with it + $subMethod = (new ReflectionClass(ChangedEarlySub::class))->getMethod('m'); + self::assertSame($published->getAddress(), $subMethod->getAddress()); + self::assertSame(Core::ZEND_ACC_CHANGED, self::flagsOf($subMethod) & Core::ZEND_ACC_CHANGED); + self::assertSame(ChangedEarlyLeaf::class, (new \ReflectionMethod(ChangedEarlySub::class, 'm'))->getDeclaringClass()->getName()); + + // Evict the warmed slot through Mid; the subclass and leaf dispatches are full lookups + self::assertSame('root@' . ChangedEarlyMid::class, (new ChangedEarlyMid())->viaRoot()); + self::assertSame('root@' . ChangedEarlySub::class, self::instance(ChangedEarlySub::class)->viaRoot(), 'the root resolves its own private m() on the subclass linked before'); + self::assertSame('donor', self::instance(ChangedEarlySub::class)->m()); + self::assertSame('root@' . ChangedEarlyLeaf::class, self::instance(ChangedEarlyLeaf::class)->viaRoot()); + self::assertSame('donor', self::instance(ChangedEarlyLeaf::class)->m()); + } + + public function testOverrideOfAParentPrivateMethodIsMarkedChangedAndLinksNoPrototype(): void + { + $baseMethod = (new ReflectionClass(ChangedSecretBase::class))->getMethod('m'); + $childBefore = (new ReflectionClass(ChangedSecretChild::class))->getMethod('m'); + // The premise: a parent's private method is copied into the child's table at link time + self::assertSame($baseMethod->getAddress(), $childBefore->getAddress()); + self::assertSame(Core::ZEND_ACC_PRIVATE, self::flagsOf($childBefore) & Core::ZEND_ACC_PRIVATE); + self::assertTrue((new \ReflectionMethod(ChangedSecretChild::class, 'm'))->isPrivate()); + self::assertSame('secret@' . ChangedSecretChild::class, self::instance(ChangedSecretChild::class)->viaBase()); + + $donor = (new ReflectionClass(ChangedSecretDonor::class))->getMethod('m'); + $published = (new ReflectionClass(ChangedSecretChild::class))->addMethod('m', $donor); + + // A redeclared parent-private method: CHANGED, and no prototype - the engine does not + // treat it as an override of the private method + self::assertSame(Core::ZEND_ACC_CHANGED, self::flagsOf($published) & Core::ZEND_ACC_CHANGED); + self::assertNull($published->getPrototype()); + $native = new \ReflectionMethod(ChangedSecretChild::class, 'm'); + self::assertSame(ChangedSecretChild::class, $native->getDeclaringClass()->getName()); + self::assertTrue($native->isPublic()); + self::assertFalse($native->hasPrototype()); + self::assertTrue((new \ReflectionMethod(ChangedSecretBase::class, 'm'))->isPrivate(), 'the base declaration is untouched'); + + // Evict the warmed slot through the base; the child dispatch is a full lookup + self::assertSame('secret@' . ChangedSecretBase::class, (new ChangedSecretBase())->viaBase()); + self::assertSame('secret@' . ChangedSecretChild::class, self::instance(ChangedSecretChild::class)->viaBase(), 'the base resolves its own private m() on a child instance'); + self::assertSame('donor', self::instance(ChangedSecretChild::class)->m(), 'the override is public to everyone else'); + } + + public function testPlainOverrideOfAPublicInheritedMethodCarriesNoChangedBit(): void + { + $baseMethod = (new ReflectionClass(ChangedPlainBase::class))->getMethod('m'); + $donorMethod = (new ReflectionClass(ChangedPlainDonor::class))->getMethod('m'); + // The inherited entry carries no CHANGED bit; the donor does (it redeclares a private + // method of its own parent), and the bit must not travel with the donor body + self::assertSame(0, self::flagsOf($baseMethod) & Core::ZEND_ACC_CHANGED); + self::assertSame(Core::ZEND_ACC_CHANGED, self::flagsOf($donorMethod) & Core::ZEND_ACC_CHANGED); + self::assertSame($baseMethod->getAddress(), (new ReflectionClass(ChangedPlainChild::class))->getMethod('m')->getAddress()); + self::assertSame('base', (new ChangedPlainBase())->viaBase()); + + $published = (new ReflectionClass(ChangedPlainChild::class))->addMethod('m', $donorMethod); + + self::assertSame(0, self::flagsOf($published) & Core::ZEND_ACC_CHANGED, 'the bit follows the inherited entry, not the donor'); + self::assertSame($baseMethod->getAddress(), $published->getPrototype()?->getAddress()); + $native = new \ReflectionMethod(ChangedPlainChild::class, 'm'); + self::assertSame(ChangedPlainChild::class, $native->getDeclaringClass()->getName()); + self::assertSame(ChangedPlainBase::class, $native->getPrototype()->getDeclaringClass()->getName()); + + // Without the bit the base's own code dispatches virtually and reaches the override, + // as it does for a compiled override of a public method + self::assertSame('donor', self::instance(ChangedPlainChild::class)->viaBase()); + self::assertSame('donor', self::instance(ChangedPlainChild::class)->m()); + self::assertSame('base', (new ChangedPlainBase())->viaBase(), 'the base is untouched'); + self::assertSame('base', (new ChangedPlainBase())->m()); + } + + /** + * Instantiates a class through a run-time class lookup: unlike a `new X()` literal, + * whose class-name literal caches the class entry it resolved first, the lookup sees + * the copy a shared-memory class was replaced with by a publication + * + * @template T of object + * + * @param class-string $class + * + * @return T + */ + private static function instance(string $class): object + { + return new $class(); + } + + /** + * The fn_flags of an entry, read through the typed common struct + */ + private static function flagsOf(FunctionLikeInterface $entry): int + { + $flags = $entry->getCommonPointer()->fn_flags; + assert(is_int($flags)); + + return $flags; + } +} diff --git a/tests/Stub/Alias/ChangedEarlyDonor.php b/tests/Stub/Alias/ChangedEarlyDonor.php new file mode 100644 index 00000000..6d95ff10 --- /dev/null +++ b/tests/Stub/Alias/ChangedEarlyDonor.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Donor outside the hierarchy for the ChangedEarlyLeaf::m() override + * (AddMethodChangedFlagTest::testOverrideIsSharedIntoASubclassLinkedBeforeWithTheChangedFlag) + */ +class ChangedEarlyDonor +{ + public function m(): string + { + return 'donor'; + } +} diff --git a/tests/Stub/Alias/ChangedEarlyLeaf.php b/tests/Stub/Alias/ChangedEarlyLeaf.php new file mode 100644 index 00000000..d026b52d --- /dev/null +++ b/tests/Stub/Alias/ChangedEarlyLeaf.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Target of the override, linked - together with its subclass ChangedEarlySub - BEFORE the + * override is published (AddMethodChangedFlagTest::testOverrideIsSharedIntoASubclassLinkedBeforeWithTheChangedFlag) + */ +class ChangedEarlyLeaf extends ChangedEarlyMid {} diff --git a/tests/Stub/Alias/ChangedEarlyMid.php b/tests/Stub/Alias/ChangedEarlyMid.php new file mode 100644 index 00000000..b8748b3e --- /dev/null +++ b/tests/Stub/Alias/ChangedEarlyMid.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Redeclares the private ChangedEarlyRoot::m(), so the engine marks this entry + * ZEND_ACC_CHANGED (AddMethodChangedFlagTest::testOverrideIsSharedIntoASubclassLinkedBeforeWithTheChangedFlag) + */ +class ChangedEarlyMid extends ChangedEarlyRoot +{ + public function m(): string + { + return 'mid'; + } +} diff --git a/tests/Stub/Alias/ChangedEarlyRoot.php b/tests/Stub/Alias/ChangedEarlyRoot.php new file mode 100644 index 00000000..5d68a545 --- /dev/null +++ b/tests/Stub/Alias/ChangedEarlyRoot.php @@ -0,0 +1,37 @@ + + * + * 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\Stub\Alias; + +/** + * Root of the linked-before chain (AddMethodChangedFlagTest::testOverrideIsSharedIntoASubclassLinkedBeforeWithTheChangedFlag): + * a private m() the root's own code calls through $this + */ +class ChangedEarlyRoot +{ + /** + * Resolves m() from the root's own scope + */ + public function viaRoot(): string + { + return $this->m(); + } + + /** + * Receiver-dependent body: not foldable by the optimizer at cache time + */ + private function m(): string + { + return 'root@' . static::class; + } +} diff --git a/tests/Stub/Alias/ChangedEarlySub.php b/tests/Stub/Alias/ChangedEarlySub.php new file mode 100644 index 00000000..e5afc23d --- /dev/null +++ b/tests/Stub/Alias/ChangedEarlySub.php @@ -0,0 +1,22 @@ + + * + * 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\Stub\Alias; + +/** + * Linked BEFORE the override is published on ChangedEarlyLeaf: its table inherits the + * CHANGED ChangedEarlyMid::m() entry and must follow the override, bit included - or the + * publication is refused for a shared-memory leaf + * (AddMethodChangedFlagTest::testOverrideIsSharedIntoASubclassLinkedBeforeWithTheChangedFlag) + */ +class ChangedEarlySub extends ChangedEarlyLeaf {} diff --git a/tests/Stub/Alias/ChangedLeaf.php b/tests/Stub/Alias/ChangedLeaf.php new file mode 100644 index 00000000..0c29fb09 --- /dev/null +++ b/tests/Stub/Alias/ChangedLeaf.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Target of the override: inherits the CHANGED ChangedMid::m() and receives the donor body + * under that name (AddMethodChangedFlagTest::testOverrideOfAChangedInheritedMethodKeepsTheRootPrivateDispatch) + */ +class ChangedLeaf extends ChangedMid {} diff --git a/tests/Stub/Alias/ChangedLeafDonor.php b/tests/Stub/Alias/ChangedLeafDonor.php new file mode 100644 index 00000000..f43bd97d --- /dev/null +++ b/tests/Stub/Alias/ChangedLeafDonor.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Donor outside the hierarchy for the ChangedLeaf::m() override: its m() carries no + * ZEND_ACC_CHANGED bit of its own (AddMethodChangedFlagTest::testOverrideOfAChangedInheritedMethodKeepsTheRootPrivateDispatch) + */ +class ChangedLeafDonor +{ + public function m(): string + { + return 'donor'; + } +} diff --git a/tests/Stub/Alias/ChangedLeafSub.php b/tests/Stub/Alias/ChangedLeafSub.php new file mode 100644 index 00000000..7ccf29b6 --- /dev/null +++ b/tests/Stub/Alias/ChangedLeafSub.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Autoloaded AFTER the override was published on ChangedLeaf, i.e. linked against the + * published entry (AddMethodChangedFlagTest::testOverrideOfAChangedInheritedMethodKeepsTheRootPrivateDispatch) + */ +class ChangedLeafSub extends ChangedLeaf {} diff --git a/tests/Stub/Alias/ChangedMid.php b/tests/Stub/Alias/ChangedMid.php new file mode 100644 index 00000000..e3f5ff00 --- /dev/null +++ b/tests/Stub/Alias/ChangedMid.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Redeclares the private ChangedRoot::m() as a public method, which the engine marks + * ZEND_ACC_CHANGED at link time; ChangedLeaf inherits this entry and overrides it with + * addMethod() (AddMethodChangedFlagTest::testOverrideOfAChangedInheritedMethodKeepsTheRootPrivateDispatch) + */ +class ChangedMid extends ChangedRoot +{ + public function m(): string + { + return 'mid'; + } +} diff --git a/tests/Stub/Alias/ChangedPlainBase.php b/tests/Stub/Alias/ChangedPlainBase.php new file mode 100644 index 00000000..e142547e --- /dev/null +++ b/tests/Stub/Alias/ChangedPlainBase.php @@ -0,0 +1,34 @@ + + * + * 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\Stub\Alias; + +/** + * Declares a PUBLIC m() carrying no ZEND_ACC_CHANGED bit, inherited and then overridden by + * ChangedPlainChild (AddMethodChangedFlagTest::testPlainOverrideOfAPublicInheritedMethodCarriesNoChangedBit) + */ +class ChangedPlainBase +{ + /** + * Resolves m() from the base's own scope: a virtual dispatch for a public method + */ + public function viaBase(): string + { + return $this->m(); + } + + public function m(): string + { + return 'base'; + } +} diff --git a/tests/Stub/Alias/ChangedPlainChild.php b/tests/Stub/Alias/ChangedPlainChild.php new file mode 100644 index 00000000..dee6ed49 --- /dev/null +++ b/tests/Stub/Alias/ChangedPlainChild.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Target of a plain override of a public, non-CHANGED inherited method + * (AddMethodChangedFlagTest::testPlainOverrideOfAPublicInheritedMethodCarriesNoChangedBit) + */ +class ChangedPlainChild extends ChangedPlainBase {} diff --git a/tests/Stub/Alias/ChangedPlainDonor.php b/tests/Stub/Alias/ChangedPlainDonor.php new file mode 100644 index 00000000..53e29bbc --- /dev/null +++ b/tests/Stub/Alias/ChangedPlainDonor.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Donor whose m() redeclares a parent-private method and therefore carries ZEND_ACC_CHANGED + * itself (AddMethodChangedFlagTest::testPlainOverrideOfAPublicInheritedMethodCarriesNoChangedBit) + */ +class ChangedPlainDonor extends ChangedPlainDonorBase +{ + public function m(): string + { + return 'donor'; + } +} diff --git a/tests/Stub/Alias/ChangedPlainDonorBase.php b/tests/Stub/Alias/ChangedPlainDonorBase.php new file mode 100644 index 00000000..73c31133 --- /dev/null +++ b/tests/Stub/Alias/ChangedPlainDonorBase.php @@ -0,0 +1,35 @@ + + * + * 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\Stub\Alias; + +/** + * Parent of the donor: its private m() makes the engine flag ChangedPlainDonor::m() + * ZEND_ACC_CHANGED, a bit the override published from that donor must not carry + * (AddMethodChangedFlagTest::testPlainOverrideOfAPublicInheritedMethodCarriesNoChangedBit) + */ +class ChangedPlainDonorBase +{ + /** + * Resolves m() from this scope (keeps the private method referenced) + */ + public function viaDonorBase(): string + { + return $this->m(); + } + + private function m(): string + { + return 'donor-base@' . static::class; + } +} diff --git a/tests/Stub/Alias/ChangedRoot.php b/tests/Stub/Alias/ChangedRoot.php new file mode 100644 index 00000000..7d99ee5f --- /dev/null +++ b/tests/Stub/Alias/ChangedRoot.php @@ -0,0 +1,39 @@ + + * + * 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\Stub\Alias; + +/** + * Root of the CHANGED chain (AddMethodChangedFlagTest::testOverrideOfAChangedInheritedMethodKeepsTheRootPrivateDispatch): + * a private m() the root's own code calls through $this, so the dispatch depends on the + * ZEND_ACC_CHANGED bit of whatever m() the receiver's class resolves + */ +class ChangedRoot +{ + /** + * Resolves m() from the root's own scope + */ + public function viaRoot(): string + { + return $this->m(); + } + + /** + * The body names its receiver, so the optimizer cannot fold the call in viaRoot() into + * a constant at cache time (a `return 'const';` private method is inlined by opcache) + */ + private function m(): string + { + return 'root@' . static::class; + } +} diff --git a/tests/Stub/Alias/ChangedSecretBase.php b/tests/Stub/Alias/ChangedSecretBase.php new file mode 100644 index 00000000..1d813e4e --- /dev/null +++ b/tests/Stub/Alias/ChangedSecretBase.php @@ -0,0 +1,38 @@ + + * + * 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\Stub\Alias; + +/** + * Declares the private m() that ChangedSecretChild inherits into its own table at link time + * and then overrides with addMethod() + * (AddMethodChangedFlagTest::testOverrideOfAParentPrivateMethodIsMarkedChangedAndLinksNoPrototype) + */ +class ChangedSecretBase +{ + /** + * Resolves m() from the base's own scope + */ + public function viaBase(): string + { + return $this->m(); + } + + /** + * Receiver-dependent body: not foldable by the optimizer at cache time + */ + private function m(): string + { + return 'secret@' . static::class; + } +} diff --git a/tests/Stub/Alias/ChangedSecretChild.php b/tests/Stub/Alias/ChangedSecretChild.php new file mode 100644 index 00000000..dbbb55c6 --- /dev/null +++ b/tests/Stub/Alias/ChangedSecretChild.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); + +namespace ZEngine\Stub\Alias; + +/** + * Target of the override of a parent-private method: before the publication its table holds + * the private ChangedSecretBase::m() entry, afterwards the public donor body + * (AddMethodChangedFlagTest::testOverrideOfAParentPrivateMethodIsMarkedChangedAndLinksNoPrototype) + * + * @method string m() the public override addMethod() publishes (the static analyser only sees the inherited private declaration) + */ +class ChangedSecretChild extends ChangedSecretBase {} diff --git a/tests/Stub/Alias/ChangedSecretDonor.php b/tests/Stub/Alias/ChangedSecretDonor.php new file mode 100644 index 00000000..a1d9402f --- /dev/null +++ b/tests/Stub/Alias/ChangedSecretDonor.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Donor for the ChangedSecretChild::m() override of a parent-private method + * (AddMethodChangedFlagTest::testOverrideOfAParentPrivateMethodIsMarkedChangedAndLinksNoPrototype) + */ +class ChangedSecretDonor +{ + public function m(): string + { + return 'donor'; + } +} From 373452fd97999acd8ac836b3918deb4078d90218 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:51:08 +0000 Subject: [PATCH 12/14] test(reflection): cover interface-induced engine clones of a redefined entry A subclass that implements an interface declaring an inherited method holds an engine clone of the entry rather than the entry pointer. The clone must follow redefine() in place, carry a preserveAs alias, be reachable by an addMethod() override through the clone-holding descendants, keep its own run-time cache after the swap, and be refused only when its class lives in shared memory. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .../Reflection/RedefineInterfaceCloneTest.php | 332 ++++++++++++++++++ tests/Stub/Alias/CloneAliasBase.php | 26 ++ tests/Stub/Alias/CloneAliasChild.php | 21 ++ tests/Stub/Alias/CloneAliasDonor.php | 28 ++ tests/Stub/Alias/CloneBase.php | 26 ++ tests/Stub/Alias/CloneChild.php | 21 ++ tests/Stub/Alias/CloneContract.php | 26 ++ tests/Stub/Alias/CloneEarlyBase.php | 26 ++ tests/Stub/Alias/CloneEarlyChild.php | 20 ++ tests/Stub/Alias/CloneEarlyDonor.php | 26 ++ tests/Stub/Alias/CloneGrandChild.php | 21 ++ tests/Stub/Alias/CloneOverrideBase.php | 27 ++ tests/Stub/Alias/CloneOverrideChild.php | 21 ++ tests/Stub/Alias/CloneOverrideDonor.php | 26 ++ tests/Stub/Alias/CloneOverrideMid.php | 20 ++ 15 files changed, 667 insertions(+) create mode 100644 tests/Reflection/RedefineInterfaceCloneTest.php create mode 100644 tests/Stub/Alias/CloneAliasBase.php create mode 100644 tests/Stub/Alias/CloneAliasChild.php create mode 100644 tests/Stub/Alias/CloneAliasDonor.php create mode 100644 tests/Stub/Alias/CloneBase.php create mode 100644 tests/Stub/Alias/CloneChild.php create mode 100644 tests/Stub/Alias/CloneContract.php create mode 100644 tests/Stub/Alias/CloneEarlyBase.php create mode 100644 tests/Stub/Alias/CloneEarlyChild.php create mode 100644 tests/Stub/Alias/CloneEarlyDonor.php create mode 100644 tests/Stub/Alias/CloneGrandChild.php create mode 100644 tests/Stub/Alias/CloneOverrideBase.php create mode 100644 tests/Stub/Alias/CloneOverrideChild.php create mode 100644 tests/Stub/Alias/CloneOverrideDonor.php create mode 100644 tests/Stub/Alias/CloneOverrideMid.php diff --git a/tests/Reflection/RedefineInterfaceCloneTest.php b/tests/Reflection/RedefineInterfaceCloneTest.php new file mode 100644 index 00000000..eb182bae --- /dev/null +++ b/tests/Reflection/RedefineInterfaceCloneTest.php @@ -0,0 +1,332 @@ + + * + * 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\Reflection; + +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\OpCache\SharedMemoryException; +use ZEngine\Stub\Alias\CloneAliasBase; +use ZEngine\Stub\Alias\CloneAliasChild; +use ZEngine\Stub\Alias\CloneAliasDonor; +use ZEngine\Stub\Alias\CloneBase; +use ZEngine\Stub\Alias\CloneChild; +use ZEngine\Stub\Alias\CloneContract; +use ZEngine\Stub\Alias\CloneEarlyBase; +use ZEngine\Stub\Alias\CloneEarlyChild; +use ZEngine\Stub\Alias\CloneEarlyDonor; +use ZEngine\Stub\Alias\CloneGrandChild; +use ZEngine\Stub\Alias\CloneOverrideBase; +use ZEngine\Stub\Alias\CloneOverrideChild; +use ZEngine\Stub\Alias\CloneOverrideDonor; +use ZEngine\Stub\Alias\CloneOverrideMid; + +/** + * The engine CLONE of an inherited method entry held by a subclass implementing an interface + * that declares the method, and the operations that must reach it: redefine() of the ancestor + * method, its preserved alias, an addMethod() override on an intermediate class, and the + * shared-memory guards of both publications + * + * Inheritance publishes the parent's zend_function POINTER into a subclass method table, so an + * in-place body swap of the parent entry is visible in every subclass at once. One inheritance + * shape does not share the pointer: when the subclass implements an interface declaring the + * inherited method, do_inheritance_check_on_method copies the inherited entry into an + * arena-allocated struct of its own - same scope and name, the interface method as prototype, + * the body and the cache pointer of that moment - and publishes THAT in the subclass table + * (subclasses of the subclass then share the clone's pointer, the ordinary way). Before the fix + * such a clone was invisible to z-engine, which enumerated holders by pointer identity: the + * subclass kept the previous body after a redefine() of the ancestor method (and kept reading + * the run-time cache the next swap freed), an addMethod() override published on an intermediate + * class left it dispatching the ancestor body, a preserved alias never reached its class, and + * the shared-memory guards let an opcache-shared ancestor be copied out from under it. Every + * scenario here pins the dispatch results and the engine state: the identity of the clone + * (FunctionBodySwap::cloneHolders()), of its body and of its publication. + * + * Every scenario owns its stub classes (tests/Stub/Alias/Clone*): a redefinition changes the + * class for the rest of the process. The scenario whose subclass links AFTER the first swap + * holds in both runner modes (the ancestor is already copied out of shared memory when the + * subclass links against it); where "linked before" is the subject, an opcache-shared ancestor + * either refuses the publication with SharedMemoryException or - for a plain body swap - is + * copied out while the subclass keeps the shared parent and the clone of the previous body + * (the copy-out caveats of docs/hot-swap.md), and the scenario pins that outcome instead. + */ +class RedefineInterfaceCloneTest extends TestCase +{ + public function testCloneOfASubclassLinkedAfterTheFirstSwapFollowsTheNextOne(): void + { + $entry = (new ReflectionClass(CloneBase::class))->getMethod('m'); + $entry->redefine(fn(string $who): string => 'v1:' . strtoupper($who)); + // Re-read after the swap: under opcache the first swap copied the class out of SHM + $entry = (new ReflectionClass(CloneBase::class))->getMethod('m'); + self::assertSame('v1:A', (new CloneBase())->m('a')); + + // Autoloaded now, i.e. linked against the redefined ancestor: the interface prototype + // makes the engine separate the inherited bucket into a struct copy of the entry, and + // the grandchild inherits that copy's pointer at its own link time + $child = new CloneChild(); + $grandChild = new CloneGrandChild(); + self::assertSame('v1:B', $child->m('b')); + self::assertSame('v1:C', $grandChild->m('c')); + + self::assertSame([CloneBase::class], self::holderNames($entry), 'neither subclass table holds the entry pointer'); + $clone = self::cloneHeldBy($entry, CloneChild::class); + $cloneAddress = $clone->getAddress(); + self::assertNotSame($entry->getAddress(), $cloneAddress, 'the subclass publishes a struct copy of the entry'); + self::assertSame((new ReflectionClass(CloneBase::class))->getAddress(), self::scopeAddress($clone), 'the copy keeps the declaring scope'); + self::assertSame((new ReflectionClass(CloneContract::class))->getMethod('m')->getAddress(), self::prototypeAddress($clone), 'the interface method is its prototype'); + self::assertSame(self::opcodesAddress($entry), self::opcodesAddress($clone), 'the copy shares the body of the moment it was made'); + self::assertSame( + [CloneChild::class, CloneGrandChild::class], + self::holderNames($clone), + 'the grandchild shares the clone pointer: a swap of the clone owes two body shares', + ); + self::assertSame($cloneAddress, (new ReflectionClass(CloneGrandChild::class))->getMethod('m')->getAddress()); + + // The second swap of the entry: before the fix the clone was not a holder, kept the + // previous body and the released run-time cache (use-after-free), so both subclasses + // stayed on v1 while the ancestor dispatched v2 + $entry->redefine(fn(string $who): string => 'v2:' . strtolower($who) . str_repeat('!', 2)); + self::assertSame('v2:a!!', (new CloneBase())->m('A')); + self::assertSame('v2:b!!', $child->m('B'), 'the subclass dispatches the new body through its clone'); + self::assertSame('v2:c!!', $grandChild->m('C'), 'and so does the grandchild sharing the clone'); + self::assertSame('v2:d!!', (new CloneChild())->m('D')); + + $clone = self::cloneHeldBy($entry, CloneChild::class); + self::assertSame($cloneAddress, $clone->getAddress(), 'the clone was swapped in place: the subclass buckets are untouched'); + self::assertSame($cloneAddress, (new ReflectionClass(CloneGrandChild::class))->getMethod('m')->getAddress()); + self::assertSame(self::opcodesAddress($entry), self::opcodesAddress($clone), 'the clone executes the entry\'s new body'); + self::assertSame((new ReflectionClass(CloneBase::class))->getAddress(), self::scopeAddress($clone), 'the declaration identity survives the swap'); + self::assertSame((new ReflectionClass(CloneContract::class))->getMethod('m')->getAddress(), self::prototypeAddress($clone)); + self::assertNotSame(self::cacheAddress($entry), self::cacheAddress($clone), 'the clone reads a minted cache of its own, not the entry\'s'); + self::assertSame(0, $clone->getCommonPointer()->fn_flags & Core::ZEND_ACC_HEAP_RT_CACHE, 'a cache shared by two tables is never heap-flagged'); + $native = new \ReflectionMethod(CloneChild::class, 'm'); + self::assertSame(CloneBase::class, $native->getDeclaringClass()->getName()); + self::assertSame(CloneContract::class, $native->getPrototype()->getDeclaringClass()->getName()); + + // Warmed inline caches of the clone keep working on the swapped-in body + $dispatched = ''; + for ($i = 0; $i < 300; $i++) { + $dispatched = (new CloneChild())->m('z') . (new CloneGrandChild())->m('y'); + } + self::assertSame('v2:z!!v2:y!!', $dispatched); + } + + public function testCloneOfASubclassLinkedBeforeFollowsTheRedefinition(): void + { + // Linked now, before the redefinition + $child = new CloneEarlyChild(); + self::assertSame('base:a', $child->m('a')); + $class = new ReflectionClass(CloneEarlyBase::class); + $entry = $class->getMethod('m'); + + $clone = self::cloneHeldBy($entry, CloneEarlyChild::class); + $cloneAddress = $clone->getAddress(); + self::assertNotSame($entry->getAddress(), $cloneAddress); + self::assertSame(self::opcodesAddress($entry), self::opcodesAddress($clone)); + + $isSharedMemoryClass = $class->isImmutable(); + $entry->redefine((new ReflectionClass(CloneEarlyDonor::class))->getMethod('m')); + self::assertSame('v1:A', (new CloneEarlyBase())->m('a')); + + if ($isSharedMemoryClass) { + // The declaring class was copied out of shared memory: a subclass linked against + // the shared entry keeps the shared parent, and with it the clone of the previous + // body, whose scope is the shared class - not a clone of the copy + self::assertSame('base:b', $child->m('b'), 'the copy-out does not reach a subclass linked before'); + self::assertSame([], FunctionBodySwap::cloneHolders((new ReflectionClass(CloneEarlyBase::class))->getMethod('m'))); + self::assertSame($cloneAddress, (new ReflectionClass(CloneEarlyChild::class))->getMethod('m')->getAddress()); + + return; + } + + // Before the fix only the pointer-sharing buckets followed: the subclass kept 'base:' + self::assertSame('v1:B', $child->m('b'), 'the clone linked before follows the swap of the entry'); + self::assertSame('v1:C', (new CloneEarlyChild())->m('c')); + + $clone = self::cloneHeldBy($entry, CloneEarlyChild::class); + self::assertSame($cloneAddress, $clone->getAddress(), 'swapped in place: the subclass bucket is untouched'); + self::assertSame(self::opcodesAddress($entry), self::opcodesAddress($clone), 'the clone executes the donor body'); + self::assertNotSame(self::cacheAddress($entry), self::cacheAddress($clone), 'with a minted cache of its own'); + // The donor class bucket, the entry bucket and the clone bucket each own one share of + // the body: fewer would free it early at shutdown, more would leak it + $refCountPointer = $clone->getOpArrayPointer()->refcount; + self::assertNotNull($refCountPointer, 'a runtime donor body is refcounted'); + self::assertSame(3, $refCountPointer[0]); + self::assertSame(Core::addressOf($refCountPointer), Core::addressOf(self::refCountCell($entry)), 'one body, one counter cell'); + } + + public function testPreservedAliasReachesTheCloneHolderLinkedBefore(): void + { + // Linked now: the only subclass is the one holding a clone, which the pointer-identity + // guard of the old code did not see + $child = new CloneAliasChild(); + self::assertSame('base:a', $child->m('a')); + $class = new ReflectionClass(CloneAliasBase::class); + self::assertSame([CloneAliasBase::class], self::holderNames($class->getMethod('m'))); + self::assertSame(CloneAliasChild::class, self::cloneHolderNames($class->getMethod('m'))[0] ?? null); + + if ($class->isImmutable()) { + // The subclass linked against the shared-memory entry could never see the alias + // the swapped-in body calls: the guard refuses before anything is copied out + $this->expectException(SharedMemoryException::class); + $this->expectExceptionMessageMatches('/subclass .*CloneAliasChild was linked against the shared entry/'); + } + $class->getMethod('m')->redefine((new ReflectionClass(CloneAliasDonor::class))->getMethod('m'), preserveAs: 'mOriginal'); + + self::assertSame('[base:a]', (new CloneAliasBase())->m('a')); + // The clone dispatches the new body on the subclass instance, and that body resolves + // the private alias in the SUBCLASS table (zend_std_get_method looks there first) + self::assertSame('[base:b]', $child->m('b'), 'the swapped-in body reaches the alias on a subclass instance'); + self::assertSame('[base:c]', (new CloneAliasChild())->m('c')); + + self::assertTrue((new \ReflectionClass(CloneAliasChild::class))->hasMethod('mOriginal'), 'the alias was shared into the subclass table'); + $alias = (new ReflectionClass(CloneAliasBase::class))->getMethod('mOriginal'); + self::assertSame([CloneAliasBase::class, CloneAliasChild::class], self::holderNames($alias), 'one alias container, published by both tables'); + $native = new \ReflectionMethod(CloneAliasChild::class, 'mOriginal'); + self::assertTrue($native->isPrivate()); + self::assertSame(CloneAliasBase::class, $native->getDeclaringClass()->getName()); + self::assertSame('base:d', $native->invoke($child, 'd'), 'the preserved body runs on the subclass instance'); + } + + public function testOverrideOnAnIntermediateClassReplacesTheCloneLinkedBefore(): void + { + // The whole chain is linked now: the middle class shares the ancestor's entry pointer, + // the leaf implementing the interface holds a clone of it + $child = new CloneOverrideChild(); + self::assertSame('base:a', $child->m('a')); + $middle = new ReflectionClass(CloneOverrideMid::class); + $baseEntry = (new ReflectionClass(CloneOverrideBase::class))->getMethod('m'); + self::assertSame([CloneOverrideBase::class, CloneOverrideMid::class], self::holderNames($baseEntry)); + self::assertSame([CloneOverrideChild::class], self::cloneHolderNames($baseEntry)); + + if ($middle->isImmutable()) { + // The leaf linked against the shared entry of the middle class would keep inheriting + // the ancestor's declaration: the guard counts the clone holder like a pointer holder + $this->expectException(SharedMemoryException::class); + $this->expectExceptionMessageMatches('/subclass .*CloneOverrideChild was linked against the shared entry/'); + } + $published = $middle->addMethod('m', (new ReflectionClass(CloneOverrideDonor::class))->getMethod('m')); + + self::assertSame('over+base:b', (new CloneOverrideMid())->m('b')); + // Before the fix the leaf kept its clone of the ancestor entry and answered 'base:c' + self::assertSame('over+base:c', $child->m('c'), 'the leaf linked before dispatches the override'); + self::assertSame('over+base:d', (new CloneOverrideChild())->m('d')); + self::assertSame('base:e', (new CloneOverrideBase())->m('e'), 'the ancestor is untouched'); + + // The clone bucket was replaced by the published override itself (zend_duplicate_function + // semantics, like a pointer-sharing bucket), so no clone of the ancestor entry remains + self::assertSame($published->getAddress(), (new ReflectionClass(CloneOverrideChild::class))->getMethod('m')->getAddress()); + self::assertSame([CloneOverrideChild::class, CloneOverrideMid::class], self::holderNames($published)); + self::assertSame([], self::cloneHolderNames($baseEntry)); + self::assertSame([CloneOverrideBase::class], self::holderNames($baseEntry), 'the ancestor keeps its own bucket only'); + $native = new \ReflectionMethod(CloneOverrideChild::class, 'm'); + self::assertSame(CloneOverrideMid::class, $native->getDeclaringClass()->getName()); + self::assertSame(CloneOverrideBase::class, $native->getPrototype()->getDeclaringClass()->getName(), 'the override keeps the inherited prototype'); + } + + /** + * The clone of $entry published in the method table of $holderClass (see FunctionBodySwap::cloneHolders()) + */ + private static function cloneHeldBy(FunctionLikeInterface $entry, string $holderClass): ReflectionMethod + { + foreach (FunctionBodySwap::cloneHolders($entry) as [$holder, $clone]) { + if ($holder->getName() === $holderClass) { + return $clone; + } + } + self::fail("{$holderClass} holds no clone of the entry"); + } + + /** + * Sorted names of the classes whose method tables publish the entry pointer itself + * + * @return list + */ + private static function holderNames(FunctionLikeInterface $entry): array + { + $names = array_map( + static fn(ReflectionClass $holder): string => $holder->getName(), + FunctionBodySwap::publishedShareHolders($entry), + ); + sort($names); + + return $names; + } + + /** + * Sorted names of the classes whose method tables publish a clone of the entry + * + * @return list + */ + private static function cloneHolderNames(FunctionLikeInterface $entry): array + { + $names = array_map( + static fn(array $holderAndClone): string => $holderAndClone[0]->getName(), + FunctionBodySwap::cloneHolders($entry), + ); + sort($names); + + return $names; + } + + private static function scopeAddress(FunctionLikeInterface $function): int + { + $scope = $function->getCommonPointer()->scope; + self::assertNotNull($scope, 'a method entry always carries its declaring scope'); + + return Core::addressOf($scope); + } + + private static function prototypeAddress(FunctionLikeInterface $function): int + { + $prototype = $function->getCommonPointer()->prototype; + self::assertNotNull($prototype, 'the entry is expected to carry a prototype'); + + return Core::addressOf($prototype); + } + + /** + * Address of the opcodes array: two entries with the same address execute the same body + */ + private static function opcodesAddress(FunctionLikeInterface $function): int + { + $opcodes = $function->getOpArrayPointer()->opcodes; + self::assertNotNull($opcodes, 'a user function always has opcodes'); + + return Core::addressOf($opcodes); + } + + /** + * Address of the run-time cache block a user function reads its inline caches from + */ + private static function cacheAddress(FunctionLikeInterface $function): int + { + $cache = $function->getOpArrayPointer()->run_time_cache__ptr; + self::assertNotNull($cache, 'a swapped entry is never left without a run-time cache'); + + return Core::addressOf($cache); + } + + /** + * The body refcount cell of a refcounted (runtime, non-SHM) user function body + * + * @return \FFI\CData + */ + private static function refCountCell(FunctionLikeInterface $function): object + { + $refCountPointer = $function->getOpArrayPointer()->refcount; + self::assertNotNull($refCountPointer, 'a runtime body is refcounted'); + + return $refCountPointer; + } +} diff --git a/tests/Stub/Alias/CloneAliasBase.php b/tests/Stub/Alias/CloneAliasBase.php new file mode 100644 index 00000000..51b5224b --- /dev/null +++ b/tests/Stub/Alias/CloneAliasBase.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Target of RedefineInterfaceCloneTest::testPreservedAliasReachesTheCloneHolderLinkedBefore: + * m() is redefined with a preserved alias after CloneAliasChild linked + */ +class CloneAliasBase +{ + public function m(string $who): string + { + return 'base:' . $who; + } +} diff --git a/tests/Stub/Alias/CloneAliasChild.php b/tests/Stub/Alias/CloneAliasChild.php new file mode 100644 index 00000000..73c0b5a0 --- /dev/null +++ b/tests/Stub/Alias/CloneAliasChild.php @@ -0,0 +1,21 @@ + + * + * 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\Stub\Alias; + +/** + * Linked BEFORE CloneAliasBase::m() is redefined with a preserved alias: holds an engine clone + * of the inherited entry, and must receive the alias the preserved body calls + * (owned by RedefineInterfaceCloneTest::testPreservedAliasReachesTheCloneHolderLinkedBefore) + */ +class CloneAliasChild extends CloneAliasBase implements CloneContract {} diff --git a/tests/Stub/Alias/CloneAliasDonor.php b/tests/Stub/Alias/CloneAliasDonor.php new file mode 100644 index 00000000..fc3f9b45 --- /dev/null +++ b/tests/Stub/Alias/CloneAliasDonor.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for CloneAliasBase::m(), delegating to the preserved alias + * (owned by RedefineInterfaceCloneTest::testPreservedAliasReachesTheCloneHolderLinkedBefore) + * + * @method string mOriginal(string $who) the preserved CloneAliasBase::m() body + */ +class CloneAliasDonor +{ + public function m(string $who): string + { + return '[' . $this->mOriginal($who) . ']'; + } +} diff --git a/tests/Stub/Alias/CloneBase.php b/tests/Stub/Alias/CloneBase.php new file mode 100644 index 00000000..dffa37f8 --- /dev/null +++ b/tests/Stub/Alias/CloneBase.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Target of RedefineInterfaceCloneTest::testCloneOfASubclassLinkedAfterTheFirstSwapFollowsTheNextOne: + * m() is redefined twice, with CloneChild (and CloneGrandChild) linked in between + */ +class CloneBase +{ + public function m(string $who): string + { + return 'base:' . $who; + } +} diff --git a/tests/Stub/Alias/CloneChild.php b/tests/Stub/Alias/CloneChild.php new file mode 100644 index 00000000..fa9454c3 --- /dev/null +++ b/tests/Stub/Alias/CloneChild.php @@ -0,0 +1,21 @@ + + * + * 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\Stub\Alias; + +/** + * Autoloaded AFTER the first redefinition of CloneBase::m(): implementing CloneContract makes + * its method table hold an engine clone of the inherited entry, not the entry pointer + * (owned by RedefineInterfaceCloneTest::testCloneOfASubclassLinkedAfterTheFirstSwapFollowsTheNextOne) + */ +class CloneChild extends CloneBase implements CloneContract {} diff --git a/tests/Stub/Alias/CloneContract.php b/tests/Stub/Alias/CloneContract.php new file mode 100644 index 00000000..14c12070 --- /dev/null +++ b/tests/Stub/Alias/CloneContract.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Interface declaring the method the Clone* subclasses inherit: an interface prototype makes + * the engine separate an inherited bucket into a struct copy of the ancestor entry at link time + * (do_inheritance_check_on_method) instead of sharing the entry pointer + * + * Shared by every scenario of RedefineInterfaceCloneTest: an interface is never redefined + */ +interface CloneContract +{ + public function m(string $who): string; +} diff --git a/tests/Stub/Alias/CloneEarlyBase.php b/tests/Stub/Alias/CloneEarlyBase.php new file mode 100644 index 00000000..a975fc05 --- /dev/null +++ b/tests/Stub/Alias/CloneEarlyBase.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Target of RedefineInterfaceCloneTest::testCloneOfASubclassLinkedBeforeFollowsTheRedefinition: + * m() is redefined (no alias) after CloneEarlyChild linked + */ +class CloneEarlyBase +{ + public function m(string $who): string + { + return 'base:' . $who; + } +} diff --git a/tests/Stub/Alias/CloneEarlyChild.php b/tests/Stub/Alias/CloneEarlyChild.php new file mode 100644 index 00000000..be8241e3 --- /dev/null +++ b/tests/Stub/Alias/CloneEarlyChild.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Linked BEFORE CloneEarlyBase::m() is redefined: holds an engine clone of the inherited entry + * (owned by RedefineInterfaceCloneTest::testCloneOfASubclassLinkedBeforeFollowsTheRedefinition) + */ +class CloneEarlyChild extends CloneEarlyBase implements CloneContract {} diff --git a/tests/Stub/Alias/CloneEarlyDonor.php b/tests/Stub/Alias/CloneEarlyDonor.php new file mode 100644 index 00000000..38b444cb --- /dev/null +++ b/tests/Stub/Alias/CloneEarlyDonor.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for CloneEarlyBase::m() + * (owned by RedefineInterfaceCloneTest::testCloneOfASubclassLinkedBeforeFollowsTheRedefinition) + */ +class CloneEarlyDonor +{ + public function m(string $who): string + { + return 'v1:' . strtoupper($who); + } +} diff --git a/tests/Stub/Alias/CloneGrandChild.php b/tests/Stub/Alias/CloneGrandChild.php new file mode 100644 index 00000000..ae360e9a --- /dev/null +++ b/tests/Stub/Alias/CloneGrandChild.php @@ -0,0 +1,21 @@ + + * + * 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\Stub\Alias; + +/** + * Autoloaded after CloneChild: inherits the clone of CloneBase::m() held by CloneChild as a + * shared pointer, the ordinary inheritance shape + * (owned by RedefineInterfaceCloneTest::testCloneOfASubclassLinkedAfterTheFirstSwapFollowsTheNextOne) + */ +class CloneGrandChild extends CloneChild {} diff --git a/tests/Stub/Alias/CloneOverrideBase.php b/tests/Stub/Alias/CloneOverrideBase.php new file mode 100644 index 00000000..9067a083 --- /dev/null +++ b/tests/Stub/Alias/CloneOverrideBase.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Root of the override scenario: declares the m() that CloneOverrideMid inherits and + * CloneOverrideChild holds a clone of + * (owned by RedefineInterfaceCloneTest::testOverrideOnAnIntermediateClassReplacesTheCloneLinkedBefore) + */ +class CloneOverrideBase +{ + public function m(string $who): string + { + return 'base:' . $who; + } +} diff --git a/tests/Stub/Alias/CloneOverrideChild.php b/tests/Stub/Alias/CloneOverrideChild.php new file mode 100644 index 00000000..af783084 --- /dev/null +++ b/tests/Stub/Alias/CloneOverrideChild.php @@ -0,0 +1,21 @@ + + * + * 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\Stub\Alias; + +/** + * Linked BEFORE the override is published on CloneOverrideMid: holds an engine clone of + * CloneOverrideBase::m(), which the override must replace + * (owned by RedefineInterfaceCloneTest::testOverrideOnAnIntermediateClassReplacesTheCloneLinkedBefore) + */ +class CloneOverrideChild extends CloneOverrideMid implements CloneContract {} diff --git a/tests/Stub/Alias/CloneOverrideDonor.php b/tests/Stub/Alias/CloneOverrideDonor.php new file mode 100644 index 00000000..e81ddbb6 --- /dev/null +++ b/tests/Stub/Alias/CloneOverrideDonor.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Donor for the CloneOverrideMid::m() override, reaching the base body through parent:: + * (owned by RedefineInterfaceCloneTest::testOverrideOnAnIntermediateClassReplacesTheCloneLinkedBefore) + */ +class CloneOverrideDonor extends CloneOverrideBase +{ + public function m(string $who): string + { + return 'over+' . parent::m($who); + } +} diff --git a/tests/Stub/Alias/CloneOverrideMid.php b/tests/Stub/Alias/CloneOverrideMid.php new file mode 100644 index 00000000..84ce7b15 --- /dev/null +++ b/tests/Stub/Alias/CloneOverrideMid.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Middle of the chain: receives the addMethod() override of the inherited m() + * (owned by RedefineInterfaceCloneTest::testOverrideOnAnIntermediateClassReplacesTheCloneLinkedBefore) + */ +class CloneOverrideMid extends CloneOverrideBase {} From beb5d51672442d3019ad27308154f007f340da84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:26:38 +0000 Subject: [PATCH 13/14] perf(reflection): survey the tables once per swap instead of once per question A body swap used to walk the whole class table for every question it asked about its entry (its published shares, its clones, the shares of each clone, and again at each commit for the cache release and the statics checks) and the object store once per check, building a reflection wrapper per visited class: about 190 ms per redefine() in a process holding 1,800 classes, with the swap tests pacing their plateau loops around it. EntryPublications takes one survey per redefinition - the buckets named like the entry, read through an integer view of the class table (HashTable::pointerValueAddresses(), the technique ObjectStore already uses) with one method lookup per class and no wrapper until a class actually publishes the name - and answers every question of the entry swap, the clone swaps and their commits from memory. A commit gathers the live closures once and takes every decision that reads them before anything is destroyed. The survey keeps addresses only, so the object-store walk does not pay for its own bookkeeping. MethodPublisher reaches the linked subclasses through ReflectionClass::getLinkedSubclasses(), the same walk with a parent-chain check instead of a wrapper per class. Each clone is listed once, with the class the engine separated it for (found by ancestry: table order lies after delayed early binding), so a redefinition swaps a clone shared into a grand-subclass once instead of once per table publishing it. Same 1,800-class process: a redefine() takes 5 ms with 1,500 subclasses sharing the entry and 2 ms among unrelated classes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- docs/hot-swap.md | 10 +- src/HotSwap/CacheImageSync.php | 2 - src/HotSwap/ClassDelta.php | 1 - src/Reflection/EntryPublications.php | 217 ++++++++++++++++ src/Reflection/FunctionBodySwap.php | 277 ++++++++++----------- src/Reflection/FunctionLikeTrait.php | 15 +- src/Reflection/MethodPublisher.php | 37 +-- src/Reflection/PendingBodySwap.php | 8 +- src/Reflection/ReflectionClass.php | 79 ++++++ src/Type/HashTable.php | 50 ++++ tests/Reflection/EntryPublicationsTest.php | 139 +++++++++++ tests/Stub/Alias/SurveyBase.php | 25 ++ tests/Stub/Alias/SurveyChild.php | 19 ++ tests/Stub/Alias/SurveyContract.php | 22 ++ tests/Stub/Alias/SurveyGrandChild.php | 19 ++ tests/Stub/Alias/SurveyPlainChild.php | 19 ++ tests/Stub/Alias/SurveyUnrelated.php | 25 ++ 17 files changed, 765 insertions(+), 199 deletions(-) create mode 100644 src/Reflection/EntryPublications.php create mode 100644 tests/Reflection/EntryPublicationsTest.php create mode 100644 tests/Stub/Alias/SurveyBase.php create mode 100644 tests/Stub/Alias/SurveyChild.php create mode 100644 tests/Stub/Alias/SurveyContract.php create mode 100644 tests/Stub/Alias/SurveyGrandChild.php create mode 100644 tests/Stub/Alias/SurveyPlainChild.php create mode 100644 tests/Stub/Alias/SurveyUnrelated.php diff --git a/docs/hot-swap.md b/docs/hot-swap.md index 87de7b91..2bc0eac3 100644 --- a/docs/hot-swap.md +++ b/docs/hot-swap.md @@ -192,8 +192,14 @@ are found and detached (`FunctionBodySwap::releaseRetiredRunTimeCache()`): bounded to one block per such in-flight redefinition, like the previous body itself. -Finding those copies walks `EG(objects_store)` (`ObjectStore::instancesOf()`) -and the class table once per released block. +Finding those copies costs one walk over `EG(objects_store)` +(`ObjectStore::instancesOf()`) per committed swap and one walk over the class +table per redefinition (`EntryPublications`, the survey that also answers how +many tables share the entry and which subclasses hold a clone of it). Both +walks read the engine tables through integer views instead of building a +wrapper per entry, so a swap in a process holding thousands of classes stays +in the low milliseconds; a redefinition only pays per class that actually +publishes the method's name. ## Class hot-swap (`HotSwap` / `ClassDelta`) diff --git a/src/HotSwap/CacheImageSync.php b/src/HotSwap/CacheImageSync.php index 76fbf8c9..afa0cd78 100644 --- a/src/HotSwap/CacheImageSync.php +++ b/src/HotSwap/CacheImageSync.php @@ -237,7 +237,6 @@ public function apply(): CacheImageSyncReport preserveDeclaration: true, // 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) { @@ -256,7 +255,6 @@ public function apply(): CacheImageSyncReport $donor->getDonor(), preserveDeclaration: true, destroyPrevious: !$this->classWasShared[$classKey], - publishedShares: FunctionBodySwap::countPublishedShares($entryMethod), ); } } diff --git a/src/HotSwap/ClassDelta.php b/src/HotSwap/ClassDelta.php index bea57326..83aeaefe 100644 --- a/src/HotSwap/ClassDelta.php +++ b/src/HotSwap/ClassDelta.php @@ -314,7 +314,6 @@ public function apply(): void // The new source is authoritative for the declaration as well preserveDeclaration: false, destroyPrevious: true, - publishedShares: FunctionBodySwap::countPublishedShares($entry), ); $pendingSwaps[] = $pending; $undoStack[] = static function () use ($pending): void { diff --git a/src/Reflection/EntryPublications.php b/src/Reflection/EntryPublications.php new file mode 100644 index 00000000..47d7f4e1 --- /dev/null +++ b/src/Reflection/EntryPublications.php @@ -0,0 +1,217 @@ + + * + * 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\Reflection; + +use FFI\CData; +use ZEngine\Core; +use ZEngine\Generated\zend_class_entry; +use ZEngine\Generated\zend_function; +use ZEngine\Type\StringEntry; + +/** + * The method-table buckets of every linked user class that carry the name of one entry, + * surveyed in a single class-table walk + * + * A body swap asks the same questions about its entry several times over: how many table + * buckets publish the very structure (the body shares its accounting must mirror), which + * subclasses hold an engine clone of it instead (clones follow the swap explicitly), and + * the same again for every clone and once more when the swap is committed or rolled back. + * Each answer used to cost a walk over the class table; one survey answers all of them for + * the whole swap. + * + * A survey is a snapshot of the tables as they were when it was taken, which is exactly + * what the accounting of a swap needs: the shares and clones of the PREVIOUS body are + * frozen the moment the entry switches to the new one (a class linked afterwards inherits + * or clones the new body), and nothing links a class between the survey and the swaps it + * serves - the destructors a commit may run come after every decision that reads it. + */ +final class EntryPublications +{ + /** + * The survey keeps addresses only and builds the reflection views on demand: a wrapper + * per published bucket would stay alive for the whole swap, and the object-store walk a + * commit makes to find the live closures pays for every live object. + * + * @param list $buckets Address of the holder class entry, of the + * bucket's function struct, and of that function's scope class entry + */ + private function __construct(private readonly array $buckets) {} + + /** + * Surveys the class table for the buckets named like $entryFunction (a global function + * entry is published in no class table: its survey is empty) + */ + public static function survey(FunctionLikeInterface $entryFunction): self + { + $entryCommon = $entryFunction->getCommonPointer(); + if ($entryCommon->scope === null) { + return new self([]); + } + $namePointer = $entryCommon->function_name; + assert($namePointer instanceof CData); + // One engine string keys every lookup, and the walk - which visits every linked + // class - builds a method view for the classes publishing the name only + $lowerName = new StringEntry(strtolower(StringEntry::fromCData($namePointer)->getStringValue())); + + $buckets = []; + foreach (Core::$executor->classTable->pointerValueAddresses() as $classAddress) { + $bucketFunction = ReflectionClass::findUserMethodAt($classAddress, $lowerName); + if ($bucketFunction === null) { + continue; + } + $bucketScope = $bucketFunction->getCommonPointer()->scope; + $buckets[] = [ + $classAddress, + $bucketFunction->getAddress(), + $bucketScope === null ? null : Core::addressOf($bucketScope), + ]; + } + + return new self($buckets); + } + + /** + * The classes whose method table publishes the very structure of $function: its declaring + * class and every subclass that inherited the pointer at link time (zend_duplicate_function + * shares the structure and takes one body reference per bucket). A structure published in + * no class table has no holders; a subclass holding an engine clone instead of the pointer + * is not a holder, see clonesOf(). + * + * @return list + */ + public function holdersOf(FunctionLikeInterface $function): array + { + $address = $function->getAddress(); + $holders = []; + foreach ($this->buckets as [$classAddress, $bucketAddress]) { + if ($bucketAddress === $address) { + $holders[] = self::classAt($classAddress); + } + } + + return $holders; + } + + /** + * The body shares the published buckets of $function account for: one per holder, and one + * for a structure published in no class table (a plain function entry) + */ + public function sharesOf(FunctionLikeInterface $function): int + { + $address = $function->getAddress(); + $shares = 0; + foreach ($this->buckets as [, $bucketAddress]) { + if ($bucketAddress === $address) { + $shares++; + } + } + + return max($shares, 1); + } + + /** + * The engine clones of $function, each with the class holding it + * + * Inheritance shares the parent's zend_function POINTER into a subclass table (see + * holdersOf()) - except when the subclass implements an interface that declares the + * method: the engine then separates the inherited entry into a struct copy of its own in + * the subclass table (do_inheritance_check_on_method), carrying the entry's scope and name, + * a prototype of its own and a share on the body the entry had at link time. Such a clone + * follows neither an in-place swap of the entry nor its publications unless it is handled + * explicitly, which is what the swap machinery does with this list. Each clone is listed + * once, with the class the engine separated it for (its subclasses, linked after it, + * share the clone's pointer and are its holders). Surveyed for a clone instead of the + * entry, the list holds the clone's siblings: the other clones of the same entry. + * + * @return list + */ + public function clonesOf(FunctionLikeInterface $function): array + { + $scope = $function->getCommonPointer()->scope; + if ($scope === null) { + return []; + } + $scopeAddress = Core::addressOf($scope); + $address = $function->getAddress(); + + // The structure the declaring class publishes is the entry itself, wherever a subclass + // shares its pointer: never a clone - of the entry, or of a clone surveyed in its place + $declaredAddress = null; + foreach ($this->buckets as [$classAddress, $bucketAddress]) { + if ($classAddress === $scopeAddress) { + $declaredAddress = $bucketAddress; + break; + } + } + /** @var array> $publishers Clone struct address => addresses of the classes publishing it */ + $publishers = []; + foreach ($this->buckets as [$classAddress, $bucketAddress, $bucketScopeAddress]) { + if ($bucketAddress === $address || $bucketAddress === $declaredAddress || $bucketScopeAddress !== $scopeAddress) { + continue; + } + $publishers[$bucketAddress][] = $classAddress; + } + + $clones = []; + foreach ($publishers as $bucketAddress => $classAddresses) { + $clones[] = [ + self::separatingClass($classAddresses), + ReflectionMethod::fromRawEntry(Core::pointerAtAddress(zend_function::class, $bucketAddress)), + ]; + } + + return $clones; + } + + /** + * The class the engine separated a clone for, among the classes publishing it: the one no + * other publisher is an ancestor of (its subclasses inherit the clone's POINTER at their + * own link time, the ordinary way - one share of the clone each, see sharesOf()) + * + * Table order cannot tell: a class compiled before its parent was autoloaded keeps the + * bucket position of its compile-time (delayed early binding) key. + * + * @param list $classAddresses + */ + private static function separatingClass(array $classAddresses): ReflectionClass + { + $separating = null; + foreach ($classAddresses as $classAddress) { + $class = self::classAt($classAddress); + $descendsFromAPublisher = false; + for ($parent = $class->getParentClass(); $parent !== null; $parent = $parent->getParentClass()) { + if (in_array($parent->getAddress(), $classAddresses, true)) { + $descendsFromAPublisher = true; + break; + } + } + if (!$descendsFromAPublisher) { + $separating = $class; + break; + } + } + // A publishing class without a publishing ancestor always exists (the chain is finite) + assert($separating !== null); + + return $separating; + } + + /** + * The reflection view of the class entry at the given address + */ + private static function classAt(int $classAddress): ReflectionClass + { + return ReflectionClass::fromCData(Core::pointerAtAddress(zend_class_entry::class, $classAddress)); + } +} diff --git a/src/Reflection/FunctionBodySwap.php b/src/Reflection/FunctionBodySwap.php index 9188fce4..6e4b9d03 100644 --- a/src/Reflection/FunctionBodySwap.php +++ b/src/Reflection/FunctionBodySwap.php @@ -130,8 +130,9 @@ private function __construct() {} * keeps its own function name and class scope. * @param bool $destroyPrevious False when the previous body must stay allocated on commit * (opcache shared memory is never freed) - * @param int $publishedShares Number of table buckets pointing at this entry structure, - * see countPublishedShares() + * @param EntryPublications|null $publications Survey of the tables publishing the entry (its + * shares and clones), taken by the caller when one survey + * serves several swaps; surveyed here when omitted * * @internal */ @@ -140,12 +141,16 @@ public static function swapUserFunctionBody( FunctionLikeInterface $donorFunction, bool $preserveDeclaration, bool $destroyPrevious = true, - int $publishedShares = 1, + ?EntryPublications $publications = null, ): PendingBodySwap { - assert($publishedShares >= 1); - $entry = $entryFunction->getEntryPointer(); - $donor = $donorFunction->getEntryPointer(); - $entryAddress = $entryFunction->getAddress(); + $publications ??= EntryPublications::survey($entryFunction); + // Subclass method tables may share this very structure (inheritance publishes the + // parent's pointer): every such bucket releases one body reference when the engine + // destroys it, so the swap mirrors that accounting on the new body + $publishedShares = $publications->sharesOf($entryFunction); + $entry = $entryFunction->getEntryPointer(); + $donor = $donorFunction->getEntryPointer(); + $entryAddress = $entryFunction->getAddress(); // Snapshot the previous body byte-exact: rollback restores it wholesale, commit // detaches the shared identity fields and destroys the rest @@ -196,34 +201,13 @@ public static function swapUserFunctionBody( $publishedShares, $destroyPrevious, $previousMintedCache, + $publications, ); } - /** - * Counts the published table buckets that point at the given method entry structure - * - * Inheritance publishes the parent's zend_function pointer into every subclass - * method table (zend_duplicate_function shares the structure and takes one body - * reference per bucket), so an in-place body swap must mirror that accounting. - * Class aliases resolve to the same class entry and are deduplicated; a structure - * published in no class table (a plain function entry) counts as one share. - * - * @internal - */ - public static function countPublishedShares(FunctionLikeInterface $entryFunction): int - { - return max(count(self::publishedShareHolders($entryFunction)), 1); - } - /** * Returns the user classes whose method tables publish the given entry structure - * under its own name: the declaring class and every subclass that inherited the - * entry at link time (see countPublishedShares() for the accounting this implies) - * - * A structure published in no class table (a plain function entry) has no holders. - * Class aliases resolve to the same class entry and are deduplicated. A subclass - * holding an engine CLONE of the entry instead of the pointer is not a holder, see - * cloneHolders(). + * under its own name, in a class-table walk of its own (see EntryPublications::holdersOf()) * * @return list * @@ -231,27 +215,13 @@ public static function countPublishedShares(FunctionLikeInterface $entryFunction */ public static function publishedShareHolders(FunctionLikeInterface $entryFunction): array { - $holders = []; - foreach (self::bucketsNamedLike($entryFunction) as [$reflectionClass, $bucketFunction]) { - if ($bucketFunction->getAddress() === $entryFunction->getAddress()) { - $holders[] = $reflectionClass; - } - } - - return $holders; + return EntryPublications::survey($entryFunction)->holdersOf($entryFunction); } /** * Returns the engine clones of a method entry published in the tables of its subclasses, - * each with the class holding it - * - * Inheritance shares the parent's zend_function POINTER into a subclass table (see - * publishedShareHolders()) - except when the subclass implements an interface that - * declares the method: the engine then separates the inherited entry into a struct copy - * of its own in the subclass table (do_inheritance_check_on_method), carrying the entry's - * scope and name, a prototype of its own and a share on the body the entry had at link - * time. Such a clone follows neither an in-place swap of the entry nor its publications - * unless it is handled explicitly, which is what the callers of this method do. + * each with the class holding it, in a class-table walk of its own (see + * EntryPublications::clonesOf()) * * @return list * @@ -259,65 +229,7 @@ public static function publishedShareHolders(FunctionLikeInterface $entryFunctio */ public static function cloneHolders(FunctionLikeInterface $entryFunction): array { - $entryScope = $entryFunction->getCommonPointer()->scope; - if ($entryScope === null) { - return []; - } - $scopeAddress = Core::addressOf($entryScope); - $entryAddress = $entryFunction->getAddress(); - - $clones = []; - foreach (self::bucketsNamedLike($entryFunction) as [$reflectionClass, $bucketFunction]) { - if ($bucketFunction->getAddress() === $entryAddress || $reflectionClass->getAddress() === $scopeAddress) { - continue; - } - $bucketScope = $bucketFunction->getCommonPointer()->scope; - if ($bucketScope !== null && Core::addressOf($bucketScope) === $scopeAddress) { - $clones[] = [$reflectionClass, $bucketFunction]; - } - } - - return $clones; - } - - /** - * Enumerates the method-table buckets of every user class that carry the entry's name - * - * @return iterable - */ - private static function bucketsNamedLike(FunctionLikeInterface $entryFunction): iterable - { - $entryCommon = $entryFunction->getCommonPointer(); - if ($entryCommon->scope === null) { - return; - } - $namePointer = $entryCommon->function_name; - assert($namePointer instanceof CData); - $lowerName = strtolower(StringEntry::fromCData($namePointer)->getStringValue()); - - $seenClasses = []; - foreach (Core::$executor->classTable as $classValue) { - try { - $rawClass = $classValue->getRawClass(); - } catch (\UnexpectedValueException $e) { - // Class alias buckets (IS_ALIAS_PTR) resolve to an already-counted entry - continue; - } - $classAddress = Core::addressOf($rawClass); - if (isset($seenClasses[$classAddress])) { - continue; - } - $seenClasses[$classAddress] = true; - - $reflectionClass = ReflectionClass::fromCData($rawClass); - if (!$reflectionClass->isUserDefined()) { - continue; - } - $bucketValue = $reflectionClass->getMethodTable()->find($lowerName); - if ($bucketValue !== null) { - yield [$reflectionClass, ReflectionMethod::fromRawEntry($bucketValue->getRawFunction())]; - } - } + return EntryPublications::survey($entryFunction)->clonesOf($entryFunction); } /** @@ -495,16 +407,25 @@ private static function installFreshRunTimeCache(FunctionLikeInterface $entryFun * Kept allocated while a frame of the entry still runs on it (the same rule as for * the previous body, see destroyPreviousBody()). * - * @param CData|null $previousMintedCache `void **` view recorded before the swap + * @param CData|null $previousMintedCache `void **` view recorded before the swap + * @param EntryPublications|null $publications Survey taken for the swap (see swapUserFunctionBody()) * * @internal called by PendingBodySwap::commit() */ - public static function releaseMintedRunTimeCache(FunctionLikeInterface $entryFunction, ?CData $previousMintedCache): void - { + public static function releaseMintedRunTimeCache( + FunctionLikeInterface $entryFunction, + ?CData $previousMintedCache, + ?EntryPublications $publications = null, + ): void { if ($previousMintedCache === null || self::hasLiveFrame($entryFunction->getAddress())) { return; } - self::releaseRetiredRunTimeCache($entryFunction, $previousMintedCache); + self::releaseRetiredRunTimeCache( + $entryFunction, + $previousMintedCache, + self::liveClosureFunctions(), + $publications ?? EntryPublications::survey($entryFunction), + ); } /** @@ -524,16 +445,25 @@ public static function releaseMintedRunTimeCache(FunctionLikeInterface $entryFun * private heap copy of its warmed slots, flagged ZEND_ACC_HEAP_RT_CACHE - what the engine * gives a closure over a heap-cached function, and freed with the closure by * destroy_op_array; - * - an engine clone of the entry in a subclass table (see cloneHolders()) gets a fresh - * minted cache of its own. + * - an engine clone of the entry in a subclass table (see EntryPublications::clonesOf()) + * gets a fresh minted cache of its own. + * + * Nothing here runs user code (no destructor can fire), so the closures gathered by the + * caller stay exactly the live ones throughout. + * + * @param list $liveClosures The functions of the live closures, see liveClosureFunctions() */ - private static function releaseRetiredRunTimeCache(FunctionLikeInterface $entryFunction, CData $cache): void - { + private static function releaseRetiredRunTimeCache( + FunctionLikeInterface $entryFunction, + CData $cache, + array $liveClosures, + EntryPublications $publications, + ): void { $cacheAddress = Core::addressOf($cache); if (self::hasLiveFrameUsingCache($cacheAddress)) { return; } - foreach (self::liveClosureFunctions() as $closureFunction) { + foreach ($liveClosures as $closureFunction) { $closureOpArray = $closureFunction->getOpArrayPointer(); $closureCache = $closureOpArray->run_time_cache__ptr; if ($closureCache === null || Core::addressOf($closureCache) !== $cacheAddress) { @@ -546,7 +476,7 @@ private static function releaseRetiredRunTimeCache(FunctionLikeInterface $entryF $closureCommon = $closureFunction->getCommonPointer(); $closureCommon->fn_flags = $closureCommon->fn_flags | Core::ZEND_ACC_HEAP_RT_CACHE; } - foreach (self::cloneHolders($entryFunction) as [, $clone]) { + foreach ($publications->clonesOf($entryFunction) as [, $clone]) { $cloneCache = $clone->getOpArrayPointer()->run_time_cache__ptr; if ($cloneCache !== null && Core::addressOf($cloneCache) === $cacheAddress) { self::installFreshRunTimeCache($clone); @@ -625,6 +555,7 @@ private static function detachLiveStatics(FunctionLikeInterface $entryFunction): * @param int $releasedShares Total bucket shares the entry held on the previous body * @param CData|null $previousMintedCache Run-time cache minted for the previous body by an * earlier swap, if any (see installFreshRunTimeCache()) + * @param EntryPublications|null $publications Survey taken for the swap (see swapUserFunctionBody()) * * @internal called by PendingBodySwap::commit() */ @@ -633,6 +564,7 @@ public static function destroyPreviousBody( object $previousBody, int $releasedShares, ?CData $previousMintedCache = null, + ?EntryPublications $publications = null, ): void { $entryAddress = $entryFunction->getAddress(); $previousFunction = ReflectionFunction::fromCData(Core::cast('zend_function *', Core::addr($previousBody))); @@ -647,20 +579,15 @@ public static function destroyPreviousBody( // redefinition, see docs/hot-swap.md. return; } - if ($previousMintedCache !== null) { - // The cache of the previous body was minted by z-engine (no heap flag, so - // destroy_op_array below leaves it alone) - self::releaseRetiredRunTimeCache($entryFunction, $previousMintedCache); - } + $publications ??= EntryPublications::survey($entryFunction); // 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 - // resource the swap minted for it - the run-time cache (released above) - is - // ordinary request memory though, and is released 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). + // resource the swap minted for it - the run-time cache - is ordinary request + // memory though, and is released 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) { @@ -675,13 +602,30 @@ public static function destroyPreviousBody( $isLastHolder = $referenceCount <= 1; } + $liveTable = $previousOpArray->static_variables_ptr__ptr; + + // Every decision that depends on the live closures is taken first, on one walk + // over the object store: the releases below may run destructors (a value held + // in a statics table), and user code may create or drop closures. Nothing a + // destructor creates can reach the resources of the PREVIOUS body - the entry + // already executes the new one - so the decisions stay valid while they are + // carried out. + $readsClosures = $previousMintedCache !== null || (!$isLastHolder && $liveTable !== null); + $liveClosures = $readsClosures ? self::liveClosureFunctions() : []; + if ($previousMintedCache !== null) { + // The cache of the previous body was minted by z-engine (no heap flag, so + // destroy_op_array below leaves it alone); nothing in the release runs user code + self::releaseRetiredRunTimeCache($entryFunction, $previousMintedCache, $liveClosures, $publications); + } + // The materialized live static-variables table was reachable through this entry + // only - the slot now belongs to the new body - unless a fake closure over the old + // body or an engine clone of the entry borrowed it: then it must survive for them + $destroysLiveTable = $liveTable !== null + && ($isLastHolder || !self::isLiveStaticsBorrowed($entryFunction, $liveTable, $liveClosures, $publications)); + $rawOpArray = Core::cast('zend_op_array *', Core::addr($previousBody)); - $liveTable = $previousOpArray->static_variables_ptr__ptr; - if ($liveTable !== null && ($isLastHolder || !self::isLiveStaticsBorrowed($entryFunction, $liveTable))) { - // Frees the materialized live static-variables table and clears the map - // slot. The table was reachable through this entry only - the slot now - // belongs to the new body - unless a fake closure over the old body or an - // engine clone of the entry borrowed it: then it must survive for them + if ($destroysLiveTable) { + // Frees the materialized live static-variables table and clears the map slot Core::call('zend_destroy_static_vars', $rawOpArray); } @@ -716,18 +660,32 @@ public static function retireUnpublishedEntry(FunctionLikeInterface $entryFuncti return; } $entryAddress = $entryFunction->getAddress(); - if (self::hasLiveFrame($entryAddress) || count(self::publishedShareHolders($entryFunction)) > 1) { + if (self::hasLiveFrame($entryAddress)) { return; } - $liveTable = $entryFunction->getLiveStaticsPointer(); - if ($liveTable !== null && !self::isLiveStaticsBorrowed($entryFunction, $liveTable)) { + $publications = EntryPublications::survey($entryFunction); + if ($publications->sharesOf($entryFunction) > 1) { + return; + } + $liveTable = $entryFunction->getLiveStaticsPointer(); + $mintedCache = self::$mintedRunTimeCaches[$entryAddress] ?? null; + + // Unlike a committed swap, the entry still carries the body being retired, so a + // destructor fired by the statics release could make a fake closure over it that + // shares the remaining resources: the closures are gathered again after that + // release, and the run-time cache goes last (such a destructor may still call the + // method, which reads its inline caches from the block) + $liveClosures = ($liveTable !== null || $mintedCache !== null) ? self::liveClosureFunctions() : []; + $destroysLiveTable = $liveTable !== null + && !self::isLiveStaticsBorrowed($entryFunction, $liveTable, $liveClosures, $publications); + if ($destroysLiveTable) { Core::call('zend_destroy_static_vars', Core::cast('zend_op_array *', $entryFunction->getEntryPointer())); + $liveClosures = self::liveClosureFunctions(); } - $mintedCache = self::$mintedRunTimeCaches[$entryAddress] ?? null; if ($mintedCache !== null) { unset(self::$mintedRunTimeCaches[$entryAddress]); - self::releaseRetiredRunTimeCache($entryFunction, $mintedCache); + self::releaseRetiredRunTimeCache($entryFunction, $mintedCache, $liveClosures, $publications); $entryFunction->getOpArrayPointer()->run_time_cache__ptr = null; } } @@ -737,18 +695,23 @@ public static function retireUnpublishedEntry(FunctionLikeInterface $entryFuncti * fake closure borrows the table of the function it was made over (zend_create_closure_ex * initializes its slot to it), an engine clone copied the slot with the structure * - * @param HashTableStruct $liveTable + * @param HashTableStruct $liveTable + * @param list $liveClosures The functions of the live closures, see liveClosureFunctions() */ - private static function isLiveStaticsBorrowed(FunctionLikeInterface $entryFunction, object $liveTable): bool - { + private static function isLiveStaticsBorrowed( + FunctionLikeInterface $entryFunction, + object $liveTable, + array $liveClosures, + EntryPublications $publications, + ): bool { $tableAddress = Core::addressOf($liveTable); - foreach (self::liveClosureFunctions() as $closureFunction) { + foreach ($liveClosures as $closureFunction) { $closureSlot = $closureFunction->getOpArrayPointer()->static_variables_ptr__ptr; if ($closureSlot !== null && Core::addressOf($closureSlot) === $tableAddress) { return true; } } - foreach (self::cloneHolders($entryFunction) as [, $clone]) { + foreach ($publications->clonesOf($entryFunction) as [, $clone]) { $cloneSlot = $clone->getOpArrayPointer()->static_variables_ptr__ptr; if ($cloneSlot !== null && Core::addressOf($cloneSlot) === $tableAddress) { return true; @@ -759,27 +722,34 @@ private static function isLiveStaticsBorrowed(FunctionLikeInterface $entryFuncti } /** - * Enumerates the zend_function of every live closure object as a pointer-level view + * Gathers the zend_function of every live closure object as a pointer-level view, in one + * walk over the object store * * Only user-function closures are of interest (an internal function's closure carries - * no op_array), so the enumeration is narrowed to those. + * no op_array), so the list is narrowed to those. The views point into the closure + * objects: the list describes the live closures only until user code runs again (a + * destructor fired by a table release may create or drop closures), so the callers take + * every decision that reads it BEFORE they destroy anything. * - * @return iterable + * @return list */ - private static function liveClosureFunctions(): iterable + private static function liveClosureFunctions(): array { if (self::$closureClassAddress === null) { $closureClass = Core::$executor->classTable->find('closure'); assert($closureClass !== null); self::$closureClassAddress = Core::addressOf($closureClass->getRawClass()); } + $liveClosures = []; foreach (Core::$executor->objectStore->instancesOf(self::$closureClassAddress) as $closureObject) { $closure = Core::cast(zend_closure::class, $closureObject->getRawValue()); $closureFunction = ReflectionFunction::fromCData(Core::addr($closure->func)); if ($closureFunction->isUserDefined()) { - yield $closureFunction; + $liveClosures[] = $closureFunction; } } + + return $liveClosures; } /** @@ -828,8 +798,9 @@ private static function hasLiveFrameUsingCache(int $cacheAddress): bool /** * Returns the resources taken by an uncommitted swap and forgets its bookkeeping * - * @param CData|null $previousMintedCache Cache record to reinstate for the entry (the one - * the previous body still refers to), if any + * @param CData|null $previousMintedCache Cache record to reinstate for the entry (the one + * the previous body still refers to), if any + * @param EntryPublications|null $publications Survey taken for the swap (see swapUserFunctionBody()) * * @internal called by PendingBodySwap::rollback() while the donor is still alive */ @@ -837,12 +808,18 @@ public static function releaseSwappedInBody( FunctionLikeInterface $entryFunction, int $publishedShares, ?CData $previousMintedCache = null, + ?EntryPublications $publications = null, ): void { // The fresh run-time cache was minted by the swap and is not published anywhere $entryAddress = $entryFunction->getAddress(); $mintedCache = self::$mintedRunTimeCaches[$entryAddress] ?? null; if ($mintedCache !== null) { - self::releaseRetiredRunTimeCache($entryFunction, $mintedCache); + self::releaseRetiredRunTimeCache( + $entryFunction, + $mintedCache, + self::liveClosureFunctions(), + $publications ?? EntryPublications::survey($entryFunction), + ); } if ($previousMintedCache !== null) { self::$mintedRunTimeCaches[$entryAddress] = $previousMintedCache; diff --git a/src/Reflection/FunctionLikeTrait.php b/src/Reflection/FunctionLikeTrait.php index 38ea0e24..e39af40f 100644 --- a/src/Reflection/FunctionLikeTrait.php +++ b/src/Reflection/FunctionLikeTrait.php @@ -266,20 +266,23 @@ public function redefine(\Closure|FunctionLikeInterface $newBody, ?string $prese /** @var list $pendingSwaps */ $pendingSwaps = []; try { + // One class-table walk serves the whole redefinition: the shares of the entry + // (subclass method tables may share this very structure - every such bucket + // releases one body reference when the engine destroys it), its clones and + // their shares, and the commit of every swap staged below + $publications = EntryPublications::survey($entryFunction); $pendingSwaps[] = FunctionBodySwap::swapUserFunctionBody( $entryFunction, $donorFunction, preserveDeclaration: true, // A shared-memory body is immortal by definition and must not be freed destroyPrevious: !$isSharedMemoryEntry, - // Subclass method tables may share this very structure - every such - // bucket releases one body reference when the engine destroys it - publishedShares: FunctionBodySwap::countPublishedShares($entryFunction), + publications: $publications, ); // A subclass implementing an interface that declares the method holds an engine - // CLONE of the entry instead of the entry pointer (FunctionBodySwap::cloneHolders()): + // CLONE of the entry instead of the entry pointer (EntryPublications::clonesOf()): // the clone dispatches for that subclass, so it follows the redefinition in place - foreach (FunctionBodySwap::cloneHolders($entryFunction) as [$holder, $clone]) { + foreach ($publications->clonesOf($entryFunction) as [$holder, $clone]) { if ($holder->isImmutable()) { // A clone inside a shared-memory subclass is never written: that subclass // keeps the previous body, like every subclass linked against a shared entry. @@ -293,7 +296,7 @@ public function redefine(\Closure|FunctionLikeInterface $newBody, ?string $prese preserveDeclaration: true, // The clone was copied from the entry: a shared-memory body (no refcount) is immortal destroyPrevious: $clone->getOpArrayPointer()->refcount !== null, - publishedShares: FunctionBodySwap::countPublishedShares($clone), + publications: $publications, ); } } catch (\Throwable $error) { diff --git a/src/Reflection/MethodPublisher.php b/src/Reflection/MethodPublisher.php index 54a184dc..4f54d632 100644 --- a/src/Reflection/MethodPublisher.php +++ b/src/Reflection/MethodPublisher.php @@ -555,42 +555,7 @@ private static function retire(array $retired): void */ private static function subclassesOf(ReflectionClass $class): array { - $classAddress = $class->getAddress(); - $subclasses = []; - $seenClasses = []; - foreach (Core::$executor->classTable as $classValue) { - try { - $rawClass = $classValue->getRawClass(); - } catch (\UnexpectedValueException $e) { - // Class alias buckets (IS_ALIAS_PTR) resolve to an already-visited entry - continue; - } - $candidateAddress = Core::addressOf($rawClass); - if ($candidateAddress === $classAddress || isset($seenClasses[$candidateAddress])) { - continue; - } - $seenClasses[$candidateAddress] = true; - $candidate = ReflectionClass::fromCData($rawClass); - if ($candidate->isUserDefined() && self::descendsFrom($candidate, $classAddress)) { - $subclasses[] = $candidate; - } - } - - return $subclasses; - } - - /** - * Whether the class entry at $ancestorAddress is on the parent chain of $candidate - */ - private static function descendsFrom(ReflectionClass $candidate, int $ancestorAddress): bool - { - for ($parent = $candidate->getParentClass(); $parent !== null; $parent = $parent->getParentClass()) { - if ($parent->getAddress() === $ancestorAddress) { - return true; - } - } - - return false; + return $class->getLinkedSubclasses(); } /** diff --git a/src/Reflection/PendingBodySwap.php b/src/Reflection/PendingBodySwap.php index 4627abc5..aa19ef2f 100644 --- a/src/Reflection/PendingBodySwap.php +++ b/src/Reflection/PendingBodySwap.php @@ -41,6 +41,8 @@ final class PendingBodySwap * @param bool $destroyPrevious False for shared-memory previous bodies (never freed) * @param CData|null $previousMintedCache Run-time cache an earlier swap minted for the previous * body (released on commit, reinstated on rollback) + * @param EntryPublications|null $publications Survey of the tables publishing the entry, taken for + * the swap (its clones are revisited on commit/rollback) */ public function __construct( private FunctionLikeInterface $entry, @@ -48,6 +50,7 @@ public function __construct( private int $publishedShares, private bool $destroyPrevious, private ?CData $previousMintedCache = null, + private ?EntryPublications $publications = null, ) {} /** @@ -63,10 +66,11 @@ public function commit(): void $this->previousBody, $this->publishedShares, $this->previousMintedCache, + $this->publications, ); } else { // A shared-memory body is never freed, but the cache z-engine minted for it is - FunctionBodySwap::releaseMintedRunTimeCache($this->entry, $this->previousMintedCache); + FunctionBodySwap::releaseMintedRunTimeCache($this->entry, $this->previousMintedCache, $this->publications); } } @@ -81,7 +85,7 @@ public function rollback(): void { $this->assertUnresolved(); $this->isResolved = true; - FunctionBodySwap::releaseSwappedInBody($this->entry, $this->publishedShares, $this->previousMintedCache); + FunctionBodySwap::releaseSwappedInBody($this->entry, $this->publishedShares, $this->previousMintedCache, $this->publications); Core::memcpy($this->entry->getEntryPointer(), Core::addr($this->previousBody), Core::sizeof($this->previousBody)); } diff --git a/src/Reflection/ReflectionClass.php b/src/Reflection/ReflectionClass.php index 690c4d3c..56087fdd 100644 --- a/src/Reflection/ReflectionClass.php +++ b/src/Reflection/ReflectionClass.php @@ -259,6 +259,85 @@ public static function fromClassTable(string $className): ?ReflectionClass return static::fromCData($classEntryValue->getRawClass()); } + /** + * Looks a method up in the table of the class entry at the given address without building + * the class wrapper + * + * The class-table surveys of the swap machinery (EntryPublications) visit every linked + * class and keep the few that publish a given name: the wrapper, with its table views, is + * built for those alone. An internal class holds no user method and answers null. + * + * @param int $classAddress Address of the zend_class_entry, as the class table stores it + * @param StringEntry $lowerName Lower-cased method name, as the method table keys it + * + * @internal used by EntryPublications::survey() + */ + public static function findUserMethodAt(int $classAddress, StringEntry $lowerName): ?ReflectionMethod + { + $classEntry = Core::pointerAtAddress(zend_class_entry::class, $classAddress); + if (ord($classEntry->type) !== Core::ZEND_USER_CLASS) { + return null; + } + $bucket = Core::call('zend_hash_find', Core::addr($classEntry->function_table), $lowerName->getRawValue()); + if ($bucket === null) { + return null; + } + assert($bucket instanceof CData); + + return ReflectionMethod::fromRawEntry(ReflectionValue::fromValueEntry($bucket)->getRawFunction()); + } + + /** + * Returns every linked user class descending from this one, found in one walk over the + * class table that builds a wrapper for the descendants only + * + * A class alias (class_alias()) resolves to the entry it aliases and is not listed twice; + * a class the engine has not linked yet (an opcache lazy-linking temporary) descends + * from nothing yet and is left out. + * + * @return list + * + * @internal used by MethodPublisher to reach the subclasses linked before a publication + */ + public function getLinkedSubclasses(): array + { + $ownAddress = $this->getAddress(); + $subclasses = []; + foreach (Core::$executor->classTable->pointerValueAddresses() as $classAddress) { + if ($classAddress === $ownAddress || !self::descendsFromAddress($classAddress, $ownAddress)) { + continue; + } + $subclasses[] = static::fromCData(Core::pointerAtAddress(zend_class_entry::class, $classAddress)); + } + + return $subclasses; + } + + /** + * Whether the class entry at $classAddress is a linked user class with the entry at + * $ancestorAddress on its parent chain + */ + private static function descendsFromAddress(int $classAddress, int $ancestorAddress): bool + { + $classEntry = Core::pointerAtAddress(zend_class_entry::class, $classAddress); + if (ord($classEntry->type) !== Core::ZEND_USER_CLASS) { + return false; + } + // Until a class is linked, the parent slot holds the parent NAME (a union); the + // parent of a linked class is always linked itself + for ($entry = $classEntry; ($entry->ce_flags & Core::ZEND_ACC_LINKED) !== 0; $entry = $parent) { + $parent = $entry->parent; + if ($parent === null) { + return false; + } + if (Core::addressOf($parent) === $ancestorAddress) { + return true; + } + } + + return false; + } + /** * @inheritDoc */ diff --git a/src/Type/HashTable.php b/src/Type/HashTable.php index d20df71e..58c96fd4 100644 --- a/src/Type/HashTable.php +++ b/src/Type/HashTable.php @@ -25,6 +25,7 @@ use ZEngine\Generated\zend_function; use ZEngine\Generated\zend_internal_function; use ZEngine\Generated\zend_refcounted_h; +use ZEngine\Generated\zval; use ZEngine\Memory\Allocator; use ZEngine\Memory\EngineAllocator; use ZEngine\Reflection\ReflectionValue; @@ -308,6 +309,55 @@ public function getIterator(): Traversable return $iterator(); } + /** + * Returns the addresses of the pointer values (IS_PTR) the table stores, read through an + * integer view of the bucket array without a handle per bucket + * + * The engine's class and function tables key pointers to class entries and functions; + * a walk over the whole table (the class-table surveys of the swap machinery, see + * EntryPublications) needs those addresses only, and the value wrapper the iterator + * builds per bucket is what makes such a walk cost tens of milliseconds in a process + * holding thousands of classes. Alias buckets (IS_ALIAS_PTR, the entry a class_alias() + * adds) are left out: they resolve to a pointer the table also stores under the class's + * own name. Deleted slots (IS_UNDEF) and indirect ones are left out as well. + * + * @return list + * + * @internal used by EntryPublications::survey() + */ + public function pointerValueAddresses(): array + { + if (($this->pointer->u->flags & self::HASH_FLAG_PACKED) !== 0) { + // A packed table holds plain values under integer keys, never engine pointers + return []; + } + $arData = $this->pointer->arData; + assert($arData !== null); + // Machine-word view of the bucket array: a bucket is a zval (the value word, then + // the type-info word) followed by the hash and the key pointer + $words = Core::pointerAtAddress('uintptr_t *', Core::addressOf($arData)); + $wordsPerBucket = intdiv(Core::sizeOfType(Bucket::class), 8); + $typeWord = intdiv(Core::offsetOfField(zval::class, 'u1'), 8); + $numUsed = $this->pointer->nNumUsed; + + $addresses = []; + for ($index = 0; $index < $numUsed; $index++) { + $base = $index * $wordsPerBucket; + $type = $words[$base + $typeWord]; + assert(is_int($type)); + // The zval type is the low byte of u1.type_info (little-endian, like every + // platform z-engine runs on) + if (($type & 0xFF) !== ReflectionValue::IS_PTR) { + continue; + } + $address = $words[$base]; + assert(is_int($address)); + $addresses[] = $address; + } + + return $addresses; + } + /** * Returns the number of live elements stored in the table * diff --git a/tests/Reflection/EntryPublicationsTest.php b/tests/Reflection/EntryPublicationsTest.php new file mode 100644 index 00000000..a767c4c5 --- /dev/null +++ b/tests/Reflection/EntryPublicationsTest.php @@ -0,0 +1,139 @@ + + * + * 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\Reflection; + +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Stub\Alias\SurveyBase; +use ZEngine\Stub\Alias\SurveyChild; +use ZEngine\Stub\Alias\SurveyGrandChild; +use ZEngine\Stub\Alias\SurveyPlainChild; +use ZEngine\Stub\Alias\SurveyUnrelated; + +/** + * The one-walk survey a body swap takes of the tables publishing its entry: the shares the + * engine's inheritance took on the structure, the subclasses holding an engine clone of it, + * and the wrapper-light class-table walk behind it (HashTable::pointerValueAddresses(), + * ReflectionClass::getLinkedSubclasses()) + * + * The Survey* stubs are never redefined: SurveyBase declares m(), SurveyPlainChild inherits + * its pointer, SurveyChild implements an interface declaring m() and so holds a clone of the + * entry, SurveyGrandChild inherits that clone's pointer, and SurveyUnrelated declares an m() + * of its own. + */ +final class EntryPublicationsTest extends TestCase +{ + public static function setUpBeforeClass(): void + { + // Link every stub before the surveys (autoloading inside a survey is not the subject) + class_exists(SurveyPlainChild::class); + class_exists(SurveyGrandChild::class); + class_exists(SurveyUnrelated::class); + require_once __DIR__ . '/../Stub/Alias/LiveStatics-functions.php'; + } + + public function testASurveyCountsThePointerSharesAndFindsTheClones(): void + { + $entry = (new ReflectionClass(SurveyBase::class))->getMethod('m'); + $survey = EntryPublications::survey($entry); + + self::assertSame(2, $survey->sharesOf($entry), 'the declaring class and the plain subclass publish the pointer'); + self::assertSame( + [SurveyBase::class, SurveyPlainChild::class], + self::names($survey->holdersOf($entry)), + ); + + $clones = $survey->clonesOf($entry); + self::assertCount(1, $clones, 'the interface-implementing subclass holds the only clone'); + [$holder, $clone] = $clones[0]; + self::assertSame(SurveyChild::class, $holder->getName()); + self::assertNotSame($entry->getAddress(), $clone->getAddress(), 'a clone is a struct of its own'); + self::assertSame(SurveyBase::class, self::scopeName($clone), 'the clone carries the scope of the entry'); + + // The clone is published by pointer into the tables below it, like any entry + self::assertSame(2, $survey->sharesOf($clone)); + self::assertSame([SurveyChild::class, SurveyGrandChild::class], self::names($survey->holdersOf($clone))); + self::assertSame([], $survey->clonesOf($clone), 'nothing below the clone holds a clone of it'); + } + + public function testAnUnrelatedMethodOfTheSameNameIsNeitherAHolderNorAClone(): void + { + $unrelated = (new ReflectionClass(SurveyUnrelated::class))->getMethod('m'); + $survey = EntryPublications::survey($unrelated); + + self::assertSame(1, $survey->sharesOf($unrelated)); + self::assertSame([SurveyUnrelated::class], self::names($survey->holdersOf($unrelated))); + self::assertSame([], $survey->clonesOf($unrelated)); + } + + public function testAGlobalFunctionIsPublishedInNoClassTable(): void + { + $entry = new ReflectionFunction('zengine_live_statics_fn'); + $survey = EntryPublications::survey($entry); + + self::assertSame(1, $survey->sharesOf($entry), 'a plain function entry counts as one share'); + self::assertSame([], $survey->holdersOf($entry)); + self::assertSame([], $survey->clonesOf($entry)); + } + + public function testTheWrapperLightClassWalkListsEveryClassEntryOnce(): void + { + $expected = []; + foreach (Core::$executor->classTable as $classValue) { + try { + $expected[] = Core::addressOf($classValue->getRawClass()); + } catch (\UnexpectedValueException $e) { + // An alias bucket resolves to an entry listed under its own name + } + } + $addresses = Core::$executor->classTable->pointerValueAddresses(); + + self::assertNotEmpty($addresses); + self::assertSame($expected, $addresses, 'the integer view reads exactly the pointer values the iterator yields'); + self::assertSame($addresses, array_values(array_unique($addresses)), 'a class alias adds no second entry'); + } + + public function testTheLinkedSubclassesAreFoundWithoutAWrapperPerClass(): void + { + $base = new ReflectionClass(SurveyBase::class); + self::assertSame( + [SurveyChild::class, SurveyGrandChild::class, SurveyPlainChild::class], + self::names($base->getLinkedSubclasses()), + ); + self::assertSame([SurveyGrandChild::class], self::names((new ReflectionClass(SurveyChild::class))->getLinkedSubclasses())); + self::assertSame([], (new ReflectionClass(SurveyGrandChild::class))->getLinkedSubclasses()); + self::assertSame([], (new ReflectionClass(SurveyUnrelated::class))->getLinkedSubclasses()); + } + + /** + * @param list $classes + * + * @return list + */ + private static function names(array $classes): array + { + $names = array_map(static fn(ReflectionClass $class): string => $class->getName(), $classes); + sort($names); + + return $names; + } + + private static function scopeName(ReflectionMethod $method): string + { + $scope = $method->getCommonPointer()->scope; + self::assertNotNull($scope); + + return ReflectionClass::fromCData($scope)->getName(); + } +} diff --git a/tests/Stub/Alias/SurveyBase.php b/tests/Stub/Alias/SurveyBase.php new file mode 100644 index 00000000..a358524a --- /dev/null +++ b/tests/Stub/Alias/SurveyBase.php @@ -0,0 +1,25 @@ + + * + * 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\Stub\Alias; + +/** + * Declaring class of the surveyed method; never redefined (EntryPublicationsTest) + */ +class SurveyBase +{ + public function m(string $who): string + { + return 'base:' . $who; + } +} diff --git a/tests/Stub/Alias/SurveyChild.php b/tests/Stub/Alias/SurveyChild.php new file mode 100644 index 00000000..2b529898 --- /dev/null +++ b/tests/Stub/Alias/SurveyChild.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Subclass implementing the interface: holds an engine CLONE of SurveyBase::m() instead of the pointer (EntryPublicationsTest) + */ +class SurveyChild extends SurveyBase implements SurveyContract {} diff --git a/tests/Stub/Alias/SurveyContract.php b/tests/Stub/Alias/SurveyContract.php new file mode 100644 index 00000000..8787066e --- /dev/null +++ b/tests/Stub/Alias/SurveyContract.php @@ -0,0 +1,22 @@ + + * + * 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\Stub\Alias; + +/** + * Interface that makes a subclass hold an engine clone of the inherited SurveyBase::m() (EntryPublicationsTest) + */ +interface SurveyContract +{ + public function m(string $who): string; +} diff --git a/tests/Stub/Alias/SurveyGrandChild.php b/tests/Stub/Alias/SurveyGrandChild.php new file mode 100644 index 00000000..b834d49a --- /dev/null +++ b/tests/Stub/Alias/SurveyGrandChild.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Subclass of the clone holder: inherits the clone's POINTER (a published share of the clone) (EntryPublicationsTest) + */ +class SurveyGrandChild extends SurveyChild {} diff --git a/tests/Stub/Alias/SurveyPlainChild.php b/tests/Stub/Alias/SurveyPlainChild.php new file mode 100644 index 00000000..5dce41a4 --- /dev/null +++ b/tests/Stub/Alias/SurveyPlainChild.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Plain subclass: inherits the POINTER of SurveyBase::m() (a published share of the entry) (EntryPublicationsTest) + */ +class SurveyPlainChild extends SurveyBase {} diff --git a/tests/Stub/Alias/SurveyUnrelated.php b/tests/Stub/Alias/SurveyUnrelated.php new file mode 100644 index 00000000..83e84955 --- /dev/null +++ b/tests/Stub/Alias/SurveyUnrelated.php @@ -0,0 +1,25 @@ + + * + * 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\Stub\Alias; + +/** + * Unrelated class declaring a method of the same name: neither a holder nor a clone (EntryPublicationsTest) + */ +class SurveyUnrelated +{ + public function m(string $who): string + { + return 'unrelated:' . $who; + } +} From b2f261bdc6991becc23062956d124060cb487d39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:28:04 +0000 Subject: [PATCH 14/14] test(hot-swap): extend the alias matrix with the destructor-path scenarios of the fixes The matrix script runs under both allocators and both opcache modes and shuts the process down through the destructor path, which is where the fixed defects used to crash. Five scenarios are added: fake closures over a redefined method surviving its next redefinition, an interface clone linked between two redefinitions following the second, a closure donor with statics preserved under an alias and swapped back before the alias is removed, an addMethod() override under a redeclared ancestor-private method carrying ZEND_ACC_CHANGED, and an overriding subclass linked before a preserving redefine (refused on a shared-memory entry, served through parent:: otherwise). Every scenario also pins the engine state it relies on by pointer identity and flag bits, and the hot loop drives the new entries. On PHP 8.5 the statics scenario pins the refcount-guarded defaults table the alias and the restored body share with the preserved body, instead of the duplicates PHP 8.4 mints. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .../HotSwap/scripts/redefine-alias-matrix.php | 235 ++++++++++++++++++ tests/Stub/Alias/MatrixChangedDonor.php | 26 ++ tests/Stub/Alias/MatrixChangedLeaf.php | 19 ++ tests/Stub/Alias/MatrixChangedLeafSub.php | 20 ++ tests/Stub/Alias/MatrixChangedMid.php | 25 ++ tests/Stub/Alias/MatrixChangedRoot.php | 36 +++ tests/Stub/Alias/MatrixCloneBase.php | 28 +++ tests/Stub/Alias/MatrixCloneChild.php | 20 ++ tests/Stub/Alias/MatrixCloneContract.php | 24 ++ tests/Stub/Alias/MatrixCloneDonorV1.php | 27 ++ tests/Stub/Alias/MatrixCloneDonorV2.php | 27 ++ tests/Stub/Alias/MatrixEarlyBase.php | 28 +++ tests/Stub/Alias/MatrixEarlyChild.php | 26 ++ tests/Stub/Alias/MatrixEarlyDonor.php | 27 ++ tests/Stub/Alias/MatrixEarlyGrandChild.php | 19 ++ tests/Stub/Alias/MatrixFakeClosureDonorV1.php | 27 ++ tests/Stub/Alias/MatrixFakeClosureDonorV2.php | 27 ++ tests/Stub/Alias/MatrixFakeClosureTarget.php | 31 +++ tests/Stub/Alias/MatrixStaticsCounter.php | 30 +++ .../Stub/Alias/MatrixStaticsCounterDonor.php | 27 ++ 20 files changed, 729 insertions(+) create mode 100644 tests/Stub/Alias/MatrixChangedDonor.php create mode 100644 tests/Stub/Alias/MatrixChangedLeaf.php create mode 100644 tests/Stub/Alias/MatrixChangedLeafSub.php create mode 100644 tests/Stub/Alias/MatrixChangedMid.php create mode 100644 tests/Stub/Alias/MatrixChangedRoot.php create mode 100644 tests/Stub/Alias/MatrixCloneBase.php create mode 100644 tests/Stub/Alias/MatrixCloneChild.php create mode 100644 tests/Stub/Alias/MatrixCloneContract.php create mode 100644 tests/Stub/Alias/MatrixCloneDonorV1.php create mode 100644 tests/Stub/Alias/MatrixCloneDonorV2.php create mode 100644 tests/Stub/Alias/MatrixEarlyBase.php create mode 100644 tests/Stub/Alias/MatrixEarlyChild.php create mode 100644 tests/Stub/Alias/MatrixEarlyDonor.php create mode 100644 tests/Stub/Alias/MatrixEarlyGrandChild.php create mode 100644 tests/Stub/Alias/MatrixFakeClosureDonorV1.php create mode 100644 tests/Stub/Alias/MatrixFakeClosureDonorV2.php create mode 100644 tests/Stub/Alias/MatrixFakeClosureTarget.php create mode 100644 tests/Stub/Alias/MatrixStaticsCounter.php create mode 100644 tests/Stub/Alias/MatrixStaticsCounterDonor.php diff --git a/tests/HotSwap/scripts/redefine-alias-matrix.php b/tests/HotSwap/scripts/redefine-alias-matrix.php index 2b671ffe..fdfc6442 100644 --- a/tests/HotSwap/scripts/redefine-alias-matrix.php +++ b/tests/HotSwap/scripts/redefine-alias-matrix.php @@ -12,7 +12,11 @@ declare(strict_types=1); use ZEngine\Core; +use ZEngine\OpCache\SharedMemoryException; +use ZEngine\Reflection\FunctionBodySwap; +use ZEngine\Reflection\FunctionLikeInterface; use ZEngine\Reflection\ReflectionClass; +use ZEngine\Reflection\ReflectionFunction; use ZEngine\Stub\Alias\Ancestor; use ZEngine\Stub\Alias\AncestorDonor; use ZEngine\Stub\Alias\Constructed; @@ -23,11 +27,29 @@ use ZEngine\Stub\Alias\GrandDescendant; use ZEngine\Stub\Alias\Greeter; use ZEngine\Stub\Alias\GreeterDonor; +use ZEngine\Stub\Alias\MatrixChangedDonor; +use ZEngine\Stub\Alias\MatrixChangedLeaf; +use ZEngine\Stub\Alias\MatrixChangedLeafSub; +use ZEngine\Stub\Alias\MatrixChangedMid; +use ZEngine\Stub\Alias\MatrixCloneBase; +use ZEngine\Stub\Alias\MatrixCloneChild; +use ZEngine\Stub\Alias\MatrixCloneDonorV1; +use ZEngine\Stub\Alias\MatrixCloneDonorV2; +use ZEngine\Stub\Alias\MatrixEarlyBase; +use ZEngine\Stub\Alias\MatrixEarlyChild; +use ZEngine\Stub\Alias\MatrixEarlyDonor; +use ZEngine\Stub\Alias\MatrixEarlyGrandChild; +use ZEngine\Stub\Alias\MatrixFakeClosureDonorV1; +use ZEngine\Stub\Alias\MatrixFakeClosureDonorV2; +use ZEngine\Stub\Alias\MatrixFakeClosureTarget; +use ZEngine\Stub\Alias\MatrixStaticsCounter; +use ZEngine\Stub\Alias\MatrixStaticsCounterDonor; use ZEngine\Stub\Alias\Secretive; use ZEngine\Stub\Alias\SecretiveDonor; use ZEngine\Stub\Alias\Statics; use ZEngine\Stub\Alias\StaticsChild; use ZEngine\Stub\Alias\StaticsDonor; +use ZEngine\Type\ClosureEntry; /** * Child-process matrix for redefine(preserveAs:) and addMethod() overrides @@ -63,6 +85,22 @@ fwrite(STDERR, "FAIL {$label}: expected " . var_export($expected, true) . ', got ' . var_export($actual, true) . "\n"); } }; +// Address of the run-time cache block a user function reads its inline caches from (0 when none) +$cacheAddress = static function (FunctionLikeInterface $function): int { + $cache = $function->getOpArrayPointer()->run_time_cache__ptr; + + return $cache === null ? 0 : Core::addressOf($cache); +}; +// Whether the function owns its run-time cache (ZEND_ACC_HEAP_RT_CACHE: destroy_op_array frees it) +$hasHeapCache = static function (FunctionLikeInterface $function): bool { + return ($function->getCommonPointer()->fn_flags & Core::ZEND_ACC_HEAP_RT_CACHE) !== 0; +}; +// Pointer-level view of the zend_function embedded in a closure object (the closure must stay alive) +$functionOfClosure = static function (\Closure $closure): ReflectionFunction { + $closureEntry = new ClosureEntry($closure); + + return ReflectionFunction::fromCData(Core::addr($closureEntry->getRawFunction())); +}; // --- Instance method, method donor, alias --------------------------------------------- $greeter = new ReflectionClass(Greeter::class); @@ -101,6 +139,193 @@ $check('override reaches the grand-descendant linked before', 1002, (new GrandDescendant())->inheritedOnly(1)); $check('ancestor untouched', 2, (new Ancestor())->inheritedOnly(1)); +// --- Fake closures over a redefined method, then a second redefinition ---------------------- +// zend_create_closure_ex copies the entry struct into the closure, cache pointer included, when +// the scope matches and the cache is not heap-flagged: the three fake-closure forms share the +// cache minted by swap #1. Swap #2 releases that block - after handing each closure a private +// heap copy (ZEND_ACC_HEAP_RT_CACHE) that destroy_op_array frees with the closure. Before the fix +// the closures read, and at their destruction freed, the released block. +$fakeEntry = (new ReflectionClass(MatrixFakeClosureTarget::class))->getMethod('greet'); +$fakeEntry->redefine((new ReflectionClass(MatrixFakeClosureDonorV1::class))->getMethod('greet')); +$fakeEntry = (new ReflectionClass(MatrixFakeClosureTarget::class))->getMethod('greet'); +$fakeFirstCache = $cacheAddress($fakeEntry); +$check('swap #1 minted a run-time cache for the entry', true, $fakeFirstCache !== 0); +$fakeTarget = new MatrixFakeClosureTarget('t'); +$fakeClosures = [ + 'first-class callable' => $fakeTarget->greet(...), + 'Closure::fromCallable' => \Closure::fromCallable([$fakeTarget, 'greet']), + 'ReflectionMethod::getClosure' => (new \ReflectionMethod(MatrixFakeClosureTarget::class, 'greet'))->getClosure($fakeTarget), +]; +foreach ($fakeClosures as $kind => $fakeClosure) { + $closureFunction = $functionOfClosure($fakeClosure); + $check("{$kind} shares the cache minted by swap #1", $fakeFirstCache, $cacheAddress($closureFunction)); + $check("{$kind} carries no heap flag while sharing", false, $hasHeapCache($closureFunction)); + $check("{$kind} dispatches the swap #1 body and warms the shared block", 'v1:A from t..', $fakeClosure('a')); +} +$fakeEntry->redefine((new ReflectionClass(MatrixFakeClosureDonorV2::class))->getMethod('greet')); +$fakeSecondCache = $cacheAddress($fakeEntry); +$check('swap #2 installed a fresh cache on the entry', true, $fakeSecondCache !== 0 && $fakeSecondCache !== $fakeFirstCache); +$check('direct dispatch after swap #2', 'v2:B from t..', $fakeTarget->greet('b')); +foreach ($fakeClosures as $kind => $fakeClosure) { + $closureFunction = $functionOfClosure($fakeClosure); + $closureCache = $cacheAddress($closureFunction); + $check("{$kind} left the released block", true, $closureCache !== 0 && $closureCache !== $fakeFirstCache); + $check("{$kind} does not share the current cache of the entry", true, $closureCache !== $fakeSecondCache); + $check("{$kind} owns a heap copy the engine frees with the closure", true, $hasHeapCache($closureFunction)); + $check("{$kind} still dispatches the swap #1 body", 'v1:C from t..', $fakeClosure('c')); +} + +// --- Interface clone linked between two redefinitions ---------------------------------------- +// A subclass implementing an interface that declares the inherited method gets a struct copy of +// the entry (do_inheritance_check_on_method), cache pointer included, instead of the entry +// pointer. The second swap must swap the clone in place and give it a cache of its own before +// the shared block is released; before the fix the subclass kept the previous body over freed memory. +$cloneEntry = (new ReflectionClass(MatrixCloneBase::class))->getMethod('greet'); +$cloneEntry->redefine((new ReflectionClass(MatrixCloneDonorV1::class))->getMethod('greet')); +$cloneEntry = (new ReflectionClass(MatrixCloneBase::class))->getMethod('greet'); +$cloneFirstCache = $cacheAddress($cloneEntry); +// Autoloaded now, i.e. linked against the redefined base +$cloneChild = new MatrixCloneChild('c'); +$check('the subclass table holds no share of the entry pointer', 1, count(FunctionBodySwap::publishedShareHolders($cloneEntry))); +$cloneHolders = FunctionBodySwap::cloneHolders($cloneEntry); +$check('the subclass holds one engine clone of the entry', 1, count($cloneHolders)); +$cloneAddress = 0; +foreach ($cloneHolders as [$cloneHolder, $clone]) { + $cloneAddress = $clone->getAddress(); + $check('clone holder', MatrixCloneChild::class, $cloneHolder->getName()); + $check('the clone is a struct copy, not the entry', true, $cloneAddress !== $cloneEntry->getAddress()); + $check('the clone shares the cache minted by swap #1', $cloneFirstCache, $cacheAddress($clone)); + $check('the clone carries no heap flag', false, $hasHeapCache($clone)); +} +$check('the clone dispatches the swap #1 body and warms the shared block', 'v1:A from c..', $cloneChild->greet('a')); +$cloneEntry->redefine((new ReflectionClass(MatrixCloneDonorV2::class))->getMethod('greet')); +$cloneSecondCache = $cacheAddress($cloneEntry); +$check('swap #2 installed a fresh cache on the entry', true, $cloneSecondCache !== 0 && $cloneSecondCache !== $cloneFirstCache); +$check('the base dispatches the swap #2 body', 'v2:B from b..', (new MatrixCloneBase('b'))->greet('b')); +$check('the subclass dispatches the swap #2 body through its clone', 'v2:B from c..', $cloneChild->greet('b')); +$cloneHolders = FunctionBodySwap::cloneHolders($cloneEntry); +$check('the clone is still the only engine copy', 1, count($cloneHolders)); +foreach ($cloneHolders as [, $clone]) { + $cloneCache = $cacheAddress($clone); + $check('the clone was swapped in place', $cloneAddress, $clone->getAddress()); + $check('the clone left the released block', true, $cloneCache !== 0 && $cloneCache !== $cloneFirstCache); + $check('the clone has a minted cache of its own', true, $cloneCache !== $cloneSecondCache); + $check('the clone cache is request memory the engine never frees itself', false, $hasHeapCache($clone)); +} + +// --- Closure donor with statics, alias from a method donor, swap back, removal ----------------- +// The closure donor's statics defaults table is guarded by the body refcount on PHP 8.5 (no +// duplicate is minted): the entry shares it after swap #1 and its live table materializes on +// the first call. The alias adopting that body shares the defaults too and takes over the live +// table; the entry swapped back from the alias starts afresh; removeMethods() releases what the +// alias owned. +$counterEntry = (new ReflectionClass(MatrixStaticsCounter::class))->getMethod('counter'); +$counterEntry->redefine(function (): int { + /** @var int $n */ + static $n = 10; + + return ++$n; +}); +$counterEntry = (new ReflectionClass(MatrixStaticsCounter::class))->getMethod('counter'); +$counterEntryAddress = $counterEntry->getAddress(); +$counterInstance = new MatrixStaticsCounter(); +$check('closure body counts from its own default', 11, $counterInstance->counter()); +$counterDefaults = $counterEntry->getOpArrayPointer()->static_variables; +assert($counterDefaults !== null); +$counterDefaultsAddress = Core::addressOf($counterDefaults); +$counterLiveTable = $counterEntry->getLiveStaticsPointer(); +assert($counterLiveTable !== null); +$counterLiveAddress = Core::addressOf($counterLiveTable); + +$counterEntry->redefine((new ReflectionClass(MatrixStaticsCounterDonor::class))->getMethod('counter'), preserveAs: 'counterOriginal'); +$counterAlias = (new ReflectionClass(MatrixStaticsCounter::class))->getMethod('counterOriginal'); +$counterAliasAddress = $counterAlias->getAddress(); +$counterAliasDefaults = $counterAlias->getOpArrayPointer()->static_variables; +$check('the alias carries a defaults table', true, $counterAliasDefaults !== null); +$check('the alias shares the refcount-guarded defaults table of the body it preserves', $counterDefaultsAddress, $counterAliasDefaults === null ? 0 : Core::addressOf($counterAliasDefaults)); +$check('the alias is a struct of its own', true, $counterAliasAddress !== $counterEntryAddress); +$check('the entry executes a body without statics', null, $counterEntry->getOpArrayPointer()->static_variables); +$counterAliasLive = $counterAlias->getLiveStaticsPointer(); +$check('the alias took over the live statics table', $counterLiveAddress, $counterAliasLive === null ? 0 : Core::addressOf($counterAliasLive)); +$check('the entry starts afresh', null, $counterEntry->getLiveStaticsPointer()); +$check('the alias keeps counting the preserved state', 112, $counterInstance->counter()); +$check('the alias keeps counting', 113, $counterInstance->counter()); + +// Swap the preserved body back in from the alias: a method donor sharing the defaults table +$counterEntry->redefine($counterAlias); +$counterRestoredDefaults = $counterEntry->getOpArrayPointer()->static_variables; +$check('the restored body shares the defaults table of the preserved body', $counterDefaultsAddress, $counterRestoredDefaults === null ? 0 : Core::addressOf($counterRestoredDefaults)); +$check('the restored body starts from the defaults', 11, $counterInstance->counter()); +$check('the restored body counts on', 12, $counterInstance->counter()); +(new ReflectionClass(MatrixStaticsCounter::class))->removeMethods('counterOriginal'); +$check('the alias is unpublished', false, (new ReflectionClass(MatrixStaticsCounter::class))->hasMethod('counterOriginal')); +$check('the entry is unaffected by the removal', 13, $counterInstance->counter()); + +// --- addMethod() override below a redeclared ancestor-private method (ZEND_ACC_CHANGED) -------- +// MatrixChangedMid::m() redeclares the private MatrixChangedRoot::m() and carries ZEND_ACC_CHANGED, +// which MatrixChangedLeaf inherits. The override published on the leaf must carry the bit too +// (do_inheritance_check_on_method propagates it), or `$this->m()` inside MatrixChangedRoot::viaRoot() +// resolves the public override instead of the root's private method on leaf instances. +$leafClass = new ReflectionClass(MatrixChangedLeaf::class); +$inheritedLeaf = $leafClass->getMethod('m'); +$check('the inherited method carries CHANGED', Core::ZEND_ACC_CHANGED, $inheritedLeaf->getCommonPointer()->fn_flags & Core::ZEND_ACC_CHANGED); +$check('the inherited method is the middle declaration', MatrixChangedMid::class, (new \ReflectionMethod(MatrixChangedLeaf::class, 'm'))->getDeclaringClass()->getName()); +$override = $leafClass->addMethod('m', (new ReflectionClass(MatrixChangedDonor::class))->getMethod('m')); +$overrideFlags = $override->getCommonPointer()->fn_flags; +$check('the override inherited CHANGED from the replaced entry', Core::ZEND_ACC_CHANGED, $overrideFlags & Core::ZEND_ACC_CHANGED); +$check('the override stays public', 0, $overrideFlags & (Core::ZEND_ACC_PRIVATE | Core::ZEND_ACC_PROTECTED)); +$check('the donor itself carries no CHANGED bit', 0, (new ReflectionClass(MatrixChangedDonor::class))->getMethod('m')->getCommonPointer()->fn_flags & Core::ZEND_ACC_CHANGED); +$check('the override is the own entry of the leaf', $override->getAddress(), (new ReflectionClass(MatrixChangedLeaf::class))->getMethod('m')->getAddress()); +$check('the root code still reaches its private method on a leaf instance', 'root', (new MatrixChangedLeaf())->viaRoot()); +$check('the override dispatches from the outside', 'leaf', (new MatrixChangedLeaf())->m()); +$check('the middle class is untouched: root code', 'root', (new MatrixChangedMid())->viaRoot()); +$check('the middle class is untouched: own method', 'mid', (new MatrixChangedMid())->m()); +$check('the override keeps the prototype chain', MatrixChangedMid::class, (new \ReflectionMethod(MatrixChangedLeaf::class, 'm'))->getPrototype()->getDeclaringClass()->getName()); +// Autoloaded now: inherits the published pointer, bit included +$leafSub = new MatrixChangedLeafSub(); +$check('a subclass linked after inherits the override pointer', $override->getAddress(), (new ReflectionClass(MatrixChangedLeafSub::class))->getMethod('m')->getAddress()); +$check('the root code reaches its private method on the subclass too', 'root', $leafSub->viaRoot()); +$check('the override dispatches on the subclass', 'leaf', $leafSub->m()); + +// --- Overriding subclass linked BEFORE a preserving redefinition -------------------------------- +// MatrixEarlyChild overrides handle() and reaches the preserved body through parent::, so the +// alias has to exist in its table (and in the leaf's below it) or the preserved body fails on +// child instances. A subclass linked against a shared-memory entry keeps the shared parent after +// the copy-out and could never see the alias: the publication is refused before anything changes. +$earlyGrandChild = new MatrixEarlyGrandChild(); +$check('linked before: the override dispatches through parent::', 'child:base', $earlyGrandChild->handle()); +$earlyBase = new ReflectionClass(MatrixEarlyBase::class); +$earlyRefusal = null; +try { + $earlyBase->getMethod('handle')->redefine((new ReflectionClass(MatrixEarlyDonor::class))->getMethod('handle'), preserveAs: 'handleOriginal'); +} catch (SharedMemoryException $exception) { + $earlyRefusal = $exception; +} +if ($expectOpcache) { + $check('refused for the shared-memory class with linked subclasses', true, $earlyRefusal !== null); + $check( + 'the refusal names the subclass linked against the shared entry', + 1, + preg_match('/subclass .*MatrixEarly(Child|GrandChild) was linked against the shared entry/', $earlyRefusal instanceof SharedMemoryException ? $earlyRefusal->getMessage() : ''), + ); + $check('nothing was modified: no alias published', false, (new ReflectionClass(MatrixEarlyBase::class))->hasMethod('handleOriginal')); + $check('nothing was modified: the class is still shared', true, (new ReflectionClass(MatrixEarlyBase::class))->isImmutable()); + $check('nothing was modified: previous dispatch', 'child:base', (new MatrixEarlyChild())->handle()); + $check('nothing was modified: base dispatch', 'base', (new MatrixEarlyBase())->handle()); +} else { + $check('the alias was published', null, $earlyRefusal); + $earlyAlias = (new ReflectionClass(MatrixEarlyBase::class))->getMethod('handleOriginal'); + foreach ([MatrixEarlyChild::class, MatrixEarlyGrandChild::class] as $earlySubclass) { + $check("{$earlySubclass} publishes the alias pointer", $earlyAlias->getAddress(), (new ReflectionClass($earlySubclass))->getMethod('handleOriginal')->getAddress()); + } + $check('the alias is private', true, (new \ReflectionMethod(MatrixEarlyBase::class, 'handleOriginal'))->isPrivate()); + $check('three tables share the alias (one body share each)', 3, count(FunctionBodySwap::publishedShareHolders($earlyAlias))); + $check('the alias cache is request memory shared by the three tables', false, $hasHeapCache($earlyAlias)); + $check('base dispatch through the donor body', '[base]', (new MatrixEarlyBase())->handle()); + $check('the overriding subclass reaches the alias through parent::', 'child:[base]', (new MatrixEarlyChild())->handle()); + $check('the leaf below it too', 'child:[base]', $earlyGrandChild->handle()); +} + // --- Hot loop: warmed inline caches, no growth -------------------------------------------- $before = memory_get_usage(); $length = 0; @@ -108,6 +333,16 @@ $length += strlen($instance->greet('loop')); $length += strlen(Statics::label('l')); $length += strlen((new GrandDescendant())->greet('c')); + foreach ($fakeClosures as $fakeClosure) { + $fakeResult = $fakeClosure('loop'); + assert(is_string($fakeResult)); + $length += strlen($fakeResult); + } + $length += strlen($fakeTarget->greet('loop')); + $length += strlen($cloneChild->greet('loop')); + $length += $counterInstance->counter() > 0 ? 1 : 0; + $length += strlen($leafSub->viaRoot()) + strlen($leafSub->m()); + $length += strlen($earlyGrandChild->handle()); } $check('hot loop produced output', true, $length > 0); $check('hot loop stays memory-flat', true, memory_get_usage() - $before < 64 * 1024); diff --git a/tests/Stub/Alias/MatrixChangedDonor.php b/tests/Stub/Alias/MatrixChangedDonor.php new file mode 100644 index 00000000..e1f1b257 --- /dev/null +++ b/tests/Stub/Alias/MatrixChangedDonor.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Donor outside the hierarchy for the addMethod() override of m() on MatrixChangedLeaf: its + * own m() carries no CHANGED bit, the override inherits it from the replaced inherited entry + */ +class MatrixChangedDonor +{ + public function m(): string + { + return 'leaf'; + } +} diff --git a/tests/Stub/Alias/MatrixChangedLeaf.php b/tests/Stub/Alias/MatrixChangedLeaf.php new file mode 100644 index 00000000..6004feb4 --- /dev/null +++ b/tests/Stub/Alias/MatrixChangedLeaf.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Inherits the CHANGED MatrixChangedMid::m() and receives the addMethod() override of it + */ +class MatrixChangedLeaf extends MatrixChangedMid {} diff --git a/tests/Stub/Alias/MatrixChangedLeafSub.php b/tests/Stub/Alias/MatrixChangedLeafSub.php new file mode 100644 index 00000000..9da1a171 --- /dev/null +++ b/tests/Stub/Alias/MatrixChangedLeafSub.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Autoloaded after the override was published on MatrixChangedLeaf: inherits the published + * entry pointer, CHANGED bit included + */ +class MatrixChangedLeafSub extends MatrixChangedLeaf {} diff --git a/tests/Stub/Alias/MatrixChangedMid.php b/tests/Stub/Alias/MatrixChangedMid.php new file mode 100644 index 00000000..0e9aae4c --- /dev/null +++ b/tests/Stub/Alias/MatrixChangedMid.php @@ -0,0 +1,25 @@ + + * + * 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\Stub\Alias; + +/** + * Redeclares the private MatrixChangedRoot::m(): the engine marks this m() ZEND_ACC_CHANGED + */ +class MatrixChangedMid extends MatrixChangedRoot +{ + public function m(): string + { + return 'mid'; + } +} diff --git a/tests/Stub/Alias/MatrixChangedRoot.php b/tests/Stub/Alias/MatrixChangedRoot.php new file mode 100644 index 00000000..50c38846 --- /dev/null +++ b/tests/Stub/Alias/MatrixChangedRoot.php @@ -0,0 +1,36 @@ + + * + * 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\Stub\Alias; + +/** + * Root of the matrix ZEND_ACC_CHANGED scenario (redefine-alias-matrix.php): declares a private + * m() and calls it from its own scope + * + * m() reads a property so that the opcache optimizer cannot fold the `$this->m()` call site + * in viaRoot() at cache time (a private method returning a literal is inlined). + */ +class MatrixChangedRoot +{ + public function __construct(private string $tag = 'root') {} + + private function m(): string + { + return $this->tag; + } + + public function viaRoot(): string + { + return $this->m(); + } +} diff --git a/tests/Stub/Alias/MatrixCloneBase.php b/tests/Stub/Alias/MatrixCloneBase.php new file mode 100644 index 00000000..01d8a8cd --- /dev/null +++ b/tests/Stub/Alias/MatrixCloneBase.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Base class of the matrix interface-clone scenario: greet() is redefined twice, with the + * subclass MatrixCloneChild linked in between + */ +class MatrixCloneBase +{ + public function __construct(public string $name = 'base') {} + + public function greet(string $who): string + { + return strtoupper($who) . ' from ' . $this->name . str_repeat('.', 2); + } +} diff --git a/tests/Stub/Alias/MatrixCloneChild.php b/tests/Stub/Alias/MatrixCloneChild.php new file mode 100644 index 00000000..b9bf9d28 --- /dev/null +++ b/tests/Stub/Alias/MatrixCloneChild.php @@ -0,0 +1,20 @@ + + * + * 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\Stub\Alias; + +/** + * Autoloaded after the first redefinition of MatrixCloneBase::greet(): the interface prototype + * gives this class an engine clone of the inherited entry instead of the entry pointer + */ +class MatrixCloneChild extends MatrixCloneBase implements MatrixCloneContract {} diff --git a/tests/Stub/Alias/MatrixCloneContract.php b/tests/Stub/Alias/MatrixCloneContract.php new file mode 100644 index 00000000..6115b615 --- /dev/null +++ b/tests/Stub/Alias/MatrixCloneContract.php @@ -0,0 +1,24 @@ + + * + * 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\Stub\Alias; + +/** + * Interface declaring the method MatrixCloneChild inherits from MatrixCloneBase: implementing + * it makes the engine separate the inherited bucket into a struct clone of the entry + * (matrix interface-clone scenario, redefine-alias-matrix.php) + */ +interface MatrixCloneContract +{ + public function greet(string $who): string; +} diff --git a/tests/Stub/Alias/MatrixCloneDonorV1.php b/tests/Stub/Alias/MatrixCloneDonorV1.php new file mode 100644 index 00000000..b6e971fc --- /dev/null +++ b/tests/Stub/Alias/MatrixCloneDonorV1.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * First donor body for MatrixCloneBase::greet() (installed before the subclass links) + */ +class MatrixCloneDonorV1 +{ + public string $name = 'donor1'; + + public function greet(string $who): string + { + return 'v1:' . strtoupper($who) . ' from ' . $this->name . str_repeat('.', 2); + } +} diff --git a/tests/Stub/Alias/MatrixCloneDonorV2.php b/tests/Stub/Alias/MatrixCloneDonorV2.php new file mode 100644 index 00000000..188341da --- /dev/null +++ b/tests/Stub/Alias/MatrixCloneDonorV2.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Second donor body for MatrixCloneBase::greet() (the swap the interface clone must follow) + */ +class MatrixCloneDonorV2 +{ + public string $name = 'donor2'; + + public function greet(string $who): string + { + return 'v2:' . strtoupper($who) . ' from ' . $this->name . str_repeat('.', 2); + } +} diff --git a/tests/Stub/Alias/MatrixEarlyBase.php b/tests/Stub/Alias/MatrixEarlyBase.php new file mode 100644 index 00000000..8ead9d63 --- /dev/null +++ b/tests/Stub/Alias/MatrixEarlyBase.php @@ -0,0 +1,28 @@ + + * + * 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\Stub\Alias; + +/** + * Base of the matrix early-overriding-subclass scenario (redefine-alias-matrix.php): handle() is + * redefined with preserveAs AFTER MatrixEarlyChild and MatrixEarlyGrandChild were linked + */ +class MatrixEarlyBase +{ + public function __construct(private string $tag = 'base') {} + + public function handle(): string + { + return $this->tag; + } +} diff --git a/tests/Stub/Alias/MatrixEarlyChild.php b/tests/Stub/Alias/MatrixEarlyChild.php new file mode 100644 index 00000000..34ccc6de --- /dev/null +++ b/tests/Stub/Alias/MatrixEarlyChild.php @@ -0,0 +1,26 @@ + + * + * 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\Stub\Alias; + +/** + * Linked BEFORE the preserving redefinition of MatrixEarlyBase::handle(); overrides it and + * reaches the preserved body through parent::, which calls the alias on this instance + */ +class MatrixEarlyChild extends MatrixEarlyBase +{ + public function handle(): string + { + return 'child:' . parent::handle(); + } +} diff --git a/tests/Stub/Alias/MatrixEarlyDonor.php b/tests/Stub/Alias/MatrixEarlyDonor.php new file mode 100644 index 00000000..92ba4d91 --- /dev/null +++ b/tests/Stub/Alias/MatrixEarlyDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Donor body for MatrixEarlyBase::handle(), calling the preserved alias + * + * @method string handleOriginal() the preserved MatrixEarlyBase::handle() body + */ +class MatrixEarlyDonor +{ + public function handle(): string + { + return '[' . $this->handleOriginal() . ']'; + } +} diff --git a/tests/Stub/Alias/MatrixEarlyGrandChild.php b/tests/Stub/Alias/MatrixEarlyGrandChild.php new file mode 100644 index 00000000..6d233bc9 --- /dev/null +++ b/tests/Stub/Alias/MatrixEarlyGrandChild.php @@ -0,0 +1,19 @@ + + * + * 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\Stub\Alias; + +/** + * Leaf below the overriding MatrixEarlyChild, linked before the redefinition as well + */ +class MatrixEarlyGrandChild extends MatrixEarlyChild {} diff --git a/tests/Stub/Alias/MatrixFakeClosureDonorV1.php b/tests/Stub/Alias/MatrixFakeClosureDonorV1.php new file mode 100644 index 00000000..daf450ca --- /dev/null +++ b/tests/Stub/Alias/MatrixFakeClosureDonorV1.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * First donor body for MatrixFakeClosureTarget::greet() (the body the fake closures are made over) + */ +class MatrixFakeClosureDonorV1 +{ + public string $name = 'donor1'; + + public function greet(string $who): string + { + return 'v1:' . strtoupper($who) . ' from ' . $this->name . str_repeat('.', 2); + } +} diff --git a/tests/Stub/Alias/MatrixFakeClosureDonorV2.php b/tests/Stub/Alias/MatrixFakeClosureDonorV2.php new file mode 100644 index 00000000..6b893fd6 --- /dev/null +++ b/tests/Stub/Alias/MatrixFakeClosureDonorV2.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Second donor body for MatrixFakeClosureTarget::greet() (the swap that releases the shared cache) + */ +class MatrixFakeClosureDonorV2 +{ + public string $name = 'donor2'; + + public function greet(string $who): string + { + return 'v2:' . strtoupper($who) . ' from ' . $this->name . str_repeat('.', 2); + } +} diff --git a/tests/Stub/Alias/MatrixFakeClosureTarget.php b/tests/Stub/Alias/MatrixFakeClosureTarget.php new file mode 100644 index 00000000..457bd98b --- /dev/null +++ b/tests/Stub/Alias/MatrixFakeClosureTarget.php @@ -0,0 +1,31 @@ + + * + * 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\Stub\Alias; + +/** + * Target of the matrix fake-closure scenario (redefine-alias-matrix.php): fake closures made over + * greet() after its first redefinition share the minted run-time cache of the entry + * + * The body reads a property and calls an internal function so that the warmed inline caches + * (property offset, function pointer) are what the shared cache block holds. + */ +class MatrixFakeClosureTarget +{ + public function __construct(public string $name = 'target') {} + + public function greet(string $who): string + { + return strtoupper($who) . ' from ' . $this->name . str_repeat('.', 2); + } +} diff --git a/tests/Stub/Alias/MatrixStaticsCounter.php b/tests/Stub/Alias/MatrixStaticsCounter.php new file mode 100644 index 00000000..a61529b9 --- /dev/null +++ b/tests/Stub/Alias/MatrixStaticsCounter.php @@ -0,0 +1,30 @@ + + * + * 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\Stub\Alias; + +/** + * Counter of the matrix statics scenario (redefine-alias-matrix.php): redefined with a closure + * declaring a static, then with a method donor preserving the closure body under an alias, + * then swapped back from the alias before the alias is removed + */ +class MatrixStaticsCounter +{ + public function counter(): int + { + /** @var int $n */ + static $n = 0; + + return ++$n; + } +} diff --git a/tests/Stub/Alias/MatrixStaticsCounterDonor.php b/tests/Stub/Alias/MatrixStaticsCounterDonor.php new file mode 100644 index 00000000..956570d5 --- /dev/null +++ b/tests/Stub/Alias/MatrixStaticsCounterDonor.php @@ -0,0 +1,27 @@ + + * + * 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\Stub\Alias; + +/** + * Method donor for MatrixStaticsCounter::counter(), delegating to the preserved alias + * + * @method int counterOriginal() the preserved closure body of MatrixStaticsCounter::counter() + */ +class MatrixStaticsCounterDonor +{ + public function counter(): int + { + return 100 + $this->counterOriginal(); + } +}