You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The glue that turns the substrate primitives into the package's public parallel API. This ticket writes no shared-memory machinery of its own — the arena, rings, slots and locks come from the extension; this is the PHP-side runtime that drives them and makes them selectable alongside local channels.
For local development against unmerged substrate work, pin the extension to dev-claude/php-coroutines-plan-5vovsz in composer.json and mark the temporary pin in the PR body. The pin must be removed before the milestone is undrafted.
✅ The premise is validated
The substrate spikes (ext#15) ran 84/84 green on PHP 8.4.19 and 8.5.9: an engine-formatted zend_object in MAP_SHARED|MAP_ANONYMOUS memory attaches as an ordinary PHP instance in several forked processes at once, and a plain $obj->prop = ... in one is visible to the others within ~200 µs. The reverse direction works too — a child bump-allocates and persists post-fork, sends 8 bytes, and the parent attaches. Zero-copy cross-process objects are real; this ticket is glue, not a gamble.
Binding corrections from those spikes
These are acceptance criteria, not advice. They come from measured failures, and each one is a defect that would otherwise be found in production rather than in review.
A 16-byte record is not read atomically. ~1.3 % of unlocked reads saw payload and tag from different generations. Every record in an arena ring or result slot is therefore either accessed wholly under the slot's mutex, or published payload-first-tag-last and read tag-first-payload-second, with the tag as the publication flag. Never a mix of the two disciplines. (The control socket is unaffected — an ordered byte stream cannot tear.)
An aligned 8-byte pointer read is atomic. A single-slot OBJ/STR/ARR address read whose tag cannot change may skip the lock and get old-or-new, never a mix. Do not over-lock the hot read path in await().
Never var_dump(), json_encode(), get_object_vars() or (array) a shared object unless the extension's get_properties_for interception is active. Those read-shaped operations make engine C code write a per-process properties pointer into the shared struct, segfaulting every sibling afterwards. This binds the runtime's own diagnostics hardest: a panic path or debug dump is exactly the code that reaches for var_dump() on the value it is reporting.
Never key a shared object by spl_object_id(), and never put one in an SplObjectStorage. Forked children inherit the same object-store free list and hand out identical handle numbers — handles collide by construction. Arena address is the only cross-process identity, and spl_object_id() on a shared object needs a documented story rather than an assumption.
Closures are rejected on provenance — compiled before the fork barrier — never on shape. A post-fork closure cannot be recognised by inspection: a stale address held a different, perfectly valid Closure that on PHP 8.5 executed the wrong function instead of failing. Any check that inspects bound variables, scope or arity will pass that case.
A worker that dies holding an arena lock surfaces EOWNERDEAD. The extension handles recovery, but this ticket must make sure the resulting failure reaches the waiter as a WorkerCrashedException rather than a silent hang, and that a recovered-but-inconsistent slot is not read as if it were valid.
Scope
Named shared roots — declareShared(string $name, string $class, int $capacity) at configuration time, shared(string $name) for lookup. Roots are created pre-fork so every worker inherits them by address; declaring a root after run() has forked is an error with a message that says so.
persist() / attach flow — $runtime->persist($obj) clones a graph into the arena and hands back the shared instance. Attaching in a worker is by address, through the extension's per-process side table; the package must never cache an arena address across a fork boundary in a way that assumes the parent's per-process state.
SharedChannel integration — expose the substrate ring through ChannelInterface so it drops into the existing Select unchanged. readinessFd() returns the worker's wake-pipe FD; the poller must drain the wake pipe on readiness (level-triggered pokes: spurious wakeups are harmless, lost wakeups impossible, but an undrained pipe spins the poller).
Result slots + JoinHandle::await() — spawnParallel() allocates a slot, returns a handle; await() parks the calling coroutine on the pipe FD via the poller, wakes on the RESULT record, and reads the value directly from shared memory. Awaiting an already-complete slot returns immediately without parking; a slot may be awaited from any process.
Panic path — an uncaught Throwable in a parallel task completes its slot as PANIC with the address of a shared error-info object (class, message, trace as arena strings), rethrown at await() as ParallelTaskException preserving that information. The NSR applies here too: the panic must not be serialized to cross the boundary — and per correction 3, the path that builds it must not var_dump() the offending value.
NotShareableValueException — thrown when a value cannot cross a boundary (plain array, closure, resource, non-shared object). The message must name the remedy: $runtime->persist($obj), use SharedArray, implement Task.
Version-gate on the substrate's LAYOUT_VERSION 4 and fail fast with a clear message on mismatch.
Acceptance criteria
A worker mutates a shared object and the parent observes the mutation on the same identity — zero-copy, no re-read from any encoding.
spawnParallel → await() returns each tag correctly: NIL/TRUE/FALSE, INT, FLOAT, STR (arena string), OBJ (shared object), ARR (SharedArray).
await() on an already-completed slot does not park; await() from a different process than the spawner works.
Slot publication order is asserted, not assumed: a test that writes a slot and reads it concurrently under contention must never observe a tag newer than its payload.
No var_dump/json_encode/get_object_vars/(array) on a shared object anywhere in the shipped source — enforced by a source check, including the panic and diagnostic paths.
No spl_object_id()/SplObjectStorage keying of shared objects — enforced the same way.
A SharedChannel and a local Channel in a single Select both fire, and the shared one wakes the poller through its readiness FD.
The wake pipe is drained — a test asserts the poller does not spin (bounded wakeups for a bounded number of sends).
Task panic surfaces as ParallelTaskException with class, message and trace intact.
A worker killed while holding an arena lock surfaces WorkerCrashedException at the waiter — no hang, no silently-consumed inconsistent slot.
NotShareableValueException for a plain array, a closure, a resource and a non-shared object — each message naming its remedy; the closure case documented as provenance-based.
No serialize/igbinary/json_encode anywhere on the data path — enforced by a test that greps the shipped source, so a regression fails CI rather than review.
Part of #1. Plan: artifact.
The glue that turns the substrate primitives into the package's public parallel API. This ticket writes no shared-memory machinery of its own — the arena, rings, slots and locks come from the extension; this is the PHP-side runtime that drives them and makes them selectable alongside local channels.
✅ The premise is validated
The substrate spikes (ext#15) ran 84/84 green on PHP 8.4.19 and 8.5.9: an engine-formatted
zend_objectinMAP_SHARED|MAP_ANONYMOUSmemory attaches as an ordinary PHP instance in several forked processes at once, and a plain$obj->prop = ...in one is visible to the others within ~200 µs. The reverse direction works too — a child bump-allocates and persists post-fork, sends 8 bytes, and the parent attaches. Zero-copy cross-process objects are real; this ticket is glue, not a gamble.Binding corrections from those spikes
These are acceptance criteria, not advice. They come from measured failures, and each one is a defect that would otherwise be found in production rather than in review.
OBJ/STR/ARRaddress read whose tag cannot change may skip the lock and get old-or-new, never a mix. Do not over-lock the hot read path inawait().var_dump(),json_encode(),get_object_vars()or(array)a shared object unless the extension'sget_properties_forinterception is active. Those read-shaped operations make engine C code write a per-processpropertiespointer into the shared struct, segfaulting every sibling afterwards. This binds the runtime's own diagnostics hardest: a panic path or debug dump is exactly the code that reaches forvar_dump()on the value it is reporting.spl_object_id(), and never put one in anSplObjectStorage. Forked children inherit the same object-store free list and hand out identical handle numbers — handles collide by construction. Arena address is the only cross-process identity, andspl_object_id()on a shared object needs a documented story rather than an assumption.Closurethat on PHP 8.5 executed the wrong function instead of failing. Any check that inspects bound variables, scope or arity will pass that case.EOWNERDEAD. The extension handles recovery, but this ticket must make sure the resulting failure reaches the waiter as aWorkerCrashedExceptionrather than a silent hang, and that a recovered-but-inconsistent slot is not read as if it were valid.Scope
declareShared(string $name, string $class, int $capacity)at configuration time,shared(string $name)for lookup. Roots are created pre-fork so every worker inherits them by address; declaring a root afterrun()has forked is an error with a message that says so.persist()/ attach flow —$runtime->persist($obj)clones a graph into the arena and hands back the shared instance. Attaching in a worker is by address, through the extension's per-process side table; the package must never cache an arena address across aforkboundary in a way that assumes the parent's per-process state.SharedChannelintegration — expose the substrate ring throughChannelInterfaceso it drops into the existingSelectunchanged.readinessFd()returns the worker's wake-pipe FD; the poller must drain the wake pipe on readiness (level-triggered pokes: spurious wakeups are harmless, lost wakeups impossible, but an undrained pipe spins the poller).JoinHandle::await()—spawnParallel()allocates a slot, returns a handle;await()parks the calling coroutine on the pipe FD via the poller, wakes on theRESULTrecord, and reads the value directly from shared memory. Awaiting an already-complete slot returns immediately without parking; a slot may be awaited from any process.Throwablein a parallel task completes its slot asPANICwith the address of a shared error-info object (class, message, trace as arena strings), rethrown atawait()asParallelTaskExceptionpreserving that information. The NSR applies here too: the panic must not be serialized to cross the boundary — and per correction 3, the path that builds it must notvar_dump()the offending value.NotShareableValueException— thrown when a value cannot cross a boundary (plain array, closure, resource, non-shared object). The message must name the remedy:$runtime->persist($obj), useSharedArray, implementTask.LAYOUT_VERSION 4and fail fast with a clear message on mismatch.Acceptance criteria
spawnParallel→await()returns each tag correctly:NIL/TRUE/FALSE,INT,FLOAT,STR(arena string),OBJ(shared object),ARR(SharedArray).await()on an already-completed slot does not park;await()from a different process than the spawner works.var_dump/json_encode/get_object_vars/(array)on a shared object anywhere in the shipped source — enforced by a source check, including the panic and diagnostic paths.spl_object_id()/SplObjectStoragekeying of shared objects — enforced the same way.SharedChanneland a localChannelin a singleSelectboth fire, and the shared one wakes the poller through its readiness FD.ParallelTaskExceptionwith class, message and trace intact.WorkerCrashedExceptionat the waiter — no hang, no silently-consumed inconsistent slot.NotShareableValueExceptionfor a plain array, a closure, a resource and a non-shared object — each message naming its remedy; the closure case documented as provenance-based.serialize/igbinary/json_encodeanywhere on the data path — enforced by a test that greps the shipped source, so a regression fails CI rather than review.JoinHandleis not reported byDeadlockException(externally wakeable — see A: local scheduler — run queue, timers, poller, deadlock detection #3).