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
+245
View File
@@ -0,0 +1,245 @@
//! select tests. Beyond the functional cases, the *_stays_precise tests are
//! soundness probes for the consuming-wake protocol: they fire stale loser-
//! arm wakes at an actor and then run a one-shot park (`sleep`, whose early
//! return would be observable as a short elapsed time) to prove the stale
//! wake died at its epoch CAS instead of corrupting the next wait.
use smarm::{channel, run, select, spawn};
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::{Duration, Instant};
static OUT: AtomicI64 = AtomicI64::new(0);
#[test]
fn ready_arm_returns_immediately_without_parking() {
OUT.store(0, Ordering::SeqCst);
run(|| {
let (txa, rxa) = channel::<i64>();
let (_txb, rxb) = channel::<i64>();
txa.send(42).unwrap();
let i = select(&[&rxb, &rxa]);
assert_eq!(i, 1);
OUT.store(rxa.try_recv().unwrap().expect("ready arm must hold a message"), Ordering::SeqCst);
});
assert_eq!(OUT.load(Ordering::SeqCst), 42);
}
#[test]
fn lower_index_wins_when_several_arms_are_ready() {
run(|| {
let (txa, rxa) = channel::<i64>();
let (txb, rxb) = channel::<i64>();
txa.send(1).unwrap();
txb.send(2).unwrap();
// Documented priority order: index 0 first, regardless of send order.
assert_eq!(select(&[&rxa, &rxb]), 0);
assert_eq!(select(&[&rxb, &rxa]), 0);
});
}
#[test]
fn parks_until_any_arm_fires() {
OUT.store(0, Ordering::SeqCst);
run(|| {
let (txa, _rxa_keepalive) = (channel::<i64>().0, ());
let _hold = txa; // arm a: sender alive, never sends
let (txa, rxa) = channel::<i64>();
let (txb, rxb) = channel::<i64>();
let _keep_a_open = txa;
let h = spawn(move || {
smarm::sleep(Duration::from_millis(10));
txb.send(7).unwrap();
});
let i = select(&[&rxa, &rxb]);
assert_eq!(i, 1);
OUT.store(rxb.try_recv().unwrap().unwrap(), Ordering::SeqCst);
h.join().unwrap();
});
assert_eq!(OUT.load(Ordering::SeqCst), 7);
}
#[test]
fn closed_arm_counts_as_ready() {
run(|| {
let (_keep, rxa) = channel::<i64>();
let (txb, rxb) = channel::<i64>();
drop(txb);
let i = select(&[&rxa, &rxb]);
assert_eq!(i, 1);
// The caller observes the closure on the arm itself.
assert!(rxb.try_recv().is_err());
});
}
#[test]
fn closure_while_parked_wakes_the_select() {
run(|| {
let (_keep, rxa) = channel::<i64>();
let (txb, rxb) = channel::<i64>();
let h = spawn(move || {
smarm::sleep(Duration::from_millis(10));
drop(txb);
});
assert_eq!(select(&[&rxa, &rxb]), 1);
assert!(rxb.try_recv().is_err());
h.join().unwrap();
});
}
#[test]
fn loser_arm_wake_after_parked_select_stays_precise() {
// Both arms registered; arm 0 wins the wake; arm 1's sender then fires
// a wake stamped with the consumed epoch. The subsequent sleep is a
// one-shot park: an early return would mean the stale wake landed.
run(|| {
let (txa, rxa) = channel::<i64>();
let (txb, rxb) = channel::<i64>();
let h = spawn(move || {
smarm::sleep(Duration::from_millis(10));
txa.send(1).unwrap(); // wins
txb.send(2).unwrap(); // loser arm: stale-epoch wake
});
assert_eq!(select(&[&rxa, &rxb]), 0);
assert_eq!(rxa.try_recv().unwrap(), Some(1));
let t0 = Instant::now();
smarm::sleep(Duration::from_millis(40));
assert!(
t0.elapsed() >= Duration::from_millis(40),
"one-shot park returned early: a stale loser-arm wake landed"
);
// The loser's message was never lost.
assert_eq!(select(&[&rxa, &rxb]), 1);
assert_eq!(rxb.try_recv().unwrap(), Some(2));
h.join().unwrap();
});
}
#[test]
fn no_park_exit_retires_the_wait() {
// Arm 1 is ready at registration time, so select returns WITHOUT
// parking while arm 0 holds a live-epoch registration. Arm 0's sender
// then fires. retire_wait must have invalidated/eaten that wake;
// the sleep proves it.
run(|| {
let (txa, rxa) = channel::<i64>();
let (txb, rxb) = channel::<i64>();
txb.send(2).unwrap();
assert_eq!(select(&[&rxa, &rxb]), 1);
assert_eq!(rxb.try_recv().unwrap(), Some(2));
let h = spawn(move || {
txa.send(1).unwrap(); // fires at the (retired) select epoch
});
h.join().unwrap();
let t0 = Instant::now();
smarm::sleep(Duration::from_millis(40));
assert!(
t0.elapsed() >= Duration::from_millis(40),
"one-shot park returned early: the no-park exit leaked a wake"
);
assert_eq!(rxa.try_recv().unwrap(), Some(1));
});
}
#[test]
fn select_then_plain_recv_on_a_loser_arm() {
// A leftover own-registration from a select must not trip the next
// direct wait on that channel (it is overwritten, not asserted away).
run(|| {
let (txa, rxa) = channel::<i64>();
let (txb, rxb) = channel::<i64>();
txa.send(1).unwrap();
assert_eq!(select(&[&rxa, &rxb]), 0); // rxb may keep a registration
assert_eq!(rxa.try_recv().unwrap(), Some(1));
let h = spawn(move || {
smarm::sleep(Duration::from_millis(5));
txb.send(9).unwrap();
});
assert_eq!(rxb.recv().unwrap(), 9);
h.join().unwrap();
});
}
#[test]
fn select_loop_drains_two_producers_completely() {
const N: i64 = 200;
OUT.store(0, Ordering::SeqCst);
run(|| {
let (txa, rxa) = channel::<i64>();
let (txb, rxb) = channel::<i64>();
let ha = spawn(move || {
for v in 0..N {
txa.send(v).unwrap();
if v % 7 == 0 {
smarm::yield_now();
}
}
});
let hb = spawn(move || {
for v in 0..N {
txb.send(v).unwrap();
if v % 5 == 0 {
smarm::yield_now();
}
}
});
// A closed arm stays permanently ready, so it must leave the arm
// set once observed (keeping it would starve the other arm at
// priority order) — select until the FIRST closure, then drain the
// survivor with plain recv.
let mut sum = 0i64;
let survivor: &smarm::Receiver<i64> = loop {
let i = select(&[&rxa, &rxb]);
let arm: &smarm::Receiver<i64> = if i == 0 { &rxa } else { &rxb };
match arm.try_recv() {
Ok(Some(v)) => sum += v,
Ok(None) => {} // defensive: ready arm already drained
Err(_) => break if i == 0 { &rxb } else { &rxa },
}
};
loop {
match survivor.recv() {
Ok(v) => sum += v,
Err(_) => break,
}
}
OUT.store(sum, Ordering::SeqCst);
ha.join().unwrap();
hb.join().unwrap();
});
assert_eq!(OUT.load(Ordering::SeqCst), 2 * (0..200i64).sum::<i64>());
}
#[test]
fn motivating_pattern_inbox_plus_monitor_down() {
// The gen_server/handle_info shape select unlocks: a server waiting on
// its request inbox AND a monitor Down channel in one park.
use smarm::monitor;
run(|| {
let (req_tx, req_rx) = channel::<i64>();
let worker = spawn(|| {
smarm::sleep(Duration::from_millis(10));
// worker exits -> Down fires
});
let m = monitor(worker.pid());
let _keep_inbox_open = req_tx;
let mut served = 0;
loop {
match select(&[&m.rx, &req_rx]) {
0 => {
let _down = m.rx.try_recv().unwrap().expect("Down message");
break;
}
_ => {
if let Ok(Some(_)) = req_rx.try_recv() {
served += 1;
}
}
}
}
assert_eq!(served, 0);
worker.join().unwrap();
});
}