Skip to content

feat(shm): AGENTS.md and pre-fork closure exchange (closes #19; #20 Phase A) - #22

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

feat(shm): AGENTS.md and pre-fork closure exchange (closes #19; #20 Phase A)#22
lisachenko merged 5 commits into
mainfrom
claude/php-coroutines-plan-5vovsz

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

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.md for the why rather than restating it:

  1. The Never-Serialize Rule, verbatim, as invariant feat: persistent PHP objects that survive the request boundary #1 — plus the note that it is tested (NotificationPlaneForkTest shadows the encoders, proves the shadows intercept, then measures zero calls) and that a new namespace on the data path has to join the guard.
  2. Sharing is fork-only — unrelated-process attach permanently out of scope, children never write module globals (COW page), children never free arena memory, classes loaded before the fork.
  3. Two modes — frozen (default, rollback intact) vs shared-mutable (opt-in per graph, role in the registry, side table mandatory, mutableHandle() synchronized, direct writes legal-but-unsynchronized, mode conflict refused).
  4. The arData law — pre-sized or purpose-built containers only, insert past capacity refused, bounds-check HT_GET_DATA_ADDR on recovery with nTableMask read signed.
  5. Lock disciplineEOWNERDEAD at 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.
  6. Leak-until-teardown accounting — bump-only arena, string rewrites cost blocks, watermark soak gates, and no explicit munmap: the shutdown-function unmap was a real crash (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.
  7. LAYOUT_VERSION discipline — currently 5; any record/layout change bumps it in the same commit, readers hard-fail, 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.
  8. Frozen-mode invariants that must survive every change — alias-safety ordering, the refcount pin, never-free-strings, untrack-before-persistentFree().
  9. House rules — PER-CS2.0 by hand (no fixer configured), explicit (int) casts on FFI reads, no public method returns FFI\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.
  10. Closures — provenance-based acceptance only; never validate a closure by shape.

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 embedded zend_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 valid Closure there, 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\ClosureProvenance is therefore a register, not a validator:

  header (64 B)    magic | capacity | count | creator pid | barrier pid
  record (64 B)    closure address | zend_function witness | name length |
                   bound $this address | name[32]
  • the arena-owning process calls registerSharedClosure($name, $closure) before markForkBarrier(); every later registration is refused, and so is any registration from a worker (creator-pid check), so the two guards hold independently;
  • the barrier is recorded in the arena, not in the registering process, so a worker booting its own view still sees a closed register;
  • the record lives in the arena; the closure is never copied — a pre-fork one needs no copy. A test and the spike both assert the closure address is outside the arena while its record is inside;
  • the closure object is pinned at PIN_BASELINE before the fork, so no process of the family can release memory the others are reading;
  • resolution is bounds-checked (must be a record slot of this table), then integrity-checked (class entry, then the zend_function witness). 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:

Capture Verdict
bound $this null, or an object of this store (arena address recorded); a request object is refused naming persist()
used scalars, strings, shared objects, SharedArray accepted — the ordinary value contract
used plain array / request object / resource / closure refused with the same messages a channel would give
use (&$x) refused: a per-request slot each worker copy-on-writes, so the write is invisible everywhere else
static $n = 0 inside the body refused: same reason, and a counter there would count each process separately with nothing reporting it

Transport

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. ValueCodec encodes a closure only if the register knows it and refuses everything else exactly as before, now naming registerSharedClosure() in the message. Tag numbers are appended, never renumbered: they are the wire contract of the consumer runtime.

Registry::LAYOUT_VERSION is 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)

Criterion Where
Pre-fork closure invoked from two children concurrently, correct results SharedClosureForkTest::testTwoChildrenInvokeAPreForkClosureAHundredThousandTimesEach — record address arrives as 8 machine-order bytes on a socket pair, 10⁵ invocations per child, both sums verified from arena words
Channel/result-slot transport as a value record …testAClosureCrossesAChannelAsARecordAddressAndRunsInTheReceiver
Captured shared object read in a worker; bound shared $this read in a worker …testAClosureCapturingASharedObjectReadsItInEveryWorker, …testAClosureBoundToASharedObjectIsAcceptedAndRunsInAWorker
Typed rejection for anything not registered …testAnUnregisteredClosureIsRefusedAndNamesTheRegistration, …testACodecWithoutARegisterRefusesEveryClosureExactlyAsBefore, ValueCodecTest (unchanged)
Post-barrier registration, worker registration, non-record address …testRegisteringAfterTheForkBarrierIsRefused, …testAWorkerNeverRegistersAClosureOfItsOwnEvenBeforeAnyBarrier, …testAnAddressThatIsNotARecordIsRefusedBeforeAnythingIsDereferenced
Capture refusals by reference, declared static, plain array, request object, request-bound $this
Spike evidence, both minors spikes/s17-prefork-closures.php — 4 children × 50k invocations of three closures, 13 checks, GREEN on 8.4.19 and 8.5.9

Phase B verdict — docs/closure-cloning.md

The inventory, measured on both minors for the smallest capturing closure:

Piece 8.4.19 8.5.9
zend_closure 344 B 344 B
zend_op_array struct 256 B 256 B
opcodes 7 × 32 = 224 B 5 × 32 = 160 B
literals 16 B 0 B
enumerable total ~496 B ~416 B

Copying that is not the problem — z-engine already reads every piece and FunctionBodySwap is prior art. The blocker is that run_time_cache__ptr and static_variables_ptr__ptr are 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_size 8 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_PTR indirection to solve first, and that a cloned closure gaining a persisted record would bump LAYOUT_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.4 line) and PHP 8.5.9 (master line), both -d ffi.enable=1 -d opcache.jit=off, exit codes checked explicitly:

vendor/bin/phpunit                    OK (166 tests, 14455 assertions)   # 8.4 and 8.5, exit 0
vendor/bin/phpunit --order-by=random  OK (166 tests, 14455 assertions)   # 8.4 and 8.5, exit 0
MALLOC_CHECK_=3 MALLOC_PERTURB_=85    OK (166 tests, 14455 assertions)   # 8.4 and 8.5, exit 0
tools/soak.php 5000                   SOAK OK       final delta -2488 bytes (allowed 65536)
tools/soak-drop.php 5000              SOAK-DROP OK  request +31616 bytes, 3.97 kB/cycle (budget 6)
spikes/s17-prefork-closures.php       S17 GREEN     13 checks, 0 failures   # 8.4 and 8.5

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

🤖 Generated with Claude Code

https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe


Generated by Claude Code

claude added 5 commits August 15, 2026 21:35
…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
@lisachenko
lisachenko marked this pull request as ready for review August 15, 2026 21:48
@lisachenko
lisachenko merged commit 5d170a0 into main Aug 15, 2026
8 checks passed
@lisachenko
lisachenko deleted the claude/php-coroutines-plan-5vovsz branch August 15, 2026 21:48
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.

E4: AGENTS.md (Never-Serialize Rule + shared-arena invariants) and README scope update

2 participants