Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,18 @@ in every process.

### Known limits of the parallel surface, stated rather than discovered

- **A shared channel needs capacity ≥ 1.** The substrate's cross-process rendezvous only accepts a
send while a sibling is parked inside *its* blocking `recv()`, which this runtime never calls.
Capacity 0 is refused at declaration instead of delivered as a channel that usually does nothing.
- **A capacity-0 shared channel works, but not as a `select` send case.** The substrate's handoff
gate asks whether a receiver is waiting, and it used to count only receivers parked inside *its*
blocking `recv()` — which this runtime never calls. It now also counts a receiver **registered**
from here (`registerReceiver()`/`cancelReceiver()`), so a Fiber parked on this poller is a valid
rendezvous partner and `declareShared(..., capacity: 0)` is accepted. The registration is a claim
about presence, never about storage: the record still goes into the one ring slot a capacity-0
channel allocates, which is what makes a withdrawal total — a select loser cancels and the record
it may have attracted simply waits in the ring for the next receiver while its sender stays
parked. Nothing is lost and nothing is delivered twice. `send()` therefore returns only once the
value has been **taken**, and that is exactly why a rendezvous cannot be a `select` **send** case:
a case must resolve without parking, and the deposit — the only non-parking moment — is one step
too early. That case is refused with both remedies named; receive cases compose as usual.
- **Graphs are keyed per instance, and each unpublished spawn keeps its memory until teardown.**
The substrate registers a persisted graph under a name minted from its own root address
(`persistInstance()`), so any number of tasks of one class are in flight at once and none
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,10 +552,13 @@ z-engine requires it, and z-engine is a hard dependency of this package.
whichever process filled it. Closures are shareable only by **pre-fork registration**
(`registerSharedClosure()`); work created after the fork travels as a `Task`. Anything else throws
`NotShareableValueException` naming the remedy.
- **A shared channel needs capacity ≥ 1.** A cross-process rendezvous only accepts a send while a
sibling is parked inside the substrate's own blocking `recv()`, and this runtime parks Fibers on its
poller instead — so capacity 0 is refused rather than delivered as a channel that usually does
nothing.
- **A capacity-0 shared channel is a real rendezvous, except as a `select` send case.** A
cross-process handoff is accepted while a receiver is waiting, and a Fiber parked on this
runtime's poller counts as one: the channel registers this process with the substrate while it has
a waiting receiver and withdraws when the last one leaves. `send()` returns once the value has
been **taken**, which is also why a rendezvous cannot be a `select` *send* case — a case has to
resolve without parking, and the deposit is one step too early to promise a take. That one case is
refused with the remedies named; receive cases mix with local channels as usual.
- **`persist()` is per instance, roots are per name.** Two `RenderJob`s — or twenty — are twenty
graphs, none superseding another, and two roots of one class are two roots. What a design pays
for spawning arbitrary unpublished tasks is arena memory per spawn, held until teardown;
Expand Down
26 changes: 20 additions & 6 deletions src/Parallel/SharedArena.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ final class SharedArena
/** One entry per attached process, holding its wake slot; the broadcast list. */
private readonly SharedArray $family;

/**
* Wake slots the registry was created with — the size of the largest family this arena serves.
*
* Also the waiter capacity every shared channel is created with: a capacity-0 channel registers
* one entry per process that has a receiver waiting, so a table smaller than the family could
* refuse a registration that is perfectly legitimate, and a table larger than it could never be
* filled. Pre-sized either way — no arena table ever grows.
*/
private readonly int $wakeSlots;

/**
* Roots declared before the fork: name => descriptor. Every child inherits this table with the
* rest of the parent's heap, so a worker resolving a root needs no lookup protocol at all.
Expand Down Expand Up @@ -209,7 +219,8 @@ public function __construct(
$slotCount,
self::SLOTS_ROOT,
);
$this->family = SharedArray::create($this->allocator, $this->codec, $wakeSlots, self::FAMILY_ROOT);
$this->family = SharedArray::create($this->allocator, $this->codec, $wakeSlots, self::FAMILY_ROOT);
$this->wakeSlots = $wakeSlots;

// The panic path persists one of these in whichever worker died, and the waiter attaches it
// by address. Loaded here so the whole family agrees on its class entry.
Expand Down Expand Up @@ -554,12 +565,15 @@ public function shared(string $name): mixed
private function createRoot(string $name, string $class, int $capacity): array
{
if (is_a($class, SharedChannel::class, true) || is_a($class, SubstrateChannel::class, true)) {
if ($capacity < 1) {
if ($capacity < 0) {
// Capacity 0 is a rendezvous, exactly as it is for a local Channel: the substrate
// now lets a receiver parked on this runtime's poller count as the handoff partner
// (registerReceiver()), so nothing has to spin inside the substrate for it to work.
throw new \InvalidArgumentException(sprintf(
'shared channel "%s" needs a capacity of at least 1: a cross-process rendezvous '
. 'accepts a send only while a sibling is parked inside the substrate\'s own '
. 'blocking recv(), and this runtime parks Fibers on its poller instead',
'shared channel "%s" cannot have a negative capacity, got %d; 0 is a '
. 'cross-process rendezvous and a positive number buffers that many records',
$name,
$capacity,
));
}

Expand All @@ -568,7 +582,7 @@ private function createRoot(string $name, string $class, int $capacity): array
$this->codec,
$this->wake,
$capacity,
SubstrateChannel::DEFAULT_WAITERS,
max($this->wakeSlots, SubstrateChannel::DEFAULT_WAITERS),
$name,
);

Expand Down
Loading