feat(parallel): accept capacity 0 on a shared channel as a real rendezvous - #32
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.SharedChannelregisters 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.send()deposits its record (only possible while a partner is registered), then parks a second time on the ticket untilisTicketTaken()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.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
selectsend 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 whilesend()on the same channel keeps rendezvous ones. It throws with both remedies named (a capacity-1 channel, or a plainsend()). Receive cases compose inSelectnormally, alongside local channels.Acceptance criteria from #14
declareShared(..., capacity: 0)accepted —testACapacityZeroSharedChannelIsAccepted.phpttestARendezvousSendWaitsUntilAnotherProcessTakesTheValue.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 afterhasWaitingReceiver(), so what is measured is the take alone.testARendezvousReceiverParksUntilASenderArrives.phpttestACapacityZeroHandoffCostsABoundedNumberOfWakeupsOnBothSides.phpt: 8 handoffs, both processes counted, bound 64, measured 9 and 14.Selectwith a local channel —testACapacityZeroSharedChannelComposesInASelectWithALocalChannel.phpttestASelectLoserLeavesNoRendezvousRegistrationForALaterSend.phpt,testACancelledContextWithdrawsItsRendezvousRegistration.phpt— both probe the shared count from another process.testARendezvousWaitIsNeverAFalseDeadlock.phptPlus 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):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