Skip to content

feat(ipc): let a receiver parked elsewhere be a rendezvous partner - #26

Merged
lisachenko merged 4 commits into
mainfrom
claude/rendezvous-registered-receivers
Aug 16, 2026
Merged

feat(ipc): let a receiver parked elsewhere be a rendezvous partner#26
lisachenko merged 4 commits into
mainfrom
claude/rendezvous-registered-receivers

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

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 on main first.

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 one stream_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:

$token  = $channel->registerReceiver();   // null => a record is already there, take it now
$channel->cancelReceiver($token);         // on unpark, whatever woke it
$ticket = $channel->trySendTicket($v);    // deposits only while a receiver is waiting
$channel->isTicketTaken($ticket);         // head > ticket — the handshake completed

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 a select loser 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), and unpark() 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 in src/ goes through WaiterTable; no structure decodes a waiter word directly. register()'s pid parameter is optional, so ResultSlotTable and SharedWaitGroup entries 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 at WaiterTable as the owner of the encoding.

LAYOUT_VERSION unchanged: SharedChannel is 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:

  • Suite: OK (190 tests, 15 642 assertions), clean under --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.
  • 8 new fork tests, including testRegistrationsCancelledUnderContentionNeverLoseOrDuplicateAHandoff (100 handoffs with the receiver withdrawing every third lap, asserting count and sum so a loss and a duplicate are both caught), testADeadWorkersRegistrationDoesNotMakeALaterSendBelieveAPartnerIsPresent, and testARecordDepositedAgainstACancelledRegistrationStaysForTheNextReceiver.
  • PHP 8.4 is CI's leg (single local vendor tree resolves 8.5 here).

🤖 Generated with Claude Code

https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA


Generated by Claude Code

claude added 4 commits August 16, 2026 10:01
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
…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
lisachenko marked this pull request as ready for review August 16, 2026 10:23
@lisachenko
lisachenko merged commit 818efef into main Aug 16, 2026
8 checks passed
@lisachenko
lisachenko deleted the claude/rendezvous-registered-receivers branch 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
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.

2 participants