Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
05ef32f
feat(reflection): preserve the previous body of redefine() under an a…
lisachenko Oct 10, 2026
31261d2
test: keep the test suite clean under PHPStan 2.3
lisachenko Oct 10, 2026
ed9548b
fix(reflection): keep engine copies of a swapped entry valid and rele…
lisachenko Oct 10, 2026
287c55a
docs(hot-swap): describe the engine copies a swap must respect and wh…
lisachenko Oct 10, 2026
5966b08
test(reflection): pin the run-time cache release against fake closure…
lisachenko Oct 10, 2026
b1f400d
test(reflection): keep the mid-flight scenario's state on the test class
lisachenko Oct 10, 2026
3fa313b
test(reflection): pin the ownership of the statics defaults a swap mi…
lisachenko Oct 10, 2026
004b48f
test(reflection): pin the preserved alias in the tables of every subc…
lisachenko Oct 10, 2026
6761937
test(reflection): pin the live statics a preserved alias takes over a…
lisachenko Oct 10, 2026
615d9b3
fix(reflection): judge immutability by the owning class, not the entr…
claude Oct 10, 2026
e39e2dd
test(reflection): cover ZEND_ACC_CHANGED propagation of addMethod() o…
claude Oct 10, 2026
5aea050
test(reflection): cover interface-induced engine clones of a redefine…
claude Oct 10, 2026
09acee1
test(reflection): cover a closure-backed method used as a method donor
claude Oct 10, 2026
43f8f41
test(hot-swap): extend the alias matrix with the destructor-path scen…
claude Oct 10, 2026
5669853
perf(reflection): survey the tables once per swap instead of once per…
claude Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,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
Expand Down
197 changes: 187 additions & 10 deletions docs/hot-swap.md

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions src/HotSwap/CacheImageSync.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,6 @@ public function apply(): CacheImageSyncReport
duplicateStatics: false,
// A shared-memory previous body is immortal and must not be freed
destroyPrevious: !$this->functionWasShared[$functionName],
publishedShares: FunctionBodySwap::countPublishedShares($entryFunction),
);
}
foreach ($methodDonors as $classKey => $donors) {
Expand All @@ -259,7 +258,6 @@ public function apply(): CacheImageSyncReport
preserveDeclaration: true,
duplicateStatics: false,
destroyPrevious: !$this->classWasShared[$classKey],
publishedShares: FunctionBodySwap::countPublishedShares($entryMethod),
);
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/HotSwap/ClassDelta.php
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,6 @@ public function apply(): void
// The donor body refcount guards the statics defaults table
duplicateStatics: false,
destroyPrevious: true,
publishedShares: FunctionBodySwap::countPublishedShares($entry),
);
$pendingSwaps[] = $pending;
$undoStack[] = static function () use ($pending): void {
Expand Down Expand Up @@ -541,7 +540,8 @@ private static function releaseAdoptedContainer(object $container): void
$containerFunction,
$containerFunction->getAddress(),
1,
false,
// The adoption duplicated the donor's statics defaults when the donor owned them
FunctionBodySwap::hasMintedStaticDefaults($containerFunction->getAddress()),
null,
);
Core::untrackAndFree(Core::addr($container));
Expand Down
19 changes: 19 additions & 0 deletions src/OpCache/SharedMemoryException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
217 changes: 217 additions & 0 deletions src/Reflection/EntryPublications.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
<?php

/**
* Z-Engine framework
*
* @copyright Copyright 2019, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* This 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<array{int, int, int|null}> $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<ReflectionClass>
*/
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<array{ReflectionClass, ReflectionMethod}>
*/
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<int, list<int>> $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<int> $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));
}
}
Loading
Loading