//! gen_statem behaviour tests, driven through the `gen_statem!` macro: cast/call //! round-trip, `enter` firing on start and on every real transition (but not on //! a stay), the machine-down path when a handler panics, and the timeout //! surface (state-timeout firing + auto-reset across transitions; named //! timeouts surviving transitions; cancellation). use smarm::gen_statem; use smarm::gen_statem::{CallError, Reply}; use smarm::run; use std::sync::{Arc, Mutex}; use std::time::Duration; // =========================================================================== // Timeouts // =========================================================================== // // A small timer machine. `Idle` is quiet; `Armed` arms a state-timeout on entry // that — when it fires — bumps `st_fires` and returns to `Idle`. A named // timeout ("ping") is armed independently, survives the Idle/Armed transition, // and bumps `named_fires` when it fires. Counters are read back over calls. // // Durations are tiny and waits use `smarm::sleep` (parks the actor, leaves the // timer wheel turning). These tests run against real time, so they are a touch // slow; a controllable clock would tighten them. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum T { Idle, Armed, } struct TData { enters: u32, // total state entries (incl. initial) st_fires: u32, // state-timeout fires named_fires: u32, // named-timeout fires st_window: u64, // ms for the Armed state-timeout (set per test intent) } enum TCast { Arm, // Idle -> Armed Disarm, // Armed -> Idle (a transition that should auto-reset the state-timeout) Ping(u64), // arm a named "ping" timeout after the given ms CancelPing, // cancel the named "ping" } enum TCall { Enters(Reply), StFires(Reply), NamedFires(Reply), Boom(Reply), // panics, to exercise the call-to-dead-machine path } gen_statem! { machine: TimerSm { state: T, data: TData }; event: Ev2 { cast: TCast, call: TCall, info: () }; context(data, prev, cx); enter { // On entering Armed, arm the state-timeout. On Idle, nothing — and the // loop has already auto-reset any pending state-timeout on the way in. T::Armed => { data.enters += 1; cx.state_timeout(Duration::from_millis(data.st_window)); }, T::Idle => data.enters += 1, } on T::Idle => { cast TCast::Arm => T::Armed, cast TCast::Disarm => unhandled, state_timeout => unhandled, } on T::Armed => { // The state-timeout elapsed while still Armed: count it and go Idle. state_timeout => { data.st_fires += 1; T::Idle }, cast TCast::Disarm => T::Idle, cast TCast::Arm => unhandled, } // State-independent rows: the named-timeout (survives transitions), the // arm/cancel casts, the counter reads, and the panicking call. on _ => { cast TCast::Ping(ms) => { cx.timeout("ping", Duration::from_millis(ms)); prev }, cast TCast::CancelPing => { cx.cancel_timeout("ping"); prev }, timeout "ping" => { data.named_fires += 1; prev }, timeout _ => unhandled, call TCall::Enters(r) => { r.reply(data.enters); prev }, call TCall::StFires(r) => { r.reply(data.st_fires); prev }, call TCall::NamedFires(r) => { r.reply(data.named_fires); prev }, call TCall::Boom(_r) => boom(), } } fn boom() -> T { panic!("boom") } // A state-timeout armed on entry to Armed fires after its window, bumping the // counter and returning the machine to Idle on its own. #[test] fn state_timeout_fires() { let got = Arc::new(Mutex::new(0u32)); let got2 = got.clone(); run(move || { let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 5 }); m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed, arms 5ms state-timeout smarm::sleep(Duration::from_millis(40)); // let it fire *got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap(); }); assert_eq!(*got.lock().unwrap(), 1, "state-timeout fired exactly once"); } // Leaving Armed before the window elapses auto-resets the state-timeout: it // must not fire afterward, even though we wait well past its original window. #[test] fn state_timeout_auto_resets_on_transition() { let got = Arc::new(Mutex::new(99u32)); let got2 = got.clone(); run(move || { // Long window so the explicit Disarm beats it comfortably. let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 50 }); m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed, arms 50ms state-timeout m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle, auto-resets it smarm::sleep(Duration::from_millis(80)); // past the original window *got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap(); }); assert_eq!(*got.lock().unwrap(), 0, "auto-reset cancelled the pending state-timeout"); } // A named timeout survives a state change: armed in Idle, it still fires after // the machine has moved to Armed and back. #[test] fn named_timeout_survives_transition() { let got = Arc::new(Mutex::new(0u32)); let got2 = got.clone(); run(move || { // Armed's own state-timeout is long so it doesn't interfere. let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 200 }); m.send(Ev2::Cast(TCast::Ping(20))).unwrap(); // arm "ping" for 20ms (in Idle) m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed (ping must survive this) m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle (and this) smarm::sleep(Duration::from_millis(60)); // let "ping" fire *got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::NamedFires(r))).unwrap(); }); assert_eq!(*got.lock().unwrap(), 1, "named timeout fired across the transitions"); } // Cancelling a named timeout before its window prevents the fire. #[test] fn named_timeout_cancel() { let got = Arc::new(Mutex::new(99u32)); let got2 = got.clone(); run(move || { let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 200 }); m.send(Ev2::Cast(TCast::Ping(30))).unwrap(); // arm "ping" for 30ms m.send(Ev2::Cast(TCast::CancelPing)).unwrap(); // cancel before it fires smarm::sleep(Duration::from_millis(60)); // past the original window *got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::NamedFires(r))).unwrap(); }); assert_eq!(*got.lock().unwrap(), 0, "cancel prevented the named-timeout fire"); } // =========================================================================== // Core behaviour (cast/call round-trip, enter semantics, panic -> Down) // =========================================================================== // Casts are applied in order and a later call observes the resulting state via // its counters: two Arm/Disarm round-trips leave the machine back in Idle. #[test] fn cast_then_call_roundtrip() { let got = Arc::new(Mutex::new(0u32)); let got2 = got.clone(); run(move || { // Long state-timeout window so it never fires during the test. let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 }); m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed (enter) m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (enter) m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed (enter) m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (enter) // enters = 1 (start) + 4 transitions = 5. *got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap(); }); assert_eq!(*got.lock().unwrap(), 5, "one enter on start, one per real transition"); } // `enter` fires once on start and once per *real* transition; a stay (a call // that returns the current tag) does not re-enter. #[test] fn enter_on_start_and_each_transition_but_not_stay() { let got = Arc::new(Mutex::new((0u32, 0u32, 0u32))); let got2 = got.clone(); run(move || { let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 }); // enter -> 1 let after_start = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap(); // A stay (a counter read returns `prev`) must not bump enters. let _ = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap(); let still = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap(); m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed -> enter -> 2 let after_arm = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap(); *got2.lock().unwrap() = (after_start, still, after_arm); }); assert_eq!(*got.lock().unwrap(), (1, 1, 2)); } // A handler that panics tears the loop down; the in-flight call's reply channel // closes as the stack unwinds, so the parked caller wakes with Down (mirrors // gen_server's panicking-handler path). #[test] fn call_to_panicking_handler_is_down() { let got = Arc::new(Mutex::new(None::>)); let got2 = got.clone(); run(move || { let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 }); let r = m.call(|rep| Ev2::Call(TCall::Boom(rep))); *got2.lock().unwrap() = Some(r); }); assert_eq!(*got.lock().unwrap(), Some(Err(CallError::Down))); } // =========================================================================== // Postpone // =========================================================================== // // Two machines. `LatchSm` defers a `Take` *call* while `Empty` and answers it // from `Filled` — the Reply rides inside the postponed event onto the queue and // is honoured by whichever later state handles the replay. `RelaySm` defers a // `Mark` cast through two states so a replayed event can postpone *again*, // landing only once the handling state is reached. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum L { Empty, Filled, } struct LData { value: u32, // the value a Fill stored, handed back by a Take takes: u32, // completed takes } enum LCast { Fill(u32), // Empty -> Filled, storing the value } enum LCall { Take(Reply), // Filled: reply value & empty; Empty: postpone until filled Takes(Reply), // completed-take count (stay) Status(Reply), // current state tag (stay) } gen_statem! { machine: LatchSm { state: L, data: LData }; event: LEv { cast: LCast, call: LCall, info: () }; context(data, prev, cx); enter { _ => {} } on L::Empty => { cast LCast::Fill(v) => { data.value = v; L::Filled }, // No value yet: defer the take (with its Reply) until a Fill arrives. call LCall::Take(r) => postpone, } on L::Filled => { cast LCast::Fill(_) => unhandled, // already full call LCall::Take(r) => { r.reply(data.value); data.takes += 1; L::Empty }, } on _ => { call LCall::Takes(r) => { r.reply(data.takes); prev }, call LCall::Status(r) => { r.reply(prev); prev }, state_timeout => unhandled, timeout _ => unhandled, } } // A `Take` issued while the latch is Empty parks the caller and is postponed // (Reply included). A later Fill transitions Empty -> Filled, whose replay // answers the deferred call — the parked caller wakes with the filled value. #[test] fn postponed_call_answered_after_transition() { let got = Arc::new(Mutex::new(None::)); let g2 = got.clone(); run(move || { let m = LatchSm::start(L::Empty, LData { value: 0, takes: 0 }); // Child actor issues the Take while Empty; its call parks on the reply. let m2 = m.clone(); let taken = Arc::new(Mutex::new(None::)); let t2 = taken.clone(); smarm::scheduler::spawn(move || { let v = m2.call(|r| LEv::Call(LCall::Take(r))).unwrap(); *t2.lock().unwrap() = Some(v); }); smarm::sleep(Duration::from_millis(20)); // let the Take land and be deferred // While deferred, the latch is untouched: still Empty, no take completed. // (These reads are stays — they don't disturb the postponed event.) assert_eq!(m.call(|r| LEv::Call(LCall::Status(r))).unwrap(), L::Empty); assert_eq!(m.call(|r| LEv::Call(LCall::Takes(r))).unwrap(), 0); m.send(LEv::Cast(LCast::Fill(42))).unwrap(); // Empty -> Filled: replays the Take smarm::sleep(Duration::from_millis(20)); // let the child wake with its reply *g2.lock().unwrap() = *taken.lock().unwrap(); }); assert_eq!(*got.lock().unwrap(), Some(42), "postponed call answered by the Filled state"); } #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum R { S0, S1, S2, } struct RData { marks: u32, // Marks handled (only S2 handles one) } enum RCast { Go, // S0 -> S1 -> S2 -> S0 Mark, // postponed in S0/S1, handled in S2 } enum RCall { Marks(Reply), State(Reply), } gen_statem! { machine: RelaySm { state: R, data: RData }; event: REv { cast: RCast, call: RCall, info: () }; context(data, prev, cx); enter { _ => {} } on R::S0 => { cast RCast::Go => R::S1, cast RCast::Mark => postpone, } on R::S1 => { cast RCast::Go => R::S2, cast RCast::Mark => postpone, // a replay here defers again } on R::S2 => { cast RCast::Go => R::S0, cast RCast::Mark => { data.marks += 1; prev }, // finally handled } on _ => { call RCall::Marks(r) => { r.reply(data.marks); prev }, call RCall::State(r) => { r.reply(prev); prev }, state_timeout => unhandled, timeout _ => unhandled, } } // A postponed event that is replayed into a state which *also* postpones it // re-queues, and is handled only once a state that accepts it is reached. Each // `Marks` call is a stay that acts as a sync barrier after the preceding casts. #[test] fn replayed_event_can_postpone_again() { let got = Arc::new(Mutex::new((9u32, 9u32, 9u32, R::S0))); let g2 = got.clone(); run(move || { let m = RelaySm::start(R::S0, RData { marks: 0 }); m.send(REv::Cast(RCast::Mark)).unwrap(); // S0: postponed let a = m.call(|r| REv::Call(RCall::Marks(r))).unwrap(); // deferred -> 0 m.send(REv::Cast(RCast::Go)).unwrap(); // S0 -> S1: replay Mark -> postponed again let b = m.call(|r| REv::Call(RCall::Marks(r))).unwrap(); // re-deferred -> 0 m.send(REv::Cast(RCast::Go)).unwrap(); // S1 -> S2: replay Mark -> handled let c = m.call(|r| REv::Call(RCall::Marks(r))).unwrap(); // -> 1 let s = m.call(|r| REv::Call(RCall::State(r))).unwrap(); // Mark was a stay in S2 *g2.lock().unwrap() = (a, b, c, s); }); let (a, b, c, s) = *got.lock().unwrap(); assert_eq!(a, 0, "deferred while S0"); assert_eq!(b, 0, "re-deferred while S1"); assert_eq!(c, 1, "handled once S2 is reached"); assert_eq!(s, R::S2, "Mark handled as a stay in S2"); }