Skip to content

feat(memory): allocator seam for persistent cloning + external arData install (closes #223) - #224

Merged
lisachenko merged 5 commits into
8.4from
claude/php-coroutines-plan-5vovsz
Aug 15, 2026
Merged

feat(memory): allocator seam for persistent cloning + external arData install (closes #223)#224
lisachenko merged 5 commits into
8.4from
claude/php-coroutines-plan-5vovsz

Conversation

@lisachenko

@lisachenko lisachenko commented Aug 15, 2026

Copy link
Copy Markdown
Owner

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's pemalloc, 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\Allocator hands out addresses of zeroed, aligned blocks and reports whether it keeps ownership of them. EngineAllocator is 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 by PersistentHashTable)
  • PersistentGraphCloner::__construct(?Allocator) — one allocator for a whole graph

Null 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 own zend_hash_real_init_mixed layout over a caller-allocated block — HT_SIZE_EX sizing via externalStorageSize(), hash part of two uint32_t slots per bucket reset to HT_INVALID_IDX, buckets right behind it, HASH_FLAG_UNINITIALIZED replaced by HASH_FLAG_STATIC_KEYS while 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:

  • prevention: inserts through the wrapper refuse the write that would trigger the resize (getRemainingCapacity() reports the headroom; an upsert of an existing key consumes no slot and stays allowed at full capacity);
  • detection: assertNoGrowth() verifies arData/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() would pefree the caller's block and the struct free assumes z-engine's allocator.

3. Public-API remedies for the @internal / Core::$executor reach-throughs in php-shared-data-extension:

Reach-through today Named public method
Core::$executor->objectStore->put($cdata) ObjectEntry::register(): int
Core::$executor->objectStore->recycle($handle) ObjectEntry::unregister(): void
Core::$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): int
Core::sizeof(Core::type('zval')) already covered by 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. ObjectStore itself is unchanged by this PR; put()/recycle() stay @internal and a consumer never sees EG(objects_store) at all.

Design notes

  • No CData crosses a public boundary. The allocator interface is integers only (allocate(int $size, int $alignment = 16): int), so an arena implementation in a consumer package binds its own mmap/pthread primitives with FFI::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 an int handle. Blocks are converted to typed handles inside the owning classes.
  • Ownership is explicit, not guessed. Allocator::ownsAllocations() is what a structure consults before any release path; a table on foreign memory throws TypeOperationException instead of calling free(3) on somebody else's arena.
  • Engine structures stay owned by their type class. All HT_SIZE_EX/HT_SIZE_TO_MASK/HT_HASH_RESET arithmetic lives inside PersistentHashTable, and the store bookkeeping of an object lives on ObjectEntry; callers pass capacities and handles, never offsets or store views.
  • Generated headers untouched — no include/, stubs/ or .phpstorm.meta.php change; no new engine symbol was needed (HASH_FLAG_STATIC_KEYS, HT_MIN_SIZE and friends are already generated constants).
  • Domain exceptions via named constructors — eight new factories on TypeOperationException, plus a new AllocationException.
  • Not in scope: 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

composer test                 497 tests, 5095 assertions — OK (5 skipped, 5 incomplete)   [462 before]
phpunit --group internal      161 tests,  480 assertions — OK (process-isolated, no crash)
composer phpstan              [OK] No errors  (level max, no new baseline entries)
composer cs:check             [OK]            (PER-CS2.0)

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 via spl_object_id, slot returned without touching the object, re-registration, both refusal paths), plus fromClassTable()/offsetOfField() cases in the existing Reflection/Core tests.

Environment it was verified on

  • PHP version (full first line of php -v): PHP 8.4.19 (cli) (built: Mar 30 2026 19:28:35) (NTS)
  • Thread safety: NTS
  • OS / architecture: Linux / x86_64
  • Debug build (--enable-debug)? no

Checklist

  • Targets the minimum affected version branch (8.4) — merges up to master per branch flow
  • composer test passes on the matching PHP minor
  • composer phpstan (level max) and composer cs:check are green
  • Tests added or updated; no new struct dereference needed (HashTable/Bucket are already in layout_structs)
  • tools/generator/symbols.php unchanged — nothing under include/, stubs/ or .phpstorm.meta.php was touched
  • Conventional Commits used for the commit messages

🤖 Generated with Claude Code

https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe

claude added 3 commits August 15, 2026 19:06
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
Comment thread src/System/ObjectStore.php Outdated
claude added 2 commits August 15, 2026 19:48
…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
@lisachenko
lisachenko marked this pull request as ready for review August 15, 2026 20:08
@lisachenko
lisachenko merged commit 6516ad0 into 8.4 Aug 15, 2026
20 checks passed
@lisachenko
lisachenko deleted the claude/php-coroutines-plan-5vovsz branch August 15, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allocator seam for persistent graph cloning + external arData install API (PR-Z1)

2 participants