feat(gen_server): call_timeout via recv_timeout on the reply channel

Deviates deliberately from the roadmap's monitor-based sketch
(monitor + wait reply-or-Down-or-deadline + demonitor): server death is
already observable on the reply channel itself - the reply sender drops
as the server unwinds, closing the channel and waking the parked caller.
So a bounded call is exactly recv_timeout on the reply channel, mapping
Disconnected -> ServerDown and Timeout -> Timeout. No registration
exists, so a timed-out call leaks nothing by construction - the
guarantee the monitor design had to engineer.

Semantics on timeout match Erlang: the request stays in the inbox and
is still handled; the late reply's send fails harmlessly against the
dropped reply receiver. CallTimeoutError keeps ServerDown and Timeout
distinguishable; the infallible call() is unchanged.

Tests: reply within deadline, timeout against a slow (parking) handler
with elapsed bounds, server survival across an abandoned call (late
reply discarded, bounded and unbounded calls keep working), and
ServerDown-not-Timeout for both death paths (mid-call panic and
already-gone inbox).
This commit is contained in:
smarm
2026-06-09 22:57:31 +00:00
parent 134ff52c8a
commit 90b7040504
3 changed files with 138 additions and 6 deletions
+43 -5
View File
@@ -36,11 +36,9 @@
//!
//! ## Not here (yet)
//!
//! No `handle_info` and no call timeout. Both need machinery smarm currently
//! defers: a call timeout needs a per-`recv` deadline (cf. the deferred
//! `Signal::Timeout`), and `handle_info` needs the cross-channel mailbox merge
//! that selective receive deliberately left unmade. They are slated to land
//! together with timeouts.
//! No `handle_info`: it needs the cross-channel mailbox merge that selective
//! receive deliberately left unmade. (Call timeouts landed as
//! [`ServerRef::call_timeout`], on top of `Receiver::recv_timeout`.)
use crate::channel::{channel, Receiver, Sender};
use crate::pid::Pid;
@@ -100,6 +98,19 @@ pub enum CallError {
ServerDown,
}
/// Returned by [`ServerRef::call_timeout`].
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CallTimeoutError {
/// The server was already gone, or died before replying.
ServerDown,
/// The deadline passed before a reply arrived. The request stays in the
/// server's inbox: it will still be *handled*, but the reply is discarded
/// (the abandoned reply channel's receiver is dropped, so the server's
/// reply send fails harmlessly). Erlang behaves the same way; design
/// idempotent calls accordingly.
Timeout,
}
/// Returned by [`ServerRef::cast`] when the server is no longer reachable.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CastError {
@@ -124,6 +135,33 @@ impl<G: GenServer> ServerRef<G> {
reply_rx.recv().map_err(|_| CallError::ServerDown)
}
/// Bounded synchronous request-reply: like [`call`](Self::call), but
/// gives up after `timeout`, returning [`CallTimeoutError::Timeout`].
///
/// The roadmap sketched this as monitor + wait-reply-or-Down + demonitor;
/// that machinery is unnecessary here because server death is already
/// observable on the reply channel itself — the reply sender is dropped
/// as the server's stack unwinds, closing the channel and waking the
/// parked caller (see the module docs). So a bounded call is exactly
/// [`Receiver::recv_timeout`] on the reply channel: `Ok` on a reply,
/// `Disconnected` → [`CallTimeoutError::ServerDown`], `Timeout` →
/// [`CallTimeoutError::Timeout`]. Nothing is registered, so nothing can
/// leak on the timeout path by construction.
pub fn call_timeout(
&self,
request: G::Call,
timeout: std::time::Duration,
) -> Result<G::Reply, CallTimeoutError> {
let (reply_tx, reply_rx) = channel::<G::Reply>();
self.tx
.send(Envelope::Call(request, reply_tx))
.map_err(|_| CallTimeoutError::ServerDown)?;
reply_rx.recv_timeout(timeout).map_err(|e| match e {
crate::channel::RecvTimeoutError::Disconnected => CallTimeoutError::ServerDown,
crate::channel::RecvTimeoutError::Timeout => CallTimeoutError::Timeout,
})
}
/// Fire-and-forget request. Returns once the message is enqueued; does not
/// wait for the server to handle it. [`CastError::ServerDown`] if the inbox
/// is already closed.
+1 -1
View File
@@ -46,7 +46,7 @@ static ALLOCATOR: preempt::PreemptingAllocator = preempt::PreemptingAllocator;
// ---------------------------------------------------------------------------
pub use channel::{channel, Receiver, RecvError, RecvTimeoutError, Sender};
pub use gen_server::{CallError, CastError, GenServer, ServerRef};
pub use gen_server::{CallError, CallTimeoutError, CastError, GenServer, ServerRef};
pub use link::{link, trap_exit, unlink, ExitSignal};
pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
pub use mutex::{LockTimeout, Mutex, MutexGuard};