feat(gen_server): handle_down — runtime monitor forwarding via ServerCtx/Watcher

Monitors are created at runtime (you watch a worker you just spawned in a
handler), so down arms can't ride the static info list. init grows a
&ServerCtx parameter (breaking; no-op default) whose clonable Watcher hands
Monitors to the loop over a control arm; the loop selects the live down
arms ahead of everything else. Arm priority: downs → control → infos →
inbox. A delivered Down retires its arm (monitors are one-shot); a state
that never clones the Watcher closes the control arm after init and the
loop falls back to the plain-inbox park.
This commit is contained in:
Claude
2026-06-10 14:59:44 +00:00
parent e5d1b3b54b
commit 24b95c99ae
3 changed files with 190 additions and 14 deletions
+99 -1
View File
@@ -71,7 +71,7 @@ impl GenServer for Lifecycle {
type Cast = ();
type Info = ();
fn init(&mut self) {
fn init(&mut self, _ctx: &smarm::gen_server::ServerCtx) {
self.log.lock().unwrap().push("init");
}
@@ -339,3 +339,101 @@ fn closed_info_arm_is_dropped_silently() {
});
assert_eq!(*got.lock().unwrap(), vec!["cast"]);
}
// ---------------------------------------------------------------------------
// handle_down: monitors handed to the loop via Watcher (v0.8)
// ---------------------------------------------------------------------------
use smarm::gen_server::Watcher;
use smarm::{monitor, spawn, DownReason, Pid};
/// The motivating pattern: a server that spawns workers from a handler,
/// watches them, and logs their deaths.
struct Pool {
watcher: Option<Watcher>,
log: Vec<DownReason>,
}
enum PoolCast {
SpawnDoomedWorker,
Watch(Pid),
}
impl GenServer for Pool {
type Call = ();
type Reply = Vec<DownReason>;
type Cast = PoolCast;
type Info = ();
fn init(&mut self, ctx: &smarm::gen_server::ServerCtx) {
self.watcher = Some(ctx.watcher());
}
fn handle_call(&mut self, _: ()) -> Vec<DownReason> {
self.log.clone()
}
fn handle_cast(&mut self, cast: PoolCast) {
let watcher = self.watcher.as_ref().expect("init ran first");
match cast {
PoolCast::SpawnDoomedWorker => {
let h = spawn(|| panic!("worker died"));
watcher.watch(monitor(h.pid()));
}
PoolCast::Watch(pid) => watcher.watch(monitor(pid)),
}
}
fn handle_down(&mut self, down: smarm::Down) {
self.log.push(down.reason);
}
}
// A worker spawned and watched from inside a handler delivers its Down to
// handle_down. Down arms outrank the inbox, so the death is in the log by
// the time the follow-up call is answered.
#[test]
fn worker_pool_down_reaches_handle_down() {
let got = Arc::new(Mutex::new(Vec::new()));
let got2 = got.clone();
run(move || {
let server = start(Pool { watcher: None, log: Vec::new() });
server.cast(PoolCast::SpawnDoomedWorker).unwrap();
let _ = server.call(()).unwrap(); // sync point: cast handled, worker live
*got2.lock().unwrap() = server.call(()).unwrap();
});
assert_eq!(*got.lock().unwrap(), vec![DownReason::Panic]);
}
// Watching an already-dead pid yields an immediate NoProc Down, and the down
// arm outranks the inbox: the call cast *after* the watch still observes it.
#[test]
fn watch_dead_pid_is_noproc_down() {
let got = Arc::new(Mutex::new(Vec::new()));
let got2 = got.clone();
run(move || {
let h = spawn(|| {});
let dead = h.pid();
h.join().unwrap();
let server = start(Pool { watcher: None, log: Vec::new() });
server.cast(PoolCast::Watch(dead)).unwrap();
*got2.lock().unwrap() = server.call(()).unwrap();
});
assert_eq!(*got.lock().unwrap(), vec![DownReason::NoProc]);
}
// A state that never clones the Watcher closes the control arm; the loop
// falls back to the plain-inbox park and keeps serving. (Every pre-v0.8 test
// in this file also exercises this path.)
#[test]
fn unused_ctx_closes_control_arm_silently() {
let got = Arc::new(Mutex::new(0i64));
let got2 = got.clone();
run(move || {
let server = start(Counter { n: 0 });
server.cast(Op::Add(2)).unwrap();
server.cast(Op::Add(40)).unwrap();
*got2.lock().unwrap() = server.call(Req::Get).unwrap();
});
assert_eq!(*got.lock().unwrap(), 42);
}