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:
+43
-5
@@ -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
@@ -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};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user