feat(gen_server): synchronous call / async cast over a single actor (roadmap #5)
Thin request-reply layer on channels, no runtime change. A server is an
actor owning a GenServer state; clients hold a clonable ServerRef and issue
call (sync, parks for the reply) or cast (fire-and-forget).
- Single inbox carrying an internal Envelope { Call(req, reply_tx) | Cast },
forced by the no-select / no-unified-mailbox invariant; call makes a
one-shot reply channel and parks on it.
- Server-down detection is pure channel closure (no monitor): send fails if
the inbox is gone; the reply sender drops on the server's unwind so a
parked caller wakes to Err. Both collapse to CallError/CastError::ServerDown.
- init/terminate are optional trait hooks; terminate runs via a drop guard so
it fires on every exit path (clean close, panic, request_stop). Must stay
non-blocking — may run mid-unwind.
- ServerRef carries pid() for monitor/request_stop/link; start + start_under.
Tests: cast-then-call roundtrip, init/terminate ordering, both server-down
paths (reply-channel close on handler panic; inbox-send failure when gone).
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
//! gen_server tests: call round-trip, cast, lifecycle callbacks, and the two
|
||||
//! server-down detection paths (reply-channel close vs. inbox-send failure).
|
||||
|
||||
use smarm::gen_server::{start, CallError, GenServer};
|
||||
use smarm::run;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A trivial counter server: casts mutate, calls read (or blow up).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Counter {
|
||||
n: i64,
|
||||
}
|
||||
|
||||
enum Req {
|
||||
Get,
|
||||
Boom,
|
||||
}
|
||||
|
||||
enum Op {
|
||||
Add(i64),
|
||||
}
|
||||
|
||||
impl GenServer for Counter {
|
||||
type Call = Req;
|
||||
type Reply = i64;
|
||||
type Cast = Op;
|
||||
|
||||
fn handle_call(&mut self, req: Req) -> i64 {
|
||||
match req {
|
||||
Req::Get => self.n,
|
||||
Req::Boom => panic!("boom"),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cast(&mut self, op: Op) {
|
||||
match op {
|
||||
Op::Add(x) => self.n += x,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Casts are applied in order and a later call observes the accumulated state.
|
||||
#[test]
|
||||
fn cast_then_call_roundtrip() {
|
||||
let got = Arc::new(Mutex::new(0i64));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let server = start(Counter { n: 0 });
|
||||
server.cast(Op::Add(5)).unwrap();
|
||||
server.cast(Op::Add(3)).unwrap();
|
||||
let n = server.call(Req::Get).unwrap();
|
||||
*got2.lock().unwrap() = n;
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 8);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle: init runs before the first message, terminate on graceful exit.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Lifecycle {
|
||||
log: Arc<Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
impl GenServer for Lifecycle {
|
||||
type Call = ();
|
||||
type Reply = ();
|
||||
type Cast = ();
|
||||
|
||||
fn init(&mut self) {
|
||||
self.log.lock().unwrap().push("init");
|
||||
}
|
||||
|
||||
fn handle_call(&mut self, _req: ()) {
|
||||
self.log.lock().unwrap().push("call");
|
||||
}
|
||||
|
||||
fn handle_cast(&mut self, _req: ()) {}
|
||||
|
||||
fn terminate(&mut self) {
|
||||
self.log.lock().unwrap().push("terminate");
|
||||
}
|
||||
}
|
||||
|
||||
// init -> handle_call -> (drop last ref closes inbox) -> terminate.
|
||||
#[test]
|
||||
fn init_and_terminate_run() {
|
||||
let log = Arc::new(Mutex::new(Vec::new()));
|
||||
let log2 = log.clone();
|
||||
run(move || {
|
||||
let server = start(Lifecycle { log: log2 });
|
||||
server.call(()).unwrap();
|
||||
// Dropping the only ref closes the inbox; the server breaks out of its
|
||||
// recv loop and runs terminate. run() will not return until it has.
|
||||
drop(server);
|
||||
});
|
||||
assert_eq!(*log.lock().unwrap(), vec!["init", "call", "terminate"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-down detection.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The server dies *while* a call is in flight (handler panics): the reply
|
||||
// sender drops on unwind, closing the reply channel, so the parked caller's
|
||||
// recv returns Err -> ServerDown.
|
||||
#[test]
|
||||
fn call_to_panicking_handler_is_server_down() {
|
||||
let got = Arc::new(Mutex::new(None));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let server = start(Counter { n: 0 });
|
||||
let r = server.call(Req::Boom);
|
||||
*got2.lock().unwrap() = Some(r);
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::ServerDown)));
|
||||
}
|
||||
|
||||
// A call issued *after* the server is already gone: the inbox is closed, so the
|
||||
// send itself fails -> ServerDown (the other detection path).
|
||||
#[test]
|
||||
fn call_after_server_gone_is_server_down() {
|
||||
let got = Arc::new(Mutex::new(None));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let server = start(Counter { n: 0 });
|
||||
let server2 = server.clone();
|
||||
// This kills the server (and is itself ServerDown).
|
||||
assert_eq!(server.call(Req::Boom), Err(CallError::ServerDown));
|
||||
// Inbox now closed; a fresh call can't even be enqueued.
|
||||
let r = server2.call(Req::Get);
|
||||
*got2.lock().unwrap() = Some(r);
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::ServerDown)));
|
||||
}
|
||||
Reference in New Issue
Block a user