feat(channel): select — ready-index wait over multiple receivers

select(&[&dyn Selectable]) -> usize registers (pid, epoch) in every arm
under one wait epoch, parks once, and returns the first ready index in
documented priority order (BEAM-style; no fairness promise). A closed arm
counts as ready — and stays ready forever, so callers drop it from the set
once observed. Losing arms need no cancellation pass: the winning wake
consumed the epoch, so their registrations die at their wakers' failed CAS
or are overwritten by the receiver's next wait on that channel (the
single-receiver debug_asserts relax to 'none or own pid' accordingly).

The one genuinely new protocol piece is the no-park exit: returning with an
arm ready at registration time leaves earlier arms holding LIVE-epoch
registrations, whose wakes could fault a later one-shot park as a pending
notification. scheduler::retire_wait closes it — bump the epoch (in-flight
wakes die at their CAS), eat a notification that already landed
(StateWord::clear_notify), then re-observe the stop flag, in that order.
Proved by the new loom theorem retire_eats_late_arm_notification; the
integration probes in tests/select.rs fire stale loser-arm wakes and assert
a subsequent sleep's one-shot park holds its full duration.
This commit is contained in:
smarm
2026-06-10 07:45:00 +00:00
parent 400854ac5d
commit 00128f32a2
6 changed files with 466 additions and 4 deletions
+119 -3
View File
@@ -170,7 +170,7 @@ impl<T> Receiver<T> {
let me = crate::actor::current_pid()
.expect("recv() called outside an actor");
debug_assert!(
g.parked_receiver.is_none(),
g.parked_receiver.is_none_or(|(p, _)| p == me),
"channel has more than one receiver"
);
// begin_wait is lock-free — legal under the Channel lock;
@@ -227,7 +227,7 @@ impl<T> Receiver<T> {
return Err(RecvTimeoutError::Disconnected);
}
debug_assert!(
g.parked_receiver.is_none(),
g.parked_receiver.is_none_or(|(p, _)| p == me),
"channel has more than one receiver"
);
epoch = crate::scheduler::begin_wait();
@@ -283,7 +283,7 @@ impl<T> Receiver<T> {
let me = crate::actor::current_pid()
.expect("recv_match() called outside an actor");
debug_assert!(
g.parked_receiver.is_none(),
g.parked_receiver.is_none_or(|(p, _)| p == me),
"channel has more than one receiver"
);
g.parked_receiver = Some((me, crate::scheduler::begin_wait()));
@@ -356,3 +356,119 @@ impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
}
}
}
// ---------------------------------------------------------------------------
// select — ready-index wait over multiple receivers
// ---------------------------------------------------------------------------
mod sealed {
pub trait Sealed {}
}
impl<T> sealed::Sealed for Receiver<T> {}
/// An arm of a [`select`]. Implemented by [`Receiver`]; sealed, because the
/// registration contract below is part of the runtime's wake protocol.
///
/// Contract (all under the arm's own lock): `sel_register` checks-or-
/// registers atomically — if the arm is ready it does NOT register and
/// returns `false`; otherwise it publishes `(pid, epoch)` where its wakers
/// will find it. "Ready" means a receive would not park: a message is
/// queued, or the arm is closed.
pub trait Selectable: sealed::Sealed {
#[doc(hidden)]
fn sel_register(&self, pid: Pid, epoch: u32) -> bool;
#[doc(hidden)]
fn sel_ready(&self) -> bool;
}
impl<T> Selectable for Receiver<T> {
fn sel_register(&self, pid: Pid, epoch: u32) -> bool {
let mut g = self.inner.lock();
if !g.queue.is_empty() || g.senders == 0 {
return false;
}
debug_assert!(
g.parked_receiver.is_none_or(|(p, _)| p == pid),
"channel has more than one receiver"
);
g.parked_receiver = Some((pid, epoch));
true
}
fn sel_ready(&self) -> bool {
let g = self.inner.lock();
!g.queue.is_empty() || g.senders == 0
}
}
/// Park on every arm at once; return the index of the first ready one.
///
/// "Ready" means a receive on that arm would not park: a message is queued,
/// or the arm is **closed** (so the caller's `try_recv` observes the
/// disconnect — a dead arm is an event, not a hang). The caller consumes the
/// arm itself, typically via [`Receiver::try_recv`]; single-receiver
/// channels guarantee nothing can steal the message in between.
///
/// A closed arm stays ready *forever*: once its disconnect has been
/// observed, drop it from the arm set — under priority order it would
/// otherwise win every subsequent call and starve every higher-indexed arm.
///
/// Arms are scanned **in order**: index 0 is the highest priority, both on
/// the immediate-ready path and after a wake. This is a documented
/// guarantee (compose like BEAM receive clauses: put control channels
/// first), not an accident — and therefore there is NO fairness promise; a
/// saturated arm 0 starves arm 1 by design.
///
/// One actor may select on a channel and later `recv` on it (or select on
/// overlapping sets) freely. What stays illegal is what was always illegal:
/// two *different* actors receiving on one channel.
///
/// Built on the consuming-wake protocol (see slot_state.rs): all arms are
/// registered under one wait epoch; the winning wake consumes it, so losing
/// arms' registrations are inert and need no cancellation pass — they
/// self-clean at their wakers' failed CAS, or get overwritten by this
/// receiver's next wait on that channel.
///
/// Panics if `arms` is empty, or when called outside an actor.
pub fn select(arms: &[&dyn Selectable]) -> usize {
assert!(!arms.is_empty(), "select() on an empty arm list");
let me = crate::actor::current_pid().expect("select() called outside an actor");
loop {
let epoch = crate::scheduler::begin_wait();
// Check-or-register each arm, in priority order, each atomically
// under its own lock. Cross-arm atomicity is unnecessary: an arm
// becoming ready right after its registration wakes us through the
// protocol (the prep-to-park window is closed by RunningNotified).
let mut ready = None;
for (i, arm) in arms.iter().enumerate() {
if !arm.sel_register(me, epoch) {
ready = Some(i);
break;
}
}
if let Some(i) = ready {
// Returning WITHOUT parking while earlier arms hold live-epoch
// registrations: retire the wait (bump the epoch, eat a
// notification that already landed, re-observe a pending stop)
// so no stale arm wake can fault a later one-shot park.
crate::scheduler::retire_wait();
return i;
}
crate::scheduler::park_current();
// Woken precisely: an arm's send (message) or last-sender drop
// (closure) consumed our epoch, and both leave their arm ready —
// return the first one, in priority order (which may be a
// different, higher-priority arm than the one that woke us; its
// message stays queued and re-reports ready on the next call).
for (i, arm) in arms.iter().enumerate() {
if arm.sel_ready() {
return i;
}
}
// Unreachable by protocol (a stop wake unwinds out of
// park_current). Defensive: re-open the wait and re-register —
// stale own-registrations are overwritten.
}
}