feat(ipc): let a receiver parked elsewhere be a rendezvous partner - #26
Merged
Conversation
A capacity-0 SharedChannel accepts a value only while a receiver is waiting, and until now the only way to be one was to be inside the blocking recv() spin loop. A consumer with its own scheduler never calls it - its whole invariant is that a worker blocks in exactly one place - so a cross-process rendezvous was unusable from a coroutine runtime rather than merely inconvenient (native-php-coroutines#14). registerReceiver()/cancelReceiver(), and their sender mirrors, make the registration a named operation: announce the interest, park the Fiber on notificationStream(), withdraw on unpark. trySendTicket() and isTicketTaken() expose the two halves a rendezvous is made of, since the deposit and the take are separate events and only the second completes the handshake. A registration is a claim about presence, never about storage. The record still goes into the single ring slot max($capacity, 1) already allocates, so there is no per-registration cell to keep consistent: cancellation can always succeed because it never has a value in its hands, and a record deposited against a registration withdrawn a moment later waits in the ring for the next receiver while its sender stays parked. The whole handshake stays under the channel's own mutex, so the happens-before edge is the release/acquire pair it always was. Registrations can outlive their process, unlike a waiter that parks inside a call and releases its own entry on the way out. Each waiter word now packs `owner pid << 32 | wake slot + 1` - one aligned word, because two words are a 16-byte record and those tear - and a rendezvous deposit reaps the entries whose owner is gone before it reads the gate. Liveness is posix_kill(pid, 0) plus the wake registry still naming that pid as the slot's owner, since a dead owner's slot is recycled to the next claimer. Also fixes a counter leak found next door: parkForRoom()/parkForRecord() incremented the parked count even when the waiter table was full, and unpark() has nothing to give back for an entry that was never taken, so the count stayed permanently high - which on a rendezvous channel is exactly the gate a handoff passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
…ncelled A registration token outlives the call that produced it, which makes a repeated cancel a mistake a caller can actually make - unlike a waiter parked inside recv(), which holds its entry for the length of one function. Counting the same entry down twice would drive the parked count below the truth, and on a rendezvous channel that count is the gate a handoff passes: too low and the channel stops accepting values with a receiver standing right there. unpark() now releases only an entry that is still occupied. It is not a licence to cancel twice - by then the entry may hold a NEW registration, which a second cancel would withdraw - so the docblock says that outright and the token still has to be dropped once used. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
…istered-receivers
…oding The header comment described the waiter region as "wake slots parked on zero", which was the whole story when a waiter word held nothing but a biased slot. It now packs an owner pid alongside it, and the encoding belongs to WaiterTable rather than to any of its users - a wait group records no owner, so its words are unchanged, but the comment should point at the structure that decides rather than restate one case of it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
lisachenko
marked this pull request as ready for review
August 16, 2026 10:23
lisachenko
added a commit
to lisachenko/native-php-coroutines
that referenced
this pull request
Aug 16, 2026
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.
The substrate half of lisachenko/native-php-coroutines#14 — Option 1, the named upstream API the issue asked for. The consumer half is the companion PR on
native-php-coroutines, which needs this onmainfirst.The problem
A capacity-0 channel's rendezvous gate asked "is a receiver waiting", and only a receiver spinning inside this package's own blocking
recv()could ever be one. A scheduler-driven consumer never calls that method — its whole invariant is that every wait funnels into onestream_select()— so a cross-process rendezvous was unusable from exactly the runtime it exists for, and the consumer refused capacity 0 outright.The design: a registration is a claim about presence, never about storage
The issue sketched a per-registration handoff cell. It is not needed: a capacity-0 channel already allocates one ring slot, so the new surface only asserts presence:
plus
registerSender()/cancelSender(),parkedReceivers()/parkedSenders(),reapDeadWaiters().Happens-before is unchanged: the channel's own robust mutex.
registerReceiver()registers and re-checks readiness inside one critical section — the same shape the blocking path already used — so a deposit after that point necessarily sees the entry, and a record that arrived before it is reported instead of waited for.isTicketTaken()is a lock-free read of one aligned monotonic word, which the atomicity contract permits. One new wake edge: on a rendezvous channel,registerReceiver()notifies parked senders after releasing the lock — the only state change that can unblock a non-spinning sender when nothing is published and no room is freed.Cancellation is total.
cancelReceiver()never returns a value because it never has one: a record deposited against a registration withdrawn a moment later stays in the ring, the next receiver takes it, and the sender goes on waiting — exactly the state it would be in had it never found a partner. Nothing lost, nothing double-delivered, and no caller is ever handed a value it cannot deliver. That is what lets aselectloser cancel and walk away clean — and it is why a sender waits for the take, not the deposit.Stale registrations cannot fake a partner. A waiter word now packs
owner pid << 32 | wake slot + 1— one aligned word, because two would be a tearing 16-byte record. A rendezvous deposit surveys liveness before the lock (syscalls stay out of critical sections:posix_kill(pid, 0)plus the wake registry still naming that pid) and releases dead entries inside the same critical section that reads the gate. Even a missed reap is bounded: a value deposited for a phantom is never eaten — it waits in the ring.Two adjacent bugs fixed on the way:
parkForRoom()/parkForRecord()bumped the parked count even when the waiter table was full (the count never came back down — and on a rendezvous channel that count is the gate), andunpark()now leaves an already-free entry alone so a repeated cancel cannot drive the count below the truth.Merged with main, and audited against it
Rebuilt on #24 (prefault) and #25 (slot recycling); zero textual conflicts. The encoding change was audited against the merged tree as a condition of this PR:
ResultSlotTable's release path touches waiter words only by writing zeros — correct under any encoding — and every other access insrc/goes throughWaiterTable; no structure decodes a waiter word directly.register()'s pid parameter is optional, soResultSlotTableandSharedWaitGroupentries carry pid 0 and the reaper never touches them (their waiters release inside one call and cannot go stale).SharedWaitGroup's header comment now points atWaiterTableas the owner of the encoding.LAYOUT_VERSIONunchanged:SharedChannelis arena payload published in the roots directory and adds no registry field (§7's stated exemption).Verification
PHP 8.5 (this tree's z-engine line),
-d ffi.enable=1 -d opcache.jit=off:--order-by=random; the recycling fork tests (10/10) and channel fork tests (17/17) pass against the merged tree.tools/soak.php 5000— SOAK OK;tools/soak-drop.php 5000— SOAK-DROP OK.testRegistrationsCancelledUnderContentionNeverLoseOrDuplicateAHandoff(100 handoffs with the receiver withdrawing every third lap, asserting count and sum so a loss and a duplicate are both caught),testADeadWorkersRegistrationDoesNotMakeALaterSendBelieveAPartnerIsPresent, andtestARecordDepositedAgainstACancelledRegistrationStaysForTheNextReceiver.🤖 Generated with Claude Code
https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
Generated by Claude Code