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) //! ## Not here (yet)
//! //!
//! No `handle_info` and no call timeout. Both need machinery smarm currently //! No `handle_info`: it needs the cross-channel mailbox merge that selective
//! defers: a call timeout needs a per-`recv` deadline (cf. the deferred //! receive deliberately left unmade. (Call timeouts landed as
//! `Signal::Timeout`), and `handle_info` needs the cross-channel mailbox merge //! [`ServerRef::call_timeout`], on top of `Receiver::recv_timeout`.)
//! that selective receive deliberately left unmade. They are slated to land
//! together with timeouts.
use crate::channel::{channel, Receiver, Sender}; use crate::channel::{channel, Receiver, Sender};
use crate::pid::Pid; use crate::pid::Pid;
@@ -100,6 +98,19 @@ pub enum CallError {
ServerDown, 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. /// Returned by [`ServerRef::cast`] when the server is no longer reachable.
#[derive(Debug, PartialEq, Eq, Clone, Copy)] #[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CastError { pub enum CastError {
@@ -124,6 +135,33 @@ impl<G: GenServer> ServerRef<G> {
reply_rx.recv().map_err(|_| CallError::ServerDown) 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 /// Fire-and-forget request. Returns once the message is enqueued; does not
/// wait for the server to handle it. [`CastError::ServerDown`] if the inbox /// wait for the server to handle it. [`CastError::ServerDown`] if the inbox
/// is already closed. /// 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 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 link::{link, trap_exit, unlink, ExitSignal};
pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId}; pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
pub use mutex::{LockTimeout, Mutex, MutexGuard}; pub use mutex::{LockTimeout, Mutex, MutexGuard};
+94
View File
@@ -135,3 +135,97 @@ fn call_after_server_gone_is_server_down() {
}); });
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::ServerDown))); assert_eq!(*got.lock().unwrap(), Some(Err(CallError::ServerDown)));
} }
// ---------------------------------------------------------------------------
// call_timeout
// ---------------------------------------------------------------------------
use smarm::gen_server::CallTimeoutError;
use std::time::{Duration, Instant};
/// Replies after sleeping `delay_ms` (parking the server actor, not the OS
/// thread), so callers can race a deadline against the reply.
struct Slow;
impl GenServer for Slow {
type Call = u64; // delay in ms
type Reply = u64;
type Cast = ();
fn handle_call(&mut self, delay_ms: u64) -> u64 {
if delay_ms > 0 {
smarm::sleep(Duration::from_millis(delay_ms));
}
delay_ms
}
fn handle_cast(&mut self, _: ()) {}
}
#[test]
fn call_timeout_returns_reply_within_deadline() {
run(|| {
let srv = start(Slow);
assert_eq!(srv.call_timeout(0, Duration::from_secs(10)), Ok(0));
});
}
#[test]
fn call_timeout_times_out_on_slow_handler() {
run(|| {
let srv = start(Slow);
let start_t = Instant::now();
let r = srv.call_timeout(500, Duration::from_millis(50));
assert_eq!(r, Err(CallTimeoutError::Timeout));
let elapsed = start_t.elapsed();
// Gave up at the deadline, not at the reply.
assert!(elapsed >= Duration::from_millis(50));
assert!(elapsed < Duration::from_millis(500));
});
}
#[test]
fn server_survives_an_abandoned_call_and_late_reply_is_discarded() {
run(|| {
let srv = start(Slow);
assert_eq!(
srv.call_timeout(100, Duration::from_millis(20)),
Err(CallTimeoutError::Timeout)
);
// The timed-out request is still handled; its reply send fails
// harmlessly (receiver dropped). The server must keep serving, and
// the late reply must not leak into THIS call's reply channel.
assert_eq!(srv.call_timeout(0, Duration::from_secs(10)), Ok(0));
// Plain unbounded call still fine too.
assert_eq!(srv.call(0), Ok(0));
});
}
#[test]
fn call_timeout_to_dead_server_is_server_down_not_timeout() {
struct Bomb;
impl GenServer for Bomb {
type Call = ();
type Reply = ();
type Cast = ();
fn handle_call(&mut self, _: ()) {
panic!("kaboom");
}
fn handle_cast(&mut self, _: ()) {}
}
run(|| {
let srv = start(Bomb);
// Dies mid-call: reply channel closes -> ServerDown (even though the
// generous deadline never fires).
assert_eq!(
srv.call_timeout((), Duration::from_secs(10)),
Err(CallTimeoutError::ServerDown)
);
// Already gone: inbox send fails -> ServerDown.
assert_eq!(
srv.call_timeout((), Duration::from_secs(10)),
Err(CallTimeoutError::ServerDown)
);
});
}