feat(memory): allocator seam for persistent cloning + external arData install (closes #223) - #224
Merged
Merged
Conversation
Every persistent structure z-engine mints allocated through a hardcoded Core::trackedNew(..., persistent) - FFI's pemalloc, i.e. the process heap - which a consumer that keeps its objects in a fork-shared mmap arena cannot redirect. The new ZEngine\Memory\Allocator interface is that seam: it hands out ADDRESSES of zeroed, aligned blocks (no FFI\CData crosses the boundary, so an arena implementation in a consumer package binds its own mmap primitives and returns plain integers) and reports whether it keeps ownership of what it hands out. EngineAllocator is the default implementation and reproduces the three allocation shapes the framework used to hardcode - tracked persistent, tracked request-lifetime and untracked persistent - so a caller that passes nothing gets byte-for-byte the behavior it had before. HashTable's constructor takes the optional allocator and refuses destroy() when the memory belongs to a foreign allocator: both frees assume z-engine's own allocator and would corrupt an arena. PersistentHashTable can now be handed the OTHER half of a table's memory: installExternalStorage() writes the engine's own zend_hash_real_init_mixed layout (hash part of two uint32_t slots per bucket reset to HT_INVALID_IDX, buckets right behind it) over a caller-allocated block, before the first insert makes the engine allocate storage of its own. Because the engine grows a full table by perealloc()ing that very block, growth is guarded from both sides: inserts through the wrapper refuse the write that would trigger the resize, and assertNoGrowth() diagnoses a relocation that engine paths caused behind the wrapper's back. Refs #223 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
The three primitives every persistent graph is built from - the object clones of PersistentObjectFactory, the interned blocks of StringEntry::persistent(Interned) and the tables of PersistentGraphCloner - now take an optional allocator and pass it on, so a whole graph can be cloned into memory the caller owns. Passing nothing keeps each primitive on its own historical default, which is deliberately not the same allocator for all of them: object clones and table structs stay tracked malloc blocks, string blocks stay untracked ones, exactly as before. Core::offsetOfField() is the named form of the type(...)->getStructFieldOffset(...) pair the string minting needed - the same remedy sizeOfType() is for sizeof(type(...)), and one raw FFI\CType less in the API surface. Covered by an arena-shaped test allocator (bump pointer over one owned region, reporting ownership): clones, strings and whole cyclic graphs land inside the region, nothing of them reaches z-engine's block registry, and a table built on it refuses to be freed. The external bucket storage is covered end to end - layout math against HT_SIZE_EX, lookups through the installed hash part, the capacity guard at the resize boundary, upserts still allowed when full, and the relocation diagnosis. Refs #223 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…s need Packages built on z-engine had to reach through Core::$executor for two operations that have no public equivalent: registering an object the engine did not allocate (and handing its slot back), and probing the engine class table for a class name. Both are core-layer state per AGENTS.md, and the remedy for that is a named public method here rather than a reach-through there. ObjectStore::current() is the public entry point into EG(objects_store); register()/unregister() are the CData-free, ownership-explicit forms of put()/recycle() - the store never takes ownership, so the caller keeps the object memory and gives the slot back before releasing it. ReflectionClass::fromClassTable() answers "is this class already in the engine?" without autoloading and without throwing, which is what a re-attachment path needs before it trusts recorded class metadata. Refs #223 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
lisachenko
commented
Aug 15, 2026
…eview ObjectStore::current() re-exposed Core::$executor->objectStore through a side door, and the register()/unregister() pair was only reachable through it - the trio recreated the very reach-through it was meant to remove, with an extra hop. The operations belong to the object being registered, not to a store handed out to callers. ObjectEntry::register() registers THIS object in the current request's store and returns the handle the engine assigned; unregister() reads the object's handle at call time and returns that slot to the free list. The slot is verified to actually hold this object first, so a stale handle - never registered, already unregistered, or a slot meanwhile reused - is refused with a named domain exception instead of silently detaching somebody else's object. ObjectStore is byte-for-byte what it was before this PR: put() and recycle() stay @internal, and a consumer never touches EG(objects_store) at all. Refs #223 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
The registration test released the clone's memory while a value materialized
from it was still in scope. That value is a real PHP alias of the very same
zend_object, so destroying it at the end of the test method made the engine
write a refcount into a block that had already been handed back to malloc -
and gc.refcount sits at offset 0 of a zend_object, which is exactly where
glibc keeps the tcache next pointer of a free chunk. The next persistent
clone of the same size popped that chunk and the allocator aborted, far away
from the mistake:
malloc(): unaligned tcache chunk detected (SIGABRT, exit 134)
Repro (deterministic, aborts during the SECOND test of the class - the one
whose clone lands in the poisoned size class):
MALLOC_CHECK_=3 MALLOC_PERTURB_=85 php8.4 -d ffi.enable=1 \
-d opcache.jit=off vendor/bin/phpunit --filter ObjectEntryRegistration
The registration path itself is sound: unregister() bounds-checks the handle
against the store top and validates the bucket through the store's own
tagged-pointer check before it compares identity, so it never dereferences a
free-list sentinel. What was missing is the other half of the ownership
contract, now spelled out on register(): registration makes the object
reachable from userland, and every alias materialized from it must be gone
before the memory is released, not just the store slot.
Refs #223
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Closes #223 (PR-Z1 of the zero-serialization PHP coroutines initiative).
Today every persistent byte z-engine mints funnels through a hardcoded
Core::trackedNew(..., persistent)— FFI'spemalloc, i.e. the process-local heap. A consumer that keeps real PHP objects in a fork-shared mmap arena (lisachenko/php-shared-data-extension, EPIC lisachenko/php-shared-data-extension#15) cannot redirect that, and a table the engine grows on its own drags half of itself back into process-local memory. This PR opens both seams and removes the reach-throughs that consumer needed.1. Allocator seam.
ZEngine\Memory\Allocatorhands out addresses of zeroed, aligned blocks and reports whether it keeps ownership of them.EngineAllocatoris the default implementation and reproduces the three allocation shapes the framework used to hardcode —trackedPersistent(),trackedRequest(),persistent()(untracked). An optional allocator is threaded through:PersistentObjectFactory::persistentClone($object, ?Allocator)StringEntry::persistent()/persistentInterned()HashTable::__construct(?Allocator)(inherited byPersistentHashTable)PersistentGraphCloner::__construct(?Allocator)— one allocator for a whole graphNull keeps each primitive on its own historical default, which is deliberately not the same allocator for all of them (object clones and table structs stay tracked malloc blocks, string blocks stay untracked ones). Zero behavior change for existing callers; no existing test needed a line changed.
2. External arData install.
PersistentHashTable::installExternalStorage($address, $capacity)writes the engine's ownzend_hash_real_init_mixedlayout over a caller-allocated block —HT_SIZE_EXsizing viaexternalStorageSize(), hash part of twouint32_tslots per bucket reset toHT_INVALID_IDX, buckets right behind it,HASH_FLAG_UNINITIALIZEDreplaced byHASH_FLAG_STATIC_KEYSwhile the iterator count is left alone. Installation is only legal before the first insert, so the engine never real-initializes storage of its own.withExternalStorage()pairs it with the struct allocator in one call.Growth is guarded from both sides, since the engine grows a full table by
pereallocing exactly that block:getRemainingCapacity()reports the headroom; an upsert of an existing key consumes no slot and stays allowed at full capacity);assertNoGrowth()verifiesarData/capacity are still the installed ones — for engine paths this class cannot intercept — and runs after every insert.destroy()refuses on both foreign-allocated structs and installed external storage:zend_hash_destroy()wouldpefreethe caller's block and the struct free assumes z-engine's allocator.3. Public-API remedies for the
@internal/Core::$executorreach-throughs inphp-shared-data-extension:Core::$executor->objectStore->put($cdata)ObjectEntry::register(): intCore::$executor->objectStore->recycle($handle)ObjectEntry::unregister(): voidCore::$executor->classTable->find(strtolower($n))ReflectionClass::fromClassTable(string): ?ReflectionClass(no autoload, null on miss)Core::type($t)->getStructFieldOffset($f)Core::offsetOfField(string $type, string $field): intCore::sizeof(Core::type('zval'))Core::sizeOfType()Store registration is encapsulated on the object that is being registered (review feedback, f8a4ab5):
register()returns the handle the engine assigned,unregister()reads the handle at call time, verifies the slot actually holds this object and returns it to the free list — a stale handle is refused with a named domain exception instead of detaching somebody else's object.ObjectStoreitself is unchanged by this PR;put()/recycle()stay@internaland a consumer never seesEG(objects_store)at all.Design notes
allocate(int $size, int $alignment = 16): int), so an arena implementation in a consumer package binds its ownmmap/pthreadprimitives withFFI::cdef(precedent:Core::persistentFree()'s Windows cdef) and returns the address it computed.installExternalStorage()takes an address;ObjectEntry::register()/unregister()take and return nothing but aninthandle. Blocks are converted to typed handles inside the owning classes.Allocator::ownsAllocations()is what a structure consults before any release path; a table on foreign memory throwsTypeOperationExceptioninstead of callingfree(3)on somebody else's arena.HT_SIZE_EX/HT_SIZE_TO_MASK/HT_HASH_RESETarithmetic lives insidePersistentHashTable, and the store bookkeeping of an object lives onObjectEntry; callers pass capacities and handles, never offsets or store views.include/,stubs/or.phpstorm.meta.phpchange; no new engine symbol was needed (HASH_FLAG_STATIC_KEYS,HT_MIN_SIZEand friends are already generated constants).TypeOperationException, plus a newAllocationException.PersistentHeap::put()still uses the default allocator. Its eviction path frees blocks through z-engine's own allocator, so accepting a foreign one there needs a matching release contract — a follow-up, not a silent half-measure.Test evidence
New coverage:
tests/Memory/EngineAllocatorTest.php(zeroing, alignment guarantees per mode, registry membership, refusals),tests/Memory/AllocatorSeamTest.php(clones/strings/tables/whole cyclic graphs land in an arena-shaped test allocator; defaults keep the historical allocation classes),tests/Type/PersistentHashTableExternalStorageTest.php(layout math, lookups through installed storage, capacity guard, upsert at full capacity, relocation diagnosis, install-after-init and destroy refusals, struct+storage both in one region),tests/Type/ObjectEntryRegistrationTest.php(handle assignment and engine visibility viaspl_object_id, slot returned without touching the object, re-registration, both refusal paths), plusfromClassTable()/offsetOfField()cases in the existing Reflection/Core tests.Environment it was verified on
php -v):PHP 8.4.19 (cli) (built: Mar 30 2026 19:28:35) (NTS)--enable-debug)? noChecklist
8.4) — merges up tomasterper branch flowcomposer testpasses on the matching PHP minorcomposer phpstan(level max) andcomposer cs:checkare greenHashTable/Bucketare already inlayout_structs)tools/generator/symbols.phpunchanged — nothing underinclude/,stubs/or.phpstorm.meta.phpwas touched🤖 Generated with Claude Code
https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe