Skip to content

feat(parallel): accept capacity 0 on a shared channel as a real rendezvous - #32

Merged
lisachenko merged 4 commits into
mainfrom
claude/issue-14-shared-rendezvous
Aug 16, 2026
Merged

feat(parallel): accept capacity 0 on a shared channel as a real rendezvous#32
lisachenko merged 4 commits into
mainfrom
claude/issue-14-shared-rendezvous

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

Closes #14, via Option 1 — the substrate exposes a named API for registering a non-spinning waiter (lisachenko/php-shared-data-extension#26), and this runtime rides it. Depends on that PR; CI here is red until it merges into substrate main (this package requires it as dev-main) — expected, with precedent.

What works now

declareShared('x', SharedChannel::class, capacity: 0) is accepted. A cross-process rendezvous behaves like the local one: send() returns only once a receiver in another process has taken the value, a receiver parks until a sender arrives, and both sides park on their own poller — nobody spins.

  • SharedChannel registers this process with the substrate while any local coroutine waits to receive, and withdraws when the last one leaves. The registration is derived from the waiter list rather than counted — a drifting count is the stale-registration bug, so there is nothing to drift.
  • A rendezvous send() deposits its record (only possible while a partner is registered), then parks a second time on the ticket until isTicketTaken() says the handshake completed. Waiting for the take rather than the deposit is what keeps cancellation sound: a receiver that cancels after a deposit leaves the record in the ring for the next receiver, and the sender keeps waiting — nothing lost, nothing double-delivered.
  • Channel waiter tables are sized to the wake-registry slot count, so a registration can never be refused for a family the notification plane can serve.
  • serve() skips the currently-running coroutine — it is on its way into a park and has not suspended, so unparking it would strand a claimed select token. Only another process can change state in that window, and its wake event is already in our socket.

One narrower refusal replaces the blanket one

A capacity-0 shared channel cannot be a select send case. A select case must resolve without parking, and the deposit is the only non-parking moment — one step too early to promise a take. Claiming there would silently give that one case buffered semantics while send() on the same channel keeps rendezvous ones. It throws with both remedies named (a capacity-1 channel, or a plain send()). Receive cases compose in Select normally, alongside local channels.

Acceptance criteria from #14

  • declareShared(..., capacity: 0) accepted — testACapacityZeroSharedChannelIsAccepted.phpt
  • ☑ Send blocks until another process takes the value — testARendezvousSendWaitsUntilAnotherProcessTakesTheValue.phpt: the worker registers, then holds its scheduler in a call-free loop for 400 ms so the deposit is possible but the take is not; the parent's clock starts only after hasWaitingReceiver(), so what is measured is the take alone.
  • ☑ Receiver parks until a sender arrives — testARendezvousReceiverParksUntilASenderArrives.phpt
  • ☑ Neither side spins, asserted by counting — testACapacityZeroHandoffCostsABoundedNumberOfWakeupsOnBothSides.phpt: 8 handoffs, both processes counted, bound 64, measured 9 and 14.
  • ☑ Composes in a Select with a local channel — testACapacityZeroSharedChannelComposesInASelectWithALocalChannel.phpt
  • ☑ No stale registration after a Select loser or cancelled context — testASelectLoserLeavesNoRendezvousRegistrationForALaterSend.phpt, testACancelledContextWithdrawsItsRendezvousRegistration.phpt — both probe the shared count from another process.
  • ☑ Deadlock detection treats the wait as externally wakeable — testARendezvousWaitIsNeverAFalseDeadlock.phpt

Plus the refusal (testASelectSendCaseOnACapacityZeroSharedChannelIsRefused.phpt), the no-socket single-process path (testTwoCoroutinesOfOneProcessRendezvousOnASharedChannel.phpt), and an interaction test with the freshly-merged slot recycling: testASlotIsRecycledWhileARendezvousSendIsStillParkedOnItsTicket.phpt — the two structures are disjoint and share only the process's wake socket, so a slot event costs a parked rendezvous sender one spurious re-check and nothing more.

Verification

PHP 8.4, -d ffi.enable=1 -d opcache.jit=off, against the exact tree the substrate PR produces (merged branch synced into vendor):

suite      OK (121 tests, 121 assertions), 3/3 repeats identical
phpstan    level max, no errors
cs:check   clean
soaks      arena-watermark PASS (slots plateau at 1 record; watermark/allocated/rss +0.0 KiB)
           memory-flatness PASS · no-leftover-children PASS · preemption PASS
           --self-test and --leak-slots both still FAIL as required

Known limits, stated: the runtime registers receivers but not senders (sender wakeups ride the family broadcast, per the documented design) — a mixed family with a plain-substrate taker would need the sender registration the substrate already provides; and the select-send refusal fires when the select actually parks on the case, not at declaration.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA


Generated by Claude Code

claude and others added 4 commits August 16, 2026 10:02
…zvous

declareShared('x', SharedChannel::class, capacity: 0) used to be refused,
because the substrate's handoff gate counted only receivers parked inside
its own blocking recv() and this runtime never calls it - so a capacity-0
shared channel would have accepted a send approximately never. The
substrate now names the missing half (registerReceiver()/cancelReceiver(),
trySendTicket()/isTicketTaken()), and this is the consumer side of it.

SharedChannel registers this process with the substrate while any local
coroutine is waiting to receive, and withdraws when the last one leaves.
The state is derived from the waiter list rather than counted up and down,
because a count that drifts is exactly the stale registration the design
has to prevent: one decrement too many closes a gate that should be open,
one too few leaves siblings depositing values for a coroutine that has
moved on. Every path that mutates the list - a park, a select
registration, a select cancellation, a served wakeup - runs the same
derivation.

A rendezvous send returns only once the value has been TAKEN: the deposit
hands back a ticket and the sender parks a second time on it. That is what
keeps cancellation sound. A registration withdrawn between the deposit and
the take leaves the record in the one ring slot, where the next receiver
completes the handshake while the sender goes on waiting - the state it
would have been in had it never found a partner. Nothing is lost and
nothing is delivered twice, so a select loser never has to deliver a value
it can no longer deliver.

The cost is one narrower refusal in place of the old blanket one: a
capacity-0 shared channel 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 to promise a take, so claiming there would quietly
give that one case buffered semantics while send() on the same channel
keeps rendezvous ones. It is refused with both remedies named. Receive
cases compose with local channels as before, and canSend() answers false
for an open rendezvous so select's fast path never walks into a send that
parks.

Two supporting details: serve() skips the coroutine that is currently
running, since it is on its way into a park and has not suspended yet
(unpark() would not schedule it, and a claimed select token would strand
it); and shared channels are now created with a waiter table as large as
the wake registry, so a registration can never be refused for a family
that the notification plane itself can serve.

Runtime CI stays red until the substrate PR merges - this depends on the
new upstream methods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
…ticket

Slot recycling and a rendezvous handoff are disjoint by construction -
separate arena allocations, separate mutexes, bump allocation that never
reuses an address - so the only thing a released slot and a parked ticket
share is the one wake socket this process owns. Both wakeups arrive as the
same content-free poke and each waiter re-reads its own predicate, which
means a slot event can wake a rendezvous sender spuriously and costs it a
re-check of `head > ticket` and nothing more.

That was an argument. This makes it observable: a sender parks on its
ticket while a join handle takes a result slot's answer and hands the slot
back with its generation bumped and its waiter words wiped. If a recycled
slot could ever disturb a ticket wait, the send would return early or not
at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
The previous run installed the substrate from main before
lisachenko/php-shared-data-extension#26 landed there, so the
registration and ticket surface this branch consumes did not exist yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
@lisachenko
lisachenko marked this pull request as ready for review August 16, 2026 10:27
@lisachenko
lisachenko merged commit f7a0bc4 into main Aug 16, 2026
6 checks passed
@lisachenko
lisachenko deleted the claude/issue-14-shared-rendezvous branch August 16, 2026 10:27
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.

SharedChannel cannot offer capacity-0 cross-process rendezvous

2 participants