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..2bc0eac3 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 @@ -50,6 +54,153 @@ 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`). +- **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 + 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 **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) + +`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`) - 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:: +$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. + +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 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`) ```php @@ -129,7 +280,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`. | @@ -194,10 +345,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 @@ -216,8 +372,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 @@ -231,4 +390,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/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/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/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 a0381350..715929ee 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; @@ -31,23 +33,31 @@ * 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 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 - * 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. * @@ -80,6 +90,23 @@ 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 = []; + + /** + * 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 */ @@ -103,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 */ @@ -113,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 @@ -157,90 +189,84 @@ 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); return new PendingBodySwap( $entryFunction, $previousBody, - $entryAddress, $publishedShares, $destroyPrevious, + $previousMintedCache, + $publications, ); } /** - * Counts the published table buckets that point at the given method entry structure + * Returns the user classes whose method tables publish the given entry structure + * under its own name, in a class-table walk of its own (see EntryPublications::holdersOf()) * - * 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. + * @return list * * @internal */ - public static function countPublishedShares(FunctionLikeInterface $entryFunction): int + public static function publishedShareHolders(FunctionLikeInterface $entryFunction): array { - $entryCommon = $entryFunction->getCommonPointer(); - if ($entryCommon->scope === null) { - return 1; - } - $namePointer = $entryCommon->function_name; - assert($namePointer instanceof CData); - $lowerName = strtolower(StringEntry::fromCData($namePointer)->getStringValue()); - $entryAddress = $entryFunction->getAddress(); - - $shares = 0; - $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 && Core::addressOf($bucketValue->getRawFunction()) === $entryAddress) { - $shares++; - } - } + return EntryPublications::survey($entryFunction)->holdersOf($entryFunction); + } - return max($shares, 1); + /** + * Returns the engine clones of a method entry published in the tables of its subclasses, + * each with the class holding it, in a class-table walk of its own (see + * EntryPublications::clonesOf()) + * + * @return list + * + * @internal + */ + public static function cloneHolders(FunctionLikeInterface $entryFunction): array + { + return EntryPublications::survey($entryFunction)->clonesOf($entryFunction); } /** * 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 $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 + * @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))); $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; @@ -249,7 +275,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; + } } /** @@ -338,25 +368,121 @@ 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, 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 { - $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 + * @param EntryPublications|null $publications Survey taken for the swap (see swapUserFunctionBody()) + * + * @internal called by PendingBodySwap::commit() + */ + 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::liveClosureFunctions(), + $publications ?? EntryPublications::survey($entryFunction), + ); + } + + /** + * 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 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, + array $liveClosures, + EntryPublications $publications, + ): void { + $cacheAddress = Core::addressOf($cache); + if (self::hasLiveFrameUsingCache($cacheAddress)) { + return; + } + foreach ($liveClosures 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 ($publications->clonesOf($entryFunction) as [, $clone]) { + $cloneCache = $clone->getOpArrayPointer()->run_time_cache__ptr; + if ($cloneCache !== null && Core::addressOf($cloneCache) === $cacheAddress) { + self::installFreshRunTimeCache($clone); + } + } + Core::free($cache); } /** @@ -397,23 +523,50 @@ 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 + * @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()) + * @param EntryPublications|null $publications Survey taken for the swap (see swapUserFunctionBody()) * * @internal called by PendingBodySwap::commit() */ - public static function destroyPreviousBody(object $previousBody, int $entryAddress, int $releasedShares): void - { + public static function destroyPreviousBody( + FunctionLikeInterface $entryFunction, + object $previousBody, + int $releasedShares, + ?CData $previousMintedCache = null, + ?EntryPublications $publications = null, + ): void { + $entryAddress = $entryFunction->getAddress(); $previousFunction = ReflectionFunction::fromCData(Core::cast('zend_function *', Core::addr($previousBody))); $previousOpArray = $previousFunction->getOpArrayPointer(); @@ -426,15 +579,15 @@ public static function destroyPreviousBody(object $previousBody, int $entryAddre // redefinition, see docs/hot-swap.md. return; } + $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 HEAP_RT_CACHE run-time cache - is - // ordinary request memory though, and is released below exactly like for a - // refcounted body, or repeated swaps whose donors were declared in a cached - // file leak one cache per swap (the fixed-donor plateau of issue #242 under - // opcache). + // 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) { @@ -449,12 +602,30 @@ public static function destroyPreviousBody(object $previousBody, int $entryAddre $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)); - 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 + if ($destroysLiveTable) { + // Frees the materialized live static-variables table and clears the map slot Core::call('zend_destroy_static_vars', $rawOpArray); } @@ -472,6 +643,119 @@ public static function destroyPreviousBody(object $previousBody, int $entryAddre 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)) { + return; + } + $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(); + } + + if ($mintedCache !== null) { + unset(self::$mintedRunTimeCaches[$entryAddress]); + self::releaseRetiredRunTimeCache($entryFunction, $mintedCache, $liveClosures, $publications); + $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 + * @param list $liveClosures The functions of the live closures, see liveClosureFunctions() + */ + private static function isLiveStaticsBorrowed( + FunctionLikeInterface $entryFunction, + object $liveTable, + array $liveClosures, + EntryPublications $publications, + ): bool { + $tableAddress = Core::addressOf($liveTable); + foreach ($liveClosures as $closureFunction) { + $closureSlot = $closureFunction->getOpArrayPointer()->static_variables_ptr__ptr; + if ($closureSlot !== null && Core::addressOf($closureSlot) === $tableAddress) { + return true; + } + } + foreach ($publications->clonesOf($entryFunction) as [, $clone]) { + $cloneSlot = $clone->getOpArrayPointer()->static_variables_ptr__ptr; + if ($cloneSlot !== null && Core::addressOf($cloneSlot) === $tableAddress) { + return true; + } + } + + return false; + } + + /** + * 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, and no user-function view can be built over it), so the list is narrowed + * to those before any view is made. 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 list + */ + 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()); + // The type is read off the union before any view is built over it: a closure + // over an internal function (strlen(...), Closure::fromCallable('strlen')) keeps + // its name in the internal_function member, where a zend_function view has none + if (($closure->func->type & Core::ZEND_USER_FUNCTION) === 0) { + continue; + } + $liveClosures[] = ReflectionFunction::fromCData(Core::addr($closure->func)); + } + + return $liveClosures; + } + /** * Checks if any frame of the current VM call stack executes the given entry * @@ -495,21 +779,56 @@ 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 * + * @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 */ public static function releaseSwappedInBody( FunctionLikeInterface $entryFunction, int $publishedShares, + ?CData $previousMintedCache = null, + ?EntryPublications $publications = 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) { + self::releaseRetiredRunTimeCache( + $entryFunction, + $mintedCache, + self::liveClosureFunctions(), + $publications ?? EntryPublications::survey($entryFunction), + ); + } + 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..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,21 @@ 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()) + */ + 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..e39af40f 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; @@ -171,54 +172,206 @@ private function replaceFunctionFlags(int $clearMask, int $setMask): void } /** - * Redefines an existing method in the class with closure + * Replaces the body of this function/method with the body of a closure or of another + * user function/method (the donor), in place * - * 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. + * 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`. * - * @internal + * 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)); + + // 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 (!$this->isInternal()) { + 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(); - - $isSharedMemoryEntry = $this->copyEntryOutOfSharedMemory(); + $donorFunction = ReflectionFunction::fromCData(Core::addr($closureEntry->getRawFunction())); + } else { + $this->ensureAcceptableDonor($newBody); + $donorFunction = $newBody; + } - $entryFunction = ReflectionFunction::fromCData($this->pointer); - $donorFunction = ReflectionFunction::fromCData(Core::addr($newFunction)); - FunctionBodySwap::swapUserFunctionBody( + 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); + + $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); + } + /** @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), - )->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); - }; + publications: $publications, + ); + // A subclass implementing an interface that declares the method holds an engine + // 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 ($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. + // (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( + $clone, + $donorFunction, + preserveDeclaration: true, + // The clone was copied from the entry: a shared-memory body (no refcount) is immortal + destroyPrevious: $clone->getOpArrayPointer()->refcount !== null, + publications: $publications, + ); + } + } catch (\Throwable $error) { + foreach (array_reverse($pendingSwaps) as $pendingSwap) { + $pendingSwap->rollback(); + } + if ($unpublishAlias !== null) { + $unpublishAlias(); + } + throw $error; + } + foreach ($pendingSwaps as $pendingSwap) { + $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); } /** @@ -611,6 +764,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(); @@ -689,60 +870,112 @@ 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) * - * @throws \ReflectionException if closure signature is not compatible with current function/method + * 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 RedefinitionException if the donor signature is not compatible with this entry */ - private function ensureCompatibleClosure(\Closure $newCode): void + private function ensureCompatibleSignature(\ReflectionFunctionAbstract $donor): void { - /** @var \ReflectionFunction[] $reflectionPair */ - $reflectionPair = [$this, new \ReflectionFunction($newCode)]; - $signatures = []; - foreach ($reflectionPair as $index => $reflectionFunction) { - $signature = 'function '; - if ($reflectionFunction->returnsReference()) { - $signature .= '&'; + $entrySignature = self::renderSignature($this); + $donorSignature = self::renderSignature($donor); + + if ($entrySignature !== $donorSignature) { + throw RedefinitionException::signatureMismatch($entrySignature, $donorSignature); + } + } + + /** + * 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 + { + $scope = $function instanceof \ReflectionMethod + ? $function->getDeclaringClass() + : ($function instanceof \ReflectionFunction ? $function->getClosureScopeClass() : null); + + $signature = 'function '; + if ($function->returnsReference()) { + $signature .= '&'; + } + $signature .= '('; + $parameters = []; + foreach ($function->getParameters() as $reflectionParameter) { + $parameter = ''; + $type = $reflectionParameter->getType(); + if ($type !== null) { + $parameter .= self::renderType($type, $scope) . ' '; } - $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; + } + $signature .= join(', ', $parameters); + $signature .= ')'; + $returnType = $function->getReturnType(); + if ($returnType !== null) { + $signature .= ': ' . self::renderType($returnType, $scope); } - if ($signatures[0] !== $signatures[1]) { - throw new \ReflectionException( - 'Given function signature: "' . $signatures[1] . '"' . - ' should be compatible with original "' . $signatures[0] . '"', - ); + 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; } /** diff --git a/src/Reflection/MethodPublisher.php b/src/Reflection/MethodPublisher.php new file mode 100644 index 00000000..4f54d632 --- /dev/null +++ b/src/Reflection/MethodPublisher.php @@ -0,0 +1,656 @@ + + * + * 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; + } + // 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(), + ); + } + } + + /** + * 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, strtolower($methodName), $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. 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 + * 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), + ); + } + + // 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, 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 + $aliasMethod->setPrivate(); + $aliasMethod->setFinal(false); + $aliasMethod->setConstructorFlag(false); + $aliasMethod->setChangedFlag($inheritedAlias !== null); + $aliasMethod->setPrototype(null); + + /** @var list<\Closure(): void> $undoStack */ + $undoStack = []; + /** @var list $retired */ + $retired = []; + try { + $published = self::publishOwnEntry($table, $lowerAlias, $container, $inheritedAlias, $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); + 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, inheritLiveStatics: true); + 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 - 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 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 + { + 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, $lowerName, $inherited); + } + + $container = self::newContainer(); + $method = self::adopt($container, $donor, $class, $methodName); + if ($inherited !== null) { + // 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, $retired); + self::followMagicSlot($descendant, $inherited, $published, $undoStack); + } + } + } catch (\Throwable $error) { + self::unwind($undoStack); + self::releaseContainer($container); + throw $error; + } + self::retire($retired); + + 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, + bool $inheritLiveStatics = false, + ): ReflectionMethod { + FunctionBodySwap::adoptFunctionForPublishing($container, $donor, $scope, $inheritLiveStatics); + $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. 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); + 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)); + }; + $retired[] = $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 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, string $lowerName, ReflectionMethod $inherited): array + { + $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 ($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( + 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 + { + return $class->getLinkedSubclasses(); + } + + /** + * 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..aa19ef2f 100644 --- a/src/Reflection/PendingBodySwap.php +++ b/src/Reflection/PendingBodySwap.php @@ -37,16 +37,20 @@ 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 + * 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, private CData $previousBody, - private int $entryAddress, private int $publishedShares, private bool $destroyPrevious, + private ?CData $previousMintedCache = null, + private ?EntryPublications $publications = null, ) {} /** @@ -57,7 +61,16 @@ public function commit(): void $this->assertUnresolved(); $this->isResolved = true; if ($this->destroyPrevious) { - FunctionBodySwap::destroyPreviousBody($this->previousBody, $this->entryAddress, $this->publishedShares); + FunctionBodySwap::destroyPreviousBody( + $this->entry, + $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, $this->publications); } } @@ -72,7 +85,7 @@ public function rollback(): void { $this->assertUnresolved(); $this->isResolved = true; - FunctionBodySwap::releaseSwappedInBody($this->entry, $this->publishedShares); + 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/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..56087fdd 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; @@ -258,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 */ @@ -488,15 +568,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 +1025,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 +1045,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) * @@ -979,7 +1115,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/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/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) */ 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/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..fdfc6442 --- /dev/null +++ b/tests/HotSwap/scripts/redefine-alias-matrix.php @@ -0,0 +1,353 @@ + + * + * 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\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; +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\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 + * + * 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"); + } +}; +// 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); +$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)); + +// --- 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; +for ($iteration = 0; $iteration < 3000; $iteration++) { + $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); + +if ($failures > 0) { + exit(1); +} +echo "redefine-alias-matrix: ok\n"; 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/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/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/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/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/Reflection/RedefineLiveInternalClosureTest.php b/tests/Reflection/RedefineLiveInternalClosureTest.php new file mode 100644 index 00000000..0457a786 --- /dev/null +++ b/tests/Reflection/RedefineLiveInternalClosureTest.php @@ -0,0 +1,67 @@ + + * + * 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\Stub\Alias\InternalClosureCounter; + +/** + * A swap walks the live closures to find the copies of the entry (fake closures over it + * share its run-time cache and borrow its live statics). Closures over INTERNAL functions + * are live objects too - a first-class callable of strlen(), Closure::fromCallable() over + * an internal function, the closure of an internal method - and carry no user op_array: the + * walk must skip them before building a user-function view, which has no name field to + * read on an internal entry (it threw "Attempt to read undefined field 'function_name'" + * and failed every redefine() while such a closure was alive; PHP 8.6 keeps such closures + * in its callable caches, so there it failed always). + */ +final class RedefineLiveInternalClosureTest extends TestCase +{ + public function testSwapsRemovalsAndStaticsReleaseIgnoreClosuresOverInternalFunctions(): void + { + $firstClass = strlen(...); + $callable = \Closure::fromCallable('strtoupper'); + $method = (new \ReflectionMethod(\ArrayObject::class, 'count'))->getClosure(new \ArrayObject([1, 2])); + + $entry = (new ReflectionClass(InternalClosureCounter::class))->getMethod('counter'); + $entry->redefine(function (): int { + /** @var int $n */ + static $n = 10; + + return ++$n; + }); + // Instantiated after the first swap: under the opcache-active runner the class is + // copied out of shared memory by it, and instances made before keep the shared class + $instance = new InternalClosureCounter(); + self::assertSame(11, $instance->counter(), 'the live statics table of the swapped body materializes'); + + // Every release path that walks the live closures: the minted cache of the previous + // body (second swap), the live statics the preserved alias takes over, and what + // removeMethods() retires + $entry->redefine(function (): int { + /** @var int $n */ + static $n = 20; + + return ++$n; + }, preserveAs: 'counterPrevious'); + self::assertSame(21, $instance->counter()); + self::assertSame(22, $instance->counter()); + (new ReflectionClass(InternalClosureCounter::class))->removeMethods('counterPrevious'); + self::assertSame(23, $instance->counter()); + + self::assertSame(3, $firstClass('abc'), 'the closures over internal functions are untouched'); + self::assertSame('ABC', $callable('abc')); + self::assertSame(2, $method()); + } +} 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/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/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/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/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/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/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; + } +} 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'; + } +} 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 {} 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/InternalClosureCounter.php b/tests/Stub/Alias/InternalClosureCounter.php new file mode 100644 index 00000000..da489021 --- /dev/null +++ b/tests/Stub/Alias/InternalClosureCounter.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 redefined while closures over internal functions are alive + * (RedefineLiveInternalClosureTest) + */ +class InternalClosureCounter +{ + public function counter(): int + { + /** @var int $n */ + static $n = 0; + + return ++$n; + } +} 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/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(); + } +} 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(); + } +} 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/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(); + } +} 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/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; + } +} 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; +} diff --git a/tests/System/Hook/ErrorCallbackHookTest.php b/tests/System/Hook/ErrorCallbackHookTest.php index 5ff81013..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([ @@ -48,8 +49,8 @@ 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->assertNotNull($entry); $this->assertSame(E_USER_WARNING, $entry['type']); $this->assertSame(__FILE__, $entry['file']); $this->assertSame($expectedLine, $entry['line']);