From 90b7040504a6712e34d4f6be2475c5806a194f8a Mon Sep 17 00:00:00 2001 From: smarm Date: Tue, 9 Jun 2026 22:57:31 +0000 Subject: [PATCH] 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). --- src/gen_server.rs | 48 ++++++++++++++++++++--- src/lib.rs | 2 +- tests/gen_server.rs | 94 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/gen_server.rs b/src/gen_server.rs index 42ccf4f..75446f1 100644 --- a/src/gen_server.rs +++ b/src/gen_server.rs @@ -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 ServerRef { 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 { + let (reply_tx, reply_rx) = channel::(); + 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. diff --git a/src/lib.rs b/src/lib.rs index 806986b..8ea33b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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}; diff --git a/tests/gen_server.rs b/tests/gen_server.rs index 92de18d..6479ab9 100644 --- a/tests/gen_server.rs +++ b/tests/gen_server.rs @@ -135,3 +135,97 @@ fn call_after_server_gone_is_server_down() { }); 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) + ); + }); +}