//! 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 ` 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>>); 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, } enum Cast { Note(&'static str), StopNow, } enum Call { Exits(Reply), } 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 { 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"]); }); }