Skip to content

F: Layer P integration — shared roots, shared channels, result slots, JoinHandle #7

Description

@lisachenko

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.

⛔ Blocked on lisachenko/php-shared-data-extension#16 (E1 — arena, arena-backed persist) and lisachenko/php-shared-data-extension#18 (E3 — SharedChannel, SharedArray, result slots, event-record notification protocol). Everything else in #1 is independent of the substrate; only this ticket waits.

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.

  1. 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.)
  2. 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().
  3. 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.
  4. 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.
  5. 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.
  6. 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 rootsdeclareShared(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.
  • spawnParallelawait() 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.
  • A coroutine blocked on a shared primitive or an outstanding JoinHandle is not reported by DeadlockException (externally wakeable — see A: local scheduler — run queue, timers, poller, deadlock detection #3).
  • Soak: arena watermark plateaus (no unbounded growth), memory flat, zero leftover children.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions