feat(gen_statem): graceful-shutdown parity — trap_exit, shutdown/exit rows, stop, terminate
Mirrors the gen_server surface in gen_statem's event model:
- Cx::trap_exit() (in the initial enter): a shutdown request then arrives
as the Shutdown event, routed by state through `shutdown` rows (default
for a state with no row: stop); linked-peer deaths as `exit <pat>` rows
(default: drop). Non-trapping machines are stopped outright, as before.
- Cx::stop(): normal self-exit after the current event; `stop` tail keyword
is sugar for { cx.stop(); prev }.
- Machine::terminate (optional `terminate { … }` macro block), run from a
Drop guard on every exit path; the guard also drains armed timers.
- Machine::shutdown_ev / exit_ev (defaults None) so hand-written machines
keep compiling; GenStatemRef::shutdown() is graceful and waits.
- Loop selects exits > timers > inbox.
Tests: tests/gen_statem_shutdown.rs.
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
//! gen_statem graceful shutdown — the gen_server surface, in state-machine
|
||||
//! clothes. Where gen_server routes a shutdown request to a `handle_shutdown`
|
||||
//! method, a gen_statem gets it as an **event** so it can be routed by state:
|
||||
//!
|
||||
//! - A machine that does not opt in (`cx.trap_exit()` in the initial `enter`)
|
||||
//! is stopped outright by `request_shutdown`, exactly as by `request_stop`.
|
||||
//! - A trapping machine sees the request as a `shutdown` row (a unit event
|
||||
//! like `state_timeout`). The macro's default, when a state writes no
|
||||
//! `shutdown` row, is `stop` — the loop breaks and `terminate` runs on the
|
||||
//! normal path. A row may instead transition (e.g. into a Draining state)
|
||||
//! and `stop` later from any row via the `stop` tail keyword.
|
||||
//! - Linked-peer deaths reach a trapping machine as `exit <pat>` rows; an
|
||||
//! unmatched exit is silently dropped, like an unmatched info.
|
||||
//! - `terminate { … }` is an optional macro block, run on every exit path.
|
||||
|
||||
use smarm::gen_statem;
|
||||
use smarm::gen_statem::{GenStatemRef, Reply};
|
||||
use smarm::{link, monitor, request_shutdown, run, sleep, spawn, DownReason, ExitSignal};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct Log(Arc<Mutex<Vec<&'static str>>>);
|
||||
impl Log {
|
||||
fn push(&self, e: &'static str) {
|
||||
self.0.lock().unwrap().push(e);
|
||||
}
|
||||
fn get(&self) -> Vec<&'static str> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum S {
|
||||
Idle,
|
||||
Draining,
|
||||
}
|
||||
|
||||
struct D {
|
||||
log: Log,
|
||||
trap: bool,
|
||||
exits: Vec<ExitSignal>,
|
||||
}
|
||||
|
||||
enum Cast {
|
||||
Note(&'static str),
|
||||
StopNow,
|
||||
}
|
||||
enum Call {
|
||||
Exits(Reply<usize>),
|
||||
}
|
||||
|
||||
gen_statem! {
|
||||
machine: Sm { state: S, data: D };
|
||||
event: Ev { cast: Cast, call: Call, info: () };
|
||||
context(data, prev, cx);
|
||||
|
||||
enter {
|
||||
S::Idle => if data.trap { cx.trap_exit() },
|
||||
S::Draining => { data.log.push("draining"); cx.state_timeout(Duration::from_millis(30)); },
|
||||
}
|
||||
|
||||
on S::Idle => {
|
||||
// Shutdown in Idle: go drain first, stop later.
|
||||
shutdown => S::Draining,
|
||||
cast Cast::StopNow => stop,
|
||||
state_timeout => unhandled,
|
||||
}
|
||||
|
||||
on S::Draining => {
|
||||
// Drained: end the machine normally.
|
||||
state_timeout => { data.log.push("drained"); cx.stop(); prev },
|
||||
// A second request while draining is ignored.
|
||||
shutdown => unhandled,
|
||||
cast Cast::StopNow => stop,
|
||||
}
|
||||
|
||||
on _ => {
|
||||
cast Cast::Note(s) => { data.log.push(s); prev },
|
||||
call Call::Exits(r) => { r.reply(data.exits.len()); prev },
|
||||
exit sig => { data.log.push("exit"); data.exits.push(sig); prev },
|
||||
timeout _ => unhandled,
|
||||
}
|
||||
|
||||
terminate {
|
||||
data.log.push("terminate");
|
||||
}
|
||||
}
|
||||
|
||||
/// A machine with no `shutdown` rows at all: the macro default applies.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum P {
|
||||
On,
|
||||
}
|
||||
struct PD {
|
||||
log: Log,
|
||||
}
|
||||
enum PCast {}
|
||||
enum PCall {}
|
||||
|
||||
gen_statem! {
|
||||
machine: Plain { state: P, data: PD };
|
||||
event: PEv { cast: PCast, call: PCall, info: () };
|
||||
context(data, prev, cx);
|
||||
enter { P::On => cx.trap_exit(), }
|
||||
on P::On => {
|
||||
cast _ => unhandled,
|
||||
call _ => unhandled,
|
||||
state_timeout => unhandled,
|
||||
timeout _ => unhandled,
|
||||
}
|
||||
terminate { data.log.push("terminate"); }
|
||||
}
|
||||
|
||||
fn settled(log: &Log, trap: bool) -> GenStatemRef<Sm> {
|
||||
let r = Sm::start(
|
||||
S::Idle,
|
||||
D {
|
||||
log: log.clone(),
|
||||
trap,
|
||||
exits: Vec::new(),
|
||||
},
|
||||
);
|
||||
sleep(Duration::from_millis(20)); // let on_start (trap_exit) run
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_trapping_machine_is_stopped_outright() {
|
||||
let log = Log::default();
|
||||
let l = log.clone();
|
||||
run(move || {
|
||||
let r = settled(&l, false);
|
||||
let mon = monitor(r.pid());
|
||||
request_shutdown(r.pid());
|
||||
assert_eq!(mon.rx.recv().unwrap().reason, DownReason::Stopped);
|
||||
});
|
||||
assert_eq!(log.get(), vec!["terminate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_row_routes_by_state_and_stop_tail_exits_normally() {
|
||||
let log = Log::default();
|
||||
let l = log.clone();
|
||||
run(move || {
|
||||
let r = settled(&l, true);
|
||||
let mon = monitor(r.pid());
|
||||
request_shutdown(r.pid());
|
||||
// The second request lands in Draining and is `unhandled` (ignored).
|
||||
sleep(Duration::from_millis(5));
|
||||
request_shutdown(r.pid());
|
||||
assert_eq!(mon.rx.recv().unwrap().reason, DownReason::Exit);
|
||||
});
|
||||
assert_eq!(log.get(), vec!["draining", "drained", "terminate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_shutdown_is_stop() {
|
||||
let log = Log::default();
|
||||
let l = log.clone();
|
||||
run(move || {
|
||||
let r = Plain::start(P::On, PD { log: l });
|
||||
sleep(Duration::from_millis(20));
|
||||
let mon = monitor(r.pid());
|
||||
request_shutdown(r.pid());
|
||||
assert_eq!(mon.rx.recv().unwrap().reason, DownReason::Exit);
|
||||
});
|
||||
assert_eq!(log.get(), vec!["terminate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_tail_from_a_cast_is_a_normal_exit() {
|
||||
let log = Log::default();
|
||||
let l = log.clone();
|
||||
run(move || {
|
||||
let r = settled(&l, false);
|
||||
let mon = monitor(r.pid());
|
||||
r.send(Ev::Cast(Cast::Note("a"))).unwrap();
|
||||
r.send(Ev::Cast(Cast::StopNow)).unwrap();
|
||||
r.send(Ev::Cast(Cast::Note("after-stop"))).unwrap(); // never dispatched
|
||||
assert_eq!(mon.rx.recv().unwrap().reason, DownReason::Exit);
|
||||
});
|
||||
assert_eq!(log.get(), vec!["a", "terminate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_peer_death_reaches_exit_row() {
|
||||
let log = Log::default();
|
||||
let l = log.clone();
|
||||
run(move || {
|
||||
let r = settled(&l, true);
|
||||
let pid = r.pid();
|
||||
let peer = spawn(move || {
|
||||
link(pid);
|
||||
panic!("peer dies");
|
||||
});
|
||||
let _ = peer.join();
|
||||
sleep(Duration::from_millis(20));
|
||||
r.send(Ev::Cast(Cast::Note("still-running"))).unwrap();
|
||||
assert_eq!(r.call(|r| Ev::Call(Call::Exits(r))).unwrap(), 1);
|
||||
r.shutdown();
|
||||
});
|
||||
assert_eq!(
|
||||
log.get(),
|
||||
vec!["exit", "still-running", "draining", "drained", "terminate"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_shutdown_is_graceful_and_waits() {
|
||||
let log = Log::default();
|
||||
let l = log.clone();
|
||||
run(move || {
|
||||
let r = settled(&l, true);
|
||||
r.shutdown();
|
||||
// terminate has run by the time shutdown() returns.
|
||||
assert_eq!(l.get(), vec!["draining", "drained", "terminate"]);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user