feat(shm): AGENTS.md and pre-fork closure exchange (closes #19; #20 Phase A) - #22
Merged
Merged
Conversation
…ection A closure compiled before the fork lives in memory the whole worker family inherits, so its address means the same thing everywhere; a closure compiled after the fork does not, and the difference is not observable on the object - spike S17 found a stale address holding a different, perfectly valid Closure that then executed the wrong function. So provenance is recorded rather than inferred. ClosureProvenance is a pre-sized table in the arena: the process that owns the arena registers closures by name before it forks, marks the fork barrier, and every later registration - or any registration from a worker - is refused. A record carries the closure address, a zend_function witness, the name and the arena address of a bound $this; the closure object itself is never copied, because Phase A needs no copy. Captures are held to the value contract before a record is written: bound $this must be null or an object of this store, use-d values must be scalars, strings, shared objects or SharedArrays, and by-reference captures and declared statics are refused outright - both are per-request slots that would diverge silently once every worker copy-on-writes its own. ValueCodec accepts a registered closure as a new address-shaped tag whose payload is the RECORD address, and refuses everything else exactly as before, now naming registerSharedClosure() in the message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
The Phase A claim is exercised in real processes: two children take a record address off a socket pair as eight machine-order bytes, resolve it and invoke the closure 100k times each, with the parent checking both sums out of arena words. A second case sends a closure through a SharedChannel, where it travels as a value record and runs in the receiver, and a third reads a captured shared object from a worker. The refusals are covered as thoroughly as the invocations, since registration is the whole acceptance test: after the barrier, from a worker, an unregistered closure at the codec, an address that is not a record, a by-reference capture, a declared static, a captured plain array or request object, and a closure bound to a request object. A closure bound to a SHARED object is accepted and read back in a worker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
spikes/s17-prefork-closures.php is the repo-native version of the experiment Phase A came out of: four workers invoke three pre-fork closures 50k times each and verify every result, the records are shown to live in the arena while the closures do not, and both ways of getting it wrong - registering after the barrier, registering from a worker - are refused. GREEN on 8.4.19 and 8.5.9. docs/closure-cloning.md carries the Phase B verdict with its inventory: the op-array graph of the smallest capturing closure is ~496 bytes on 8.4 and ~416 on 8.5, all of it already readable through z-engine, so copying is not the problem. run_time_cache__ptr and static_variables_ptr__ptr are - they are per-request slots that arena residency would SHARE between processes, and on 8.5 the live static-variable table is measurably a different table from the declaration defaults. Phase B is achievable and needs the per-process re-mint E2 already built for objects, so it stays open on #20 as a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
The invariants of this package lived in docblocks and a README that still described per-process memory. AGENTS.md now writes them down for whoever changes the code next, human or agent: the Never-Serialize Rule verbatim as invariant #1, fork-only sharing, frozen versus shared-mutable, the arData law, lock discipline including EOWNERDEAD at every site, leak-until-teardown with the teardown crash it comes from, LAYOUT_VERSION discipline, the frozen-mode invariants that must survive every change, the house rules of a repository with no fixer and a fork-based suite, and the closure rule - provenance only, never shape. It cites docs/shared-memory-model.md for the why rather than restating it. The README scope section stops claiming one worker process: it now describes the fork tree, frozen-by-default with mutation on request, the IPC primitives and result slots, and gains a feature map plus a section on shared closures. Closes #19. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
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 E4 and Phase A of E5 of EPIC #15, on top of E1+E2+E3 (#21, merged).
Closes #19. Part of #15. Phase A of #20 — Phase B (arena-cloned post-fork closures) is documented with a verdict and the ticket stays open for it.
E4 — AGENTS.md + README scope
AGENTS.md(new)The invariants of this package lived in docblocks and in a README that still described per-process memory. They are now written down once, imperatively, for whoever changes the code next — human or agent. It cites
docs/shared-memory-model.mdfor the why rather than restating it:NotificationPlaneForkTestshadows the encoders, proves the shadows intercept, then measures zero calls) and that a new namespace on the data path has to join the guard.mutableHandle()synchronized, direct writes legal-but-unsynchronized, mode conflict refused).HT_GET_DATA_ADDRon recovery withnTableMaskread signed.EOWNERDEADat every site or the mutex poisons permanently, robust is not optional, critical sections are memcpys and pointer swaps (no allocating engine calls, no user callbacks, no Fiber suspension), and the 8-byte/16-byte atomicity contract.OK (…)then SIGSEGV, exit 139, because PHP destroys the object store after shutdown functions run), fixed in feat(shm): fork-shared arena, IPC primitives, result slots, mutable shared mode (closes #16, closes #18, closes #17) #21 and not to be re-armed.Registry's docblock history stays current. A consumer structure in the arena payload (channel, shared array, closure table) does not bump it — it publishes itself in the roots directory.persistentFree().(int)casts on FFI reads, no public method returnsFFI\CData, docblocks cite evidence, the.phpt-free PHPUnit suite and its fork-harness patterns (children answer by exit code, parent asserts, one plane per process created pre-fork), dual 8.4/8.5 runs checking the exit code rather than the summary line, spikes are Composer-only.README
The scope section no longer claims one worker process: it describes the fork tree (arena mapped pre-fork, unrelated-process attach out of scope), frozen-by-default with mutation on request, and signalling separated from data. Added a short feature map table pointing at the deep docs and AGENTS.md, a Shared closures section with a working example, and the closure entries in the value-record and API sections. The frozen-mode documentation is unchanged and still accurate.
E5 Phase A — closure exchange by provenance
Why registration is the whole acceptance test
A closure compiled before the fork is safe by address: the
zend_closure, its embeddedzend_function, opcodes, literals and captured values were laid out by the parent, so every worker maps them at the same addresses. A closure compiled after the fork is not, and it does not fail loudly — S17 held a stale address in a sibling and found a different, perfectly validClosurethere, which executed the wrong function on 8.5 (SIGSEGV on 8.4). Nothing about the object separates the two cases (EPIC #15, correction #8), so nothing about the object is ever consulted.Ipc\ClosureProvenanceis therefore a register, not a validator:registerSharedClosure($name, $closure)beforemarkForkBarrier(); every later registration is refused, and so is any registration from a worker (creator-pid check), so the two guards hold independently;PIN_BASELINEbefore the fork, so no process of the family can release memory the others are reading;zend_functionwitness). Both are checks on our own bookkeeping and are documented as such — never a reason to accept a closure.What a shared closure may carry
Enforced at registration, before any record is written, using native reflection for the captured values and z-engine's op-array view for how they were captured:
$thispersist()used scalars, strings, shared objects,SharedArrayused plain array / request object / resource / closureuse (&$x)static $n = 0inside the bodyTransport
ValueTag::Closure(9) is appended to the tag set — payload is the record address, so it is address-shaped, arena-resident and bounds-checkable like every other address record.ValueCodecencodes a closure only if the register knows it and refuses everything else exactly as before, now namingregisterSharedClosure()in the message. Tag numbers are appended, never renumbered: they are the wire contract of the consumer runtime.Registry::LAYOUT_VERSIONis unchanged at 5 — the closure table is a consumer structure in the arena payload published under a roots name, and adds no field to any persisted record.Test evidence (#20, Phase A criteria)
SharedClosureForkTest::testTwoChildrenInvokeAPreForkClosureAHundredThousandTimesEach— record address arrives as 8 machine-order bytes on a socket pair, 10⁵ invocations per child, both sums verified from arena words…testAClosureCrossesAChannelAsARecordAddressAndRunsInTheReceiver$thisread in a worker…testAClosureCapturingASharedObjectReadsItInEveryWorker,…testAClosureBoundToASharedObjectIsAcceptedAndRunsInAWorker…testAnUnregisteredClosureIsRefusedAndNamesTheRegistration,…testACodecWithoutARegisterRefusesEveryClosureExactlyAsBefore,ValueCodecTest(unchanged)…testRegisteringAfterTheForkBarrierIsRefused,…testAWorkerNeverRegistersAClosureOfItsOwnEvenBeforeAnyBarrier,…testAnAddressThatIsNotARecordIsRefusedBeforeAnythingIsDereferenced$thisspikes/s17-prefork-closures.php— 4 children × 50k invocations of three closures, 13 checks, GREEN on 8.4.19 and 8.5.9Phase B verdict —
docs/closure-cloning.mdThe inventory, measured on both minors for the smallest capturing closure:
zend_closurezend_op_arraystructCopying that is not the problem — z-engine already reads every piece and
FunctionBodySwapis prior art. The blocker is thatrun_time_cache__ptrandstatic_variables_ptr__ptrare per-request slots: put the struct holding them in the arena and two workers write each other's caches and each other's static variables with no lock and no signal. Measured evidence in the doc: the run-time cache is non-null at creation (cache_size8 on 8.5, 0 on 8.4), and on 8.5 the live static-variable table is demonstrably a different table from the declaration defaults.Verdict: achievable, scoped as a follow-up. Phase B is "clone the op_array and re-mint the two per-process slots in every attaching process" — the side-table mechanism E2 already built for objects (#17). The doc records what to start from, the
ZEND_MAP_PTRindirection to solve first, and that a cloned closure gaining a persisted record would bumpLAYOUT_VERSION. #20 stays open for Phase B; its acceptance criteria allow a documented verdict, and this is it.Gate results
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 checked explicitly:15 new tests (151 → 166); the soak numbers are unchanged from #21, as they should be — nothing on the frozen or mutable paths moved.
Not done here, deliberately
(int)casts on FFI reads. AGENTS.md now says so rather than leaving it to be rediscovered.🤖 Generated with Claude Code
https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe
Generated by Claude Code