feat(shm): fork-shared arena, IPC primitives, result slots, mutable shared mode (closes #16, closes #18, closes #17) - #21
Merged
Conversation
The foundation of E1 (#16): one MAP_SHARED|MAP_ANONYMOUS region mapped before any worker is forked, so every child inherits it at the same virtual address and an address handed to a sibling means the same thing there. - Libc: package-local FFI::cdef against libc through RTLD_DEFAULT (mmap/munmap plus the pthread mutex family with PTHREAD_PROCESS_SHARED + PTHREAD_MUTEX_ROBUST). No z-engine header is touched: the arena is not an engine structure. - Arena: header with magic, layout version, bump cursor, a bank of 64 robust process-shared mutexes and a fixed named-roots directory; allocate() moves the cursor under the allocator mutex, so children allocate concurrently without overlapping. Fixed size from SHARED_DATA_ARENA_SIZE (64 MB default), typed ArenaException on exhaustion, and leak-until-teardown: only the creating process unmaps, at shutdown. - sizeof(pthread_mutex_t) is MEASURED at runtime (paint a buffer, initialize a mutex into it, look at what moved) instead of assuming the x86-64 glibc 40, and verified against the 64-byte slot stride. - No public method returns FFI\CData; critical sections are word loads and stores through views bound once at map time. Spikes committed as evidence: S8 (robust pshared mutexes exclude across processes and recover from a SIGKILLed owner) and S15 (four children, 8000 blocks, zero overlap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
… review TEMPORARY. E1 (#16) consumes the allocator seam being built in lisachenko/z-engine#223, which is not tagged yet, so composer resolves z-engine through a path repository pointing at the sibling checkout of that branch. This reverts to the dual-line "8.4.x-dev || 8.5.x-dev" constraint the moment #223 merges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
… in with them Consumes the z-engine allocator seam (lisachenko/z-engine#223) so a store can put its whole persisted state into the fork-shared arena instead of the process heap. - ArenaAllocator implements ZEngine\Memory\Allocator over the arena: blocks are zeroed (a bump allocator never recycles), 16-aligned, and OWNED by the arena, so z-engine never frees one through its own allocator. createTable() pairs the struct with a pre-sized external arData block. - Registry: createInArena()/fromArena() build every table - root, entries, objects, and the per-entry and per-object records - out of the arena, published in the arena roots directory so a forked child finds them with nothing but the mapping. Keys are interned into the arena too: a malloc-backed bucket key is a pointer no sibling can follow. - Growth is refused, not attempted. z-engine guards the tables it minted, but a registry recovered in a child rebuilds BORROWED views that know nothing about their storage, so the guard is re-derived from nNumUsed/nTableSize, and recovery bounds-checks HT_GET_DATA_ADDR against the arena. A resize would perealloc shared buckets into one worker's private heap and write that pointer into the shared struct before failing - silent garbage for every sibling (spikes/c1, S13). - Persister threads the allocator through EVERY minting call - clones, snapshots, strings, sealed arrays and their keys. A single malloc-backed block inside a shared graph is a pointer a sibling cannot follow, so there is no half-way. - PersistentStore::bootShared() is the opt-in entry point, anchored in its own module so globals[0] always means one thing per module; addressOf()/attachObject() are the eight-byte exchange protocol between workers. Children only ever READ module globals. - detach() now rolls back exactly the objects this process registered rather than the whole registry: a sibling's newly persisted object carries a class entry this process never rebound. Same set, same behaviour, in frozen mode. - Registry::LAYOUT_VERSION 3 -> 4 with the version history in the docblock: the record shapes are unchanged, but arena tables must never be grown or freed, and a pointer alone cannot say which kind a worker is holding. - Reclamation is skipped for arena registries (leak-until-teardown v1) - the region is reclaimed as a whole when its creating process exits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…ed persistence Real processes, pcntl_fork, exit codes as the answer channel and addresses crossing process boundaries as eight raw bytes over a socket pair - no serialization anywhere in the harness either. - ArenaTest: allocation, alignment, watermark accounting, typed exhaustion, bounds and alignment guards, the named-roots directory (including its fixed capacity) and the measured pthread_mutex_t size. - ArenaForkTest: children read what the parent wrote pre-fork; a child's allocation is reachable by the parent and by a sibling from the address alone; four children hammer the bump allocator with zero overlap; the watermark is shared; a child's destroy() is a no-op; a stripe lock is recovered from a SIGKILLed owner. - ArenaRegistryTest: tables published in the roots directory, recovery from the arena alone, the growth refusal (with the table named), the data block staying put after a refused insert, upserts still allowed on a full table, and heap registries unaffected. - ArenaStoreForkTest: a graph persisted pre-fork read identically by two children at the same address; a graph persisted by a child AFTER the fork attached by the parent and by a sibling through the address (copy-on-write cannot explain that one); arena exhaustion during persist as a typed failure; watermark visibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…xt to the code - README: a fork-shared arena section - what it is, the pre-fork call order, the pre-sized registry, leak-until-teardown, the robust mutex bank - plus an honest list of what this first iteration does not do yet (classes must be loaded before the fork, spl_object_id is meaningless on a shared object, the get_object_vars/var_dump family writes a request-heap pointer into shared memory, frozen semantics still apply). The reader/writer contract is stated where people will look for it: an aligned 8-byte read never tears, a 16-byte zval is two stores. - spikes/: the arena spikes (S8 robust pshared mutexes incl. owner-died recovery, S15 concurrent bump allocation) plus the validation sweep that established the premise, with its logs from both minors. Every non-obvious claim in the docblocks points at one of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…out is absent The path repository only exists in a workspace that has both repos checked out side by side; a VCS entry behind it lets CI resolve the same branch from GitHub. Both go away together with the pin once lisachenko/z-engine#223 merges and the constraint returns to "8.4.x-dev || 8.5.x-dev". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…an offset The magic word and the layout version were written but never read. A worker that recovers an inherited mapping is exactly who should check them: the magic proves the region is an arena at all, the version proves the mutex bank and the roots directory sit where this build expects them - and locking bytes at a wrong offset would be locking somebody else's data. bootShared() calls it on the recovery path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…out a sibling checkout Composer hard-fails when a path repository's url does not exist; CI runners have no ../z-engine checkout, so every job died before installing. The vcs entry alone resolves the same seam branch everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
6 tasks
The bank of 62 consumer stripes is the right shape for many small structures sharing a few locks and the wrong shape for a structure whose lock is taken on every operation. allocateMutex() reserves a robust process-shared mutex of its own inside the payload, initialized once by the creating process and found again through the owning structure's header, so two busy channels never serialize against each other. stripeFor() keeps the bank useful for everything else by hashing an address to a stripe, dropping the bits every arena block shares. tryLockMutex() gained the EOWNERDEAD answer it was swallowing: acquiring a lock a died owner left behind and learning that it happened are two halves of one result, and a caller that guards multi-word state needs both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
A value crossing a worker boundary is one of nine shapes and nothing else: three carry no payload, two carry the value inline, three carry an arena address, one is the channel control tag. Strings are interned into the arena at send (a structural memcpy, not a serialization) and materialize on the far side as a non-refcounted zval over the same block; shared objects and shared arrays contribute nothing but their address. The refusals matter as much as the round trips. A plain array, a resource, a closure or an object this family does not share has no address-shaped form, and the only alternative would be to encode it - so each is refused with a message naming the remedy instead. Closures are rejected on PROVENANCE rather than shape: a stale post-fork address was observed holding a valid Closure of a different function, so inspecting one can never establish that sharing it is safe. PersistentStore::addressOfInstance() is the predicate behind the object tag: it answers with an address only for objects this registry shares, keyed by arena address because forked children hand out identical object handles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…ockets Shared memory can hold state but cannot wake anybody, and FFI offers no futex and no condition variable. So blocking uses the one thing PHP can select() on - a descriptor - while every value stays in the arena. A sender that makes a ring non-empty or settles a slot writes ONE 16-byte event record to each parked process; the receiver drains it and re-reads the shared state. Descriptors are per-process, so the pairs are minted before any worker forks and inherited; the arena half of the registry is only the claim table saying which pid owns which slot, with dead owners recycled so a supervisor may respawn forever. Writes go through a single choke point that accepts nothing but an event record, and observeWrites() exposes it so a test can prove the sockets carry no values rather than asserting it in prose. Waiter tables are registered under the owning structure's lock and read without it: registering and re-checking the state in one critical section is what makes a lost wakeup impossible, while a spurious one costs a re-poll. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…lose The ring, its head and tail counters, the closed flag and both waiter tables are arena memory, so a producer in one worker and a consumer in another operate on one structure instead of two views. Counters are monotonic rather than wrapped indexes: the fill level is a subtraction, the slot is a modulo, and a sender that deposited at ticket N knows its record was taken the moment head passes N - which makes the capacity-0 rendezvous a single word comparison instead of a state machine. The whole ring operation stays under one dedicated robust mutex: a 16-byte record store is not atomic, and publishing payload-then-tag would still leave the counters racing. Inside the critical section there is nothing but aligned word access - values are encoded before the lock and decoded after it, and waiters are notified once it is gone. Closing is shared state, so a producer learns about a consumer's close() the next time it takes the lock: receivers drain what is buffered and then see the end of stream, senders get a typed refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
SharedArray is the container a plain PHP array cannot be: growth would reallocate the bucket block into one worker's private heap - and the engine writes that private pointer into the shared struct before it aborts - so capacity is decided at creation and an index outside it is a typed error. Both halves of an element access take the instance stripe, because reading a tag and a payload together is exactly the two-word read that was measured to tear. SharedMutex gives userland the same lock with the policy a caller needs around it: acquisition is a trylock loop with backoff, since a process blocked in libc cannot run its scheduler or answer its supervisor, and a lock inherited from a died owner is made consistent immediately and REPORTED rather than swallowed. AtomicInt rides the one hardware guarantee available - an aligned 8-byte load or store never tears - for get/set, and takes a stripe for read-modify-write, which FFI cannot express any other way. SharedWaitGroup adds a waiter table on top and refuses to clamp a negative counter: a done() without an add() is a miscount the whole family should see. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
Futures over the shared area, and the piece the epic's runtime model rests on: a coroutine finishing anywhere in the process tree writes its return value into its slot as a 16-byte record, and the process waiting for it is told with a fixed event record and reads the value straight out of shared memory. The socket never carries the value - an INT or FLOAT event has a zero where an address would be. The same table hands spawn arguments downwards under the identical contract. A slot settles exactly once, which is what makes "see the state, then read the record" a sequence rather than a race, and slots are handed out by a bump counter over a table pre-sized before the fork. A Throwable can never be shared - internal C state, live frames, a chained previous - and encoding it is exactly what this package refuses to do, so the panic path persists a plain three-string object into the arena and the slot carries its address. Capturing returns that address rather than the instance: a store refuses to release a graph the request can still reach, and holding the error object would make the next capture fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
… its anchor A module's globals[0] means two different things - a registry hashtable in the default mode, the ARENA BASE in arena mode - and phpinfo() walks every registered module, so a worker running both modes read the arena header as a hashtable and died with SIGSEGV rather than an exception. Reproduced by running the IPC suite before the store suite in one process; the crash predates that suite and needed only the ordering to surface. The state is now reported through the store booted for that module, which is the only thing that knows which registry it holds and how it was built. Without one, the anchor is checked against the arena magic before anything is interpreted: one aligned load tells an arena from a registry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…tion plane Real processes throughout: a producer child and a consumer child agreeing on FIFO order through one ring, a rendezvous sender that must still be parked 400 ms later, a close() issued by one process and drained by another, four children summing into one atomic cell, a worker SIGKILLed inside a critical section and the lock recovered afterwards, a child completing a result slot of every tag kind while the parent is parked on its socket - including an object the child minted after the fork, which the parent then holds at the very same address. Two claims are tested rather than asserted. The sockets are wrapped at their one write point and every byte that crossed is parsed back as a fixed event record, with value bytes searched for and absent. And the Never-Serialize Rule gets a guard with teeth: namespace-local shadows of serialize, igbinary and JSON in the three namespaces the data path runs through, proven to intercept a call before the round trip is measured at zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
… carry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…merged into 8.4 The allocator seam landed on z-engine's 8.4 branch, and the branch this package was pinned to has since been restarted from master for the 8.5 merge-up - so the pin now resolves an 8.5-only line and every PHP 8.4 leg fails to install. The constraint returns to one dev line per supported minor, which is where it belongs: Composer picks the line matching the running interpreter, and 8.4 already carries the seam this package builds on. The vcs repository entry stays (it is what makes the branches resolvable); no path repository is reintroduced. Verified against the resolved 8.4 line: suite green (128 tests), both soaks pass. The 8.5 legs stay red until the merge-up into master lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
lisachenko
marked this pull request as ready for review
August 15, 2026 20:36
lisachenko
commented
Aug 15, 2026
Owner
Author
|
If you need some knowledge extracted - collect and report this either as a doc here with limitations/problems or report ticket if needed to fix something somewhere |
…ecific paths The c1/ directory was a copy of the cross-repo validation sweep that established the premise of the epic. It predates this package's arena, so it carried its own bootstrap with paths bound to the machine it ran on - a harness nothing in the repository can run and nothing in the repository needs. The verdicts live on the ticket, and the claims this package actually depends on are promoted to tests rather than to logs. What stays is self-contained: both remaining spikes run against this package's own Arena from the repository root, through Composer's autoloader only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…the validation sweep The knowledge the removed sweep carried is worth keeping; its harness was not. This is the same material as a repository document: the laws (fork-only sharing, what is atomic and what only looks atomic, the three per-process fields, engine table growth as silent corruption, robust-mutex handling, leak-until-teardown accounting, closure provenance) with the symptom each one produces when it is violated, since almost none of them raise. Written for a consumer of the package rather than for the sweep that produced it, with the measurements cited back to the spike-gate record on the epic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
lisachenko
commented
Aug 15, 2026
lisachenko
commented
Aug 15, 2026
… constrains it Adds the implementation map to the model document: what each primitive is (arena and its allocator seam, registry, per-process side table, the mutation path, value records and the IPC primitives), and which of the laws above it exists to satisfy. The document now answers all three questions in order - what works, why it is done this way, how it is implemented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
The registry already routes arena-backed state past the reclaimer, but that is one decision at one call site, and freeing shared memory is not a mistake that announces itself: it corrupts the heap of the process that calls it and leaves every sibling reading memory nobody owns. A forked child is the dangerous case - it inherits every pointer its parent had and owns none of the memory behind them. So the refusal moves to the last line before the free, where both the table and the block paths pass, and it is armed by the arena itself when the shared store boots. The message names the role of the process, because "this is a child" is usually the whole diagnosis. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…e graph Frozen and shared-mutable objects need opposite lifecycles - one is restored from its snapshot at request end, the other must never be - and the decision cannot live in the process that persisted it: a sibling attaching the same address later has nothing but the registry to learn it from. So the role travels in the object record, and the layout version moves with it (v5), because a worker that cannot read the role would apply frozen semantics to memory its siblings are writing. Also exposes the property-slot mapping the persister already computes: writing one property of a shared object means writing one SLOT of it, resolved against the class entry of the process doing the writing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
Two halves of the same problem: a zend_object in the arena is one struct read by several processes, and both its engine state and its lifecycle have to stop being per-process by accident. The side table takes the three fields that describe the READER rather than the object. `handle` collides by construction - forked children inherit one object-store free list and are handed identical numbers - so the real handle lives per process and the shared field is overwritten with a sentinel the store can never produce, which also makes spl_object_id() uselessly honest; sharedIdOf() returns the arena address, which is what every process agrees on. `ce` is rebound per process and recorded, the shared field being advisory. And `properties`, which engine C code writes on read-shaped operations, is forced NULL at attach and never dereferenced in shared mode: scrubProperties() drops the pointer unread, because reading its refcount would already be a dereference of somebody else's request heap, and inspect() brackets a var_dump()/json_encode() so the cache dies in the process that caused it. Mutable mode is opted into per graph. Everything that makes a persistent clone safe stays - the refcount pin, GC_PERSISTENT|GC_NOT_COLLECTABLE, bare non-refcounted payloads, sealed arrays - and what goes is frozen semantics: detach() never memcpys a request-old snapshot over slots three workers are writing. SharedObjectHandle is the synchronized way to write one: values are validated and interned BEFORE the object's stripe lock, and the critical section is the payload word and then the type word, because a 16-byte zval is two stores and an unlocked reader was measured to see the halves apart. Strings are interned into the arena and swapped as one aligned pointer (the old block leaks by design), references may only point at another object of the same arena, arrays stay sealed, and declared property types are enforced here because the engine never gets to check them. Direct `$obj->prop = ...` writes stay legal and unsynchronized - a class rewired to std_object_handlers offers no write hook, which is a trade rather than an oversight - so a slot found holding a pointer outside the arena at detach is repaired from the frozen image instead of being left for a sibling to follow. Finally, drop()'s alias predicate is disabled for shared graphs: a refcount in the arena is written by every worker that ever copied the value, and it guards memory that is never handed back anyway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
The mapping was released by a shutdown function armed in Arena::create(), and PHP destroys the symbol table, the object store and every remaining zval AFTER its shutdown functions have run. So any variable still holding a shared object - a global, a static, a store that had not detached yet - was released against memory that was no longer mapped, and the process died with SIGSEGV after a completely green test run: the report is printed long before the crash, so the suite says OK and the exit code says 139. No ordering inside the class can fix it. The mapping is necessarily created before anything that points into it, and shutdown functions run in registration order, so the unmap can never be last. It also does not need to exist: the arena is process-scoped by design and the kernel reclaims the mapping when the process exits, which is precisely the lifetime the leak-until-teardown model already assumes. destroy() stays for a caller who genuinely owns the moment, with the requirement spelled out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
… real processes The cases the design rests on, run by actual forked workers on every change instead of quoted from a log: two children writing and reading one object under its stripe lock over 100k rounds, with a two-slot invariant that a half-applied update would break and an unlocked string reader that would see a torn pointer if an aligned 8-byte swap could tear; a child writing a string and a reference that every other process then follows; two overlapping children proving handles are per-process while the shared field is not an identity; a child making the engine cache a property bag inside a shared object and leaving nothing behind for its sibling to dereference. Then the lifecycle: shared state is not rolled back while a frozen graph in the same arena still is, a direct write is visible but its heap string is repaired rather than shared, a child is refused when it tries to free arena memory, and - the regression for the teardown crash - a worker that keeps a shared object alive and simply exits does so with status 0 rather than a signal. The single-process file covers what may be written at all: the refusals, the declared-type enforcement the engine never gets to do, the disabled alias predicate in shared mode against the frozen one that still refuses, and the pin that lifting the frozen semantics does not lift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
…behaviour The README still described the per-process fields and frozen-only semantics as limits of the next iteration, which they no longer are. Replaces that list with what the package actually does now - identity through the arena address, the property cache scrubbed rather than avoided, and the opt-in mutation API with the two rules a caller has to know: what the write path refuses, and what a direct property write really does. 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.
Implements E1, E2 and E3 of EPIC #15: the fork-shared memory foundation, the opt-in mutable shared mode on top of it, and the IPC primitives that live inside it.
Closes #16. Closes #18. Closes #17. Part of #15. Consumes the allocator seam from lisachenko/z-engine#223.
E1 — the arena
Shm\Arena— onemmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0)region created before any fork, so every worker inherits it at the same virtual address and an address handed to a sibling means the same thing there.SHARED_DATA_ARENA_SIZE(64 MB default), typedArenaExceptionon exhaustion, leak-until-teardown: the region lives until the creating process exits and nothing unmaps it at request shutdown (see the teardown fix below); a child'sdestroy()is a deliberate no-op;allocate()moves the shared cursor under the allocator mutex, so any child can allocate concurrently;sizeof(pthread_mutex_t)is measured at runtime (paint a buffer, initialize a mutex into it, look at what moved: 40 on x86-64 glibc) and verified against the 64-byte slot stride, instead of being assumed;EOWNERDEADis handled at every lock site —pthread_mutex_consistent()then proceed; no lock result is ever discarded;FFI\CData. Callers see integers and strings; the pointer views are bound once per process at map time, which is also what keeps critical sections to plain word loads and stores.Shm\ArenaAllocatorimplements z-engine'sZEngine\Memory\Allocatorover the arena (zeroed blocks — a bump allocator never recycles; 16-aligned;ownsAllocations() === true, so z-engine never frees an individual block), pluscreateTable()which pairs a table struct with a pre-sized externalarDatablock through the seam's install API.Registry-in-arena —
Registry::createInArena()/fromArena()put the root, entries, objects and every per-entry/per-object record table into the arena, published in the arena roots directory so a forked child finds them with nothing but the mapping. Bucket keys are arena-interned too: a malloc-backed key is a pointer no sibling can follow. Module globals shrink to[arena base, LAYOUT_VERSION], and children only ever read them (the page is COW — a child's write would silently desynchronize the family).Growth is refused, never attempted. z-engine guards the tables it minted, but a registry recovered in a child rebuilds borrowed views that know nothing about their storage, so the guard is re-derived from
nNumUsed/nTableSize, and recovery bounds-checksHT_GET_DATA_ADDR(ht) = arData - HT_HASH_SIZE(nTableMask)(mask read signed) against the arena. This is not belt-and-braces: a resizepereallocs shared buckets into one worker's private heap and writes that pointer into the shared struct before failing, so siblings read plausible garbage with no signal at all.Arena-backed persist path —
PersistentStore::bootShared($arena)is the opt-in entry point, anchored in its own persistent module soglobals[0]always means one thing per module. The Persister threads the allocator through every minting call (clones, snapshots, strings, sealed arrays and their keys): one malloc-backed block inside a shared graph is a pointer a sibling cannot follow, so there is no half-way.addressOf()/attachObject()are the eight-byte exchange protocol between workers.Registry::LAYOUT_VERSION3 → 5 (4 = arena tables, 5 = the per-object role E2 adds), hard-fail preserved, version history in the class docblock.Acceptance criteria (#16)
ArenaStoreForkTest::testTwoChildrenReadTheGraphPersistedBeforeTheForkArenaStoreForkTest::testGraphPersistedByAChildIsAttachedByTheParentAndASiblingThroughItsAddressArenaRegistryTest(refusal, table named, data block verified unmoved, upsert still allowed)ArenaTest,ArenaStoreForkTestE2 — opt-in mutable shared mode
Sharing the memory was E1. E2 is about the two things that are still per-process inside a shared object: its engine state, and its lifecycle.
Mutation contract
persist($key, $object, mutable: true)(arena mode only) keeps everything that makes a persistent clone safe —PIN_BASELINErefcount pin,GC_PERSISTENT|GC_NOT_COLLECTABLE, bare non-refcounted slot payloads, sealed immutable arrays — and gives up frozen semantics:detach()never memcpys a request-old snapshot over slots other workers are writing. The role is recorded in the registry, not in the persisting process, so every worker that attaches the address later applies the same lifecycle; one object belonging to a frozen and a mutable graph is refused (SharedMutationException::modeConflict), because the two lifecycles contradict each other.PersistentStore::mutableHandle($object)returns the synchronized write path, guarded byArena::stripeFor($address):writeScalar,writeScalarsfor several slots in one critical section)persist()zend_arraycannot grow, so it stays sealed (Ipc\SharedArrayis the mutable collection)Reads (
readScalar(s),readString,readReference,read) take the same lock, because the type of a slot can change under them — correction #1. A single aligned 8-byte pointer read may skip it, and the fork test exercises exactly that: an unlocked reader following a string pointer swapped 100 times sees a complete string every time, never a mixture (correction #2). Declared property types are enforced by the write path, since the engine never sees the assignment.No
write_propertyhooks, deliberately: a persistent clone is rewired tostd_object_handlersby construction (the only handlers block whose address survives a fork), so there is nothing to hook without giving that up. A direct$obj->prop = ...therefore still works and is unsynchronized — visible everywhere and racy for scalars; for a string/array/object it stores a request-heap pointer inside shared memory, and such a slot is restored from the persisted image atdetach()instead of being left for a sibling to dereference (repairedSlotCount()reports it). Both halves are tested.Per-process side table
SideTable, keyed by arena address, holds the three fields that describe the reader rather than the object:handle— attach registers through z-engine'sObjectEntry::register()and keeps the returned handle here; the shared field is then overwritten withSHARED_HANDLE_SENTINELso no process can trust it (children inherit one object-store free list and are handed identical numbers). Detach puts the side-table handle back for the length ofObjectEntry::unregister(), which verifies the slot still holds this object — a stale-handle refusal is tolerated, that is the guard working. That restore is the one moment the shared field is not the sentinel, it is documented, and nothing in the package reads that field;ce— rebound per process at attach and recorded here; the shared field is advisory (pre-fork class loading is still required);properties— forcedNULLat attach and never dereferenced in shared mode. The trigger list is documented onscrubProperties():get_object_vars(),var_dump(),json_encode(),(array),serialize(),debug_zval_dump(),ReflectionObject.inspect($object, $reader)brackets such a call so the cache dies in the process that caused it.Identity:
spl_object_id()reads the sentinel in every process and is documented as meaningless;PersistentStore::sharedIdOf($obj)returns the arena address, which is the identity every process agrees on.Role-aware lifecycle
detach()never rolls back a mutable graph (frozen behaviour is byte-identical, and the frozen path still releases the dynamic-property table exactly as before; the shared path drops the pointer unread);drop()'srefcount === PIN_BASELINEalias predicate is disabled for shared graphs: a refcount in the arena is written by every worker that ever copied the value, so it saturates above the baseline and can neither prove nor disprove a live alias — and it protects nothing, since an arena block is never handed back;Reclaimer::destroyTable()/freeBlock()), armed bybootShared()for the arena it maps, so every free path reachable in a child refuses with a typedArenaExceptionnaming the role of the process.One crash fixed on the way (teardown ordering)
The arena used to be unmapped by a shutdown function armed in
Arena::create(), and PHP destroys the symbol table, the object store and every remaining zval after its shutdown functions have run — so a variable still holding a shared object was released against unmapped memory. The suite printedOK (…)and the process then died with SIGSEGV (exit 139). No ordering inside the class can fix it (the mapping is necessarily created first), so the automatic unmap is gone: the kernel reclaims the region at process exit, which is the lifetime leak-until-teardown already assumes.destroy()stays for a caller who owns the moment. Regression test: a worker that keeps a shared object alive and simply exits must exit with status 0, not a signal.Acceptance criteria (#17)
MutableSharedForkTest::testTwoChildrenWriteAndReadTheSameObjectUnderItsStripeLock,…AChildWritesAStringAndAReferenceThatEveryOtherProcessCanFollowspl_object_idsanity, sentinel in the shared structMutableSharedForkTest::testConcurrentAttachKeepsEveryHandlePerProcessAndTheSharedFieldASentinelvar_dump()/get_object_vars()(S14)MutableSharedForkTest::testAChildInspectingASharedObjectLeavesNoHeapPointerForItsSiblingMutableSharedForkTest::testSharedMutableStateIsNeverRolledBackWhileAFrozenGraphStillIs,SharedMutationTest::testTheFrozenStoreStillRefusesToDropAGraphTheRequestCanReach…testSharedMutableStateIsNeverRolledBack…,MutableSharedForkTest::testAChildIsRefusedWhenItTriesToFreeArenaMemory,SharedMutationTest::testArenaBlocksAreRefusedByEveryFreePathOfThisProcessMutableSharedForkTest::testADirectWriteIsVisibleToSiblingsButItsHeapStringIsRepairedAtDetach,SharedMutationTest::testMutatingASealedArrayThroughPhpIsContainedRatherThanSharedSharedMutationTestE3 — IPC primitives as shared objects
Everything below is a structure in the arena, found by address or by a name in the roots directory; nothing is inherited as PHP state except the notification descriptors, which cannot be anything else.
Value records — the whole type system of the shared area
uint8 tag | 7 pad | uint64 payload, sixteen bytes:NIL/TRUE/FALSEINT/FLOATSTRzend_string(structural memcpy at send; the receiver overlays a non-refcounted zval, zero copy)OBJzend_objectthe registry knows — zero copy, same address in every processARRSharedArrayCLOSEA value with no address-shaped form is refused, never encoded: plain arrays (→
SharedArray), resources, non-shared objects (→PersistentStore::persist()) and closures each get aNotShareableValueExceptionnaming the remedy. Closures are rejected on provenance, not shape — a stale post-fork address was observed holding a valid Closure of a different function, so inspection can never establish safety; the message points at Task objects / #20.The primitives
SharedChannelclose()crosses processes: receivers drain, then[null, false]; senders getClosedChannelExceptionSharedArrayArrayAccess/Countable/IteratorAggregate, per-instance stripe by address hash. Growth is impossible by construction — that is the pointResultSlotTableallocateSlot()/complete()/completePanic()/readSlot()/await(). Settles exactly once. The same table carries spawn arguments downwardsSharedErrorThrowableitself can never be sharedSharedMutexEOWNERDEADrecovered and reported throughwasRecovered()AtomicIntget()/set()(an 8-byte load never tears), stripe-lockedadd()/compareAndSet()— FFI has no CASSharedWaitGroupWakeRegistryNotification plane: signalling, never payload
A sender that makes a ring non-empty or settles a slot writes ONE fixed record to each parked process:
Descriptors are per-process, so the pairs are minted before the fork and inherited; the arena side is only a claim table (pid → slot). Waking is level-triggered: a waiter registers in the structure's waiter table and re-checks the state inside the same critical section, so a wakeup can be spurious but never lost, and every blocking loop re-polls on a bounded slice — a dropped write (full buffer) costs latency, never correctness. Blocking helpers are a spin loop over the descriptor; every primitive also exposes its non-blocking half plus
notificationStream(), so a coroutine runtime parks a Fiber in its own loop instead. Parking a Fiber is deliberately not in this package.Locking discipline throughout: values are encoded before the lock and decoded after it, waiters are notified once it is released, and inside every critical section there is nothing but aligned word access — a 16-byte record store is not atomic (correction #1), an 8-byte one is (correction #2).
Acceptance criteria (#18)
[null, false]SharedChannelForkTest::testProducerAndConsumerChildrenExchangeRecordsInFifoOrder,…BlockingReceiveWakes…,…CloseCrossesProcesses…SharedChannelForkTest::testCapacityZeroChannelMakesTheSenderWaitForItsReceiver,…TrySendOnlySucceedsWhileAReceiverIsParkedResultSlotForkTest::testAChildCompletesEveryTagKind…,…ParentParksOnTheSocketAndWakesOnTheEventRecord,…PanicTravelsAsASharedErrorObject…NotShareableValueExceptionon arrays/closures/resources/non-shared objects, message names the remedyValueCodecTest(5 cases)serialize()is never called on any data pathNotificationPlaneForkTest::testAFullProducerConsumerRoundTripCallsNoEncodingFunctionAtAllNotificationPlaneForkTest::testEveryByteThatCrossesASocketIsAFixedEventRecordSharedChannelForkTest::testTheArenaWatermarkPlateausWhileRecordsChurnThroughARing, soaks belowTwo claims are tested rather than asserted. The registry's single socket-write point is wrapped and every byte that crossed is parsed back as a 16-byte event record, with the payload string searched for and absent. And the Never-Serialize Rule gets a guard with teeth: namespace-local shadows of
serialize,unserialize,igbinary_*,json_*andvar_exportin the three namespaces the data path runs through, proven to intercept a real call before the round trip is measured at zero.Also covered by real processes: 4 children summing into one
AtomicInt(1000/1000), acompareAndSettoken exactly one child wins, a worker SIGKILLed inside aSharedMutexcritical section with the lock recovered afterwards, a wait group whose units finish in four other processes, and aSharedArrayfilled by four workers.One more crash fixed on the way
phpinfo()walks every registered module, and an arena-backed module'sglobals[0]is the arena base, not a registry pointer — reading it as a hashtable was a SIGSEGV, not an exception. State is now reported through the module's live store, with the arena magic as the discriminator. Regression test:Shm\ArenaModuleInfoTest.Documentation
The vendored validation sweep (
spikes/c1/, review feedback) is removed: it was a throwaway harness from a run outside this repository, with paths bound to the machine it ran on. Its knowledge is distilled intodocs/shared-memory-model.md— what works, why the solution is shaped this way, and how it is implemented per primitive: fork-only sharing, the atomicity/tearing contract, the three per-process fields and the side table, thearDatalaw, robust-mutex rules, leak-until-teardown accounting, closure provenance, and the identity story. Raw verdicts stay on the spike-gate record on #15; the claims the code depends on are promoted to tests. The two surviving spikes are repo-native and bootstrap through Composer only.Test evidence
PHP 8.4.19 (z-engine
8.4line) and PHP 8.5.9 (master line), both-d ffi.enable=1 -d opcache.jit=off:Exit codes are checked explicitly now, not inferred from the summary line — that is how the teardown segfault above hid behind a green report.
Known limits of this iteration
ceslot for the whole family;spl_object_id()is meaningless on a shared object (it reads the sentinel); identity issharedIdOf(), the arena address;$obj->prop = ...is unsynchronized: fine for scalars, and for anything else the slot is repaired from the persisted image at detach rather than shared. The synchronized path ismutableHandle();Ipc\SharedArray;SharedErrorentry per store — a second panic replaces the first;handlefield is not the sentinel for a few instructions while a process detaches (recycling a store slot is an engine call and engine calls cannot run under an arena mutex) — harmless, because nothing here reads that field.Dependency state (the pin is gone)
lisachenko/z-engineis8.4.x-dev || 8.5.x-dev, one dev line per supported minor, resolved through the vcs repository entry (no path repository). The 8.5 runs reported above were done against the master line resolved by Composer under PHP 8.5.Not done here, deliberately
phpstan/cs:checkscripts) and could not be installed in this environment; the new code follows the existing PER-CS2.0 style by hand, with explicitintcasts on FFI field reads.PersistentHashTableview cannot re-adopt the external storage block it is sitting on, so the growth guard had to be re-derived in this package (TODOinRegistry::assertRegistryRoom()).🤖 Generated with Claude Code
https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
Generated by Claude Code