feat(channel): recv_timeout - bounded receive on the WaitTimeout machinery

The per-recv deadline the roadmap deferred, built exactly the way
Mutex::lock_timeout already works: register the wait with a per-wait
seq, arm a timer::Reason::WaitTimeout with the channel inner (now a
TimerTarget) as the target, park. On expiry on_timeout cancels the wait
only if that same seq is still parked; satisfied or abandoned waits
leave a stale heap entry that no-ops on seq mismatch, per the
no-cancellation convention in timer.rs.

Race resolution is message-first: a send that lands by the time the
woken receiver runs is delivered even if the deadline had also passed.
Closure is reported as RecvTimeoutError::Disconnected, keeping timeout
and server/sender death distinguishable for callers (gen_server call
timeout builds on exactly this distinction next).

The timer is armed outside the channel critical section (the timers
lock must never nest under a Channel-class lock); the unpark race this
opens is absorbed by the RunningNotified protocol.

Tests: immediate delivery, actual timeout (with elapsed check), prompt
wake on send, Disconnected on close, zero-duration poll, post-timeout
seq isolation (stale entry must not cancel later waits), and a
4-scheduler mixed-outcome run (12 fed / 12 timed out).
This commit is contained in:
smarm
2026-06-09 22:56:19 +00:00
parent 2c7cf0b811
commit 134ff52c8a
3 changed files with 281 additions and 1 deletions
+142
View File
@@ -39,6 +39,9 @@ pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
parked_receiver: None,
senders: 1,
receiver_alive: true,
cur_wait: None,
next_wait_seq: 0,
timed_out: false,
}));
(Sender { inner: inner.clone() }, Receiver { inner })
}
@@ -48,6 +51,15 @@ struct Inner<T> {
parked_receiver: Option<Pid>,
senders: usize,
receiver_alive: bool,
/// The seq of the receiver's current *bounded* wait (`recv_timeout`), or
/// `None` outside one. Lets `on_timeout` tell "this wait" apart from "a
/// later wait on the same channel" — same convention as `Mutex`.
cur_wait: Option<u64>,
/// Monotonic source for `cur_wait` seqs.
next_wait_seq: u64,
/// Set by `on_timeout` when it cancels the current bounded wait; consumed
/// by the woken receiver to distinguish a timeout from a message wake.
timed_out: bool,
}
pub struct Sender<T> {
@@ -72,6 +84,27 @@ impl std::fmt::Display for RecvError {
impl std::error::Error for RecvError {}
/// Returned by [`Receiver::recv_timeout`].
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum RecvTimeoutError {
/// The deadline passed with no message available.
Timeout,
/// All senders dropped with no message available — the bounded analogue
/// of [`RecvError`].
Disconnected,
}
impl std::fmt::Display for RecvTimeoutError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RecvTimeoutError::Timeout => write!(f, "recv timed out"),
RecvTimeoutError::Disconnected => write!(f, "channel closed"),
}
}
}
impl std::error::Error for RecvTimeoutError {}
impl<T> Clone for Sender<T> {
fn clone(&self) -> Self {
self.inner.lock().senders += 1;
@@ -155,6 +188,87 @@ impl<T> Receiver<T> {
}
}
/// Bounded receive: like [`recv`](Self::recv), but gives up once
/// `timeout` has elapsed, returning [`RecvTimeoutError::Timeout`].
///
/// Built on the same timer machinery as `Mutex::lock_timeout`: the wait
/// registers a `WaitTimeout` entry tagged with a per-wait seq; on expiry
/// the channel (as the [`TimerTarget`](crate::timer::TimerTarget)) checks
/// whether *this* wait is still parked and, only then, cancels it. A wake
/// that races the deadline resolves message-first: if a message is
/// available when the receiver runs, it is delivered even if the timer
/// had already fired. A satisfied or abandoned wait leaves its timer
/// entry to expire as a no-op (seq mismatch), per the no-cancellation
/// convention in `timer.rs`.
///
/// `Duration::ZERO` is a valid timeout: it parks until the immediately-
/// due timer is drained, then reports `Timeout` unless a message was
/// already queued.
pub fn recv_timeout(&self, timeout: std::time::Duration) -> Result<T, RecvTimeoutError>
where
T: Send + 'static,
{
let me = crate::actor::current_pid()
.expect("recv_timeout() called outside an actor");
// Fast path + wait registration, one critical section.
let seq;
{
let mut g = self.inner.lock();
if let Some(v) = g.queue.pop_front() {
return Ok(v);
}
if g.senders == 0 {
return Err(RecvTimeoutError::Disconnected);
}
debug_assert!(
g.parked_receiver.is_none(),
"channel has more than one receiver"
);
seq = g.next_wait_seq;
g.next_wait_seq = g.next_wait_seq.wrapping_add(1);
g.cur_wait = Some(seq);
g.timed_out = false;
g.parked_receiver = Some(me);
crate::te!(crate::trace::Event::RecvPark(me));
}
// Arm the timer after releasing the channel lock (insert takes the
// timers lock; never nest under a Channel lock). A send or even the
// timer itself may unpark us before we park — the RunningNotified
// protocol makes the park below return immediately in that case.
let deadline = crate::timer::deadline_from_now(timeout);
let target: std::sync::Arc<dyn crate::timer::TimerTarget> = self.inner.clone();
crate::scheduler::insert_wait_timer(deadline, me, target, seq);
loop {
crate::scheduler::park_current();
crate::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
let mut g = self.inner.lock();
if let Some(v) = g.queue.pop_front() {
g.cur_wait = None;
g.timed_out = false;
return Ok(v);
}
if g.senders == 0 {
g.cur_wait = None;
g.timed_out = false;
return Err(RecvTimeoutError::Disconnected);
}
if g.timed_out {
g.cur_wait = None;
g.timed_out = false;
return Err(RecvTimeoutError::Timeout);
}
// Defensive: no current wake source reaches here (a send leaves a
// message, closure zeroes senders, the timer sets timed_out, and
// a stop unwinds out of park_current) — but if one ever does,
// re-register and keep waiting; cur_wait == Some(seq) keeps the
// already-armed timer valid for this wait.
g.parked_receiver = Some(me);
}
}
/// Selective receive: remove and return the first queued message for which
/// `pred` holds, leaving the rest in arrival order. If no queued message
/// matches, parks and re-scans on every send (a selective receiver may park
@@ -226,3 +340,31 @@ impl<T> Receiver<T> {
Ok(None)
}
}
// ---------------------------------------------------------------------------
// TimerTarget — the expiry half of recv_timeout
// ---------------------------------------------------------------------------
impl<T: Send + 'static> crate::timer::TimerTarget for RawMutex<Inner<T>> {
fn on_timeout(&self, pid: Pid, wait_seq: u64) {
// Cancel the wait only if THIS wait (seq match) is still parked. If a
// sender already took `parked_receiver`, the receiver is waking with
// a message — message wins, the timer no-ops. If `cur_wait` moved on,
// this entry is stale (the wait was satisfied or abandoned) — no-op.
let unpark = {
let mut g = self.lock();
if g.cur_wait == Some(wait_seq) && g.parked_receiver == Some(pid) {
g.parked_receiver = None;
g.timed_out = true;
true
} else {
false
}
};
// Unpark outside the channel lock — it may take the run-queue lock;
// legal under a Channel lock, but pointless to nest.
if unpark {
crate::scheduler::unpark(pid);
}
}
}