gen_statem: state and named timeouts, info events
Add the two timeout flavours, both surfacing as ordinary events matched in on-state arms: - cx.state_timeout(d): fires a state_timeout event after d in the current state, auto-reset by the loop on every real transition. - cx.timeout(name, d): fires a timeout(name) event after d, surviving state changes, keyed by name, with cx.cancel_timeout(name). Both ride the existing timer min-heap via send_after_to onto a new per-loop system channel, selected above the inbox so a fire can't be starved by inbox traffic. A local-id stamp on each fire lets a reset/cancel that loses the race discard a stale fire. The macro grows an info: clause and folds three internal Ev variants (Info, StateTimeout, Timeout) alongside cast/call, with new row keywords info / state_timeout / timeout. Unmatched info silently drops (the gen_server default); state/named timeouts have no default, so a state that can see one must handle it or the match is non-exhaustive. Rename the hand-written expansion-target example fused -> expanded, retire the deprecated Switch demo machine (its round-trip / enter / panic-down coverage moves onto the timer machine), and refresh the macro docs to the door machine. cargo build --all-targets warning-free; cargo test green.
This commit is contained in:
+153
-45
@@ -1,76 +1,184 @@
|
||||
//! 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), and the machine-down path when a handler panics.
|
||||
//! 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;
|
||||
|
||||
// A two-state machine: Flip toggles, calls read counters, Boom panics.
|
||||
// ===========================================================================
|
||||
// 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 Switch {
|
||||
Off,
|
||||
On,
|
||||
enum T {
|
||||
Idle,
|
||||
Armed,
|
||||
}
|
||||
|
||||
struct Counts {
|
||||
flips: u32,
|
||||
enters: u32,
|
||||
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 Cast {
|
||||
Flip,
|
||||
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 Call {
|
||||
GetFlips(Reply<u32>),
|
||||
GetEnters(Reply<u32>),
|
||||
Boom(Reply<u32>),
|
||||
enum TCall {
|
||||
Enters(Reply<u32>),
|
||||
StFires(Reply<u32>),
|
||||
NamedFires(Reply<u32>),
|
||||
Boom(Reply<u32>), // panics, to exercise the call-to-dead-machine path
|
||||
}
|
||||
|
||||
gen_statem! {
|
||||
machine: Sm { state: Switch, data: Counts };
|
||||
event: Ev { cast: Cast, call: Call };
|
||||
machine: TimerSm { state: T, data: TData };
|
||||
event: Ev2 { cast: TCast, call: TCall, info: () };
|
||||
context(data, prev, cx);
|
||||
|
||||
enter {
|
||||
_ => data.enters += 1,
|
||||
// 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 Switch::Off => {
|
||||
cast Cast::Flip => { data.flips += 1; Switch::On },
|
||||
}
|
||||
on Switch::On => {
|
||||
cast Cast::Flip => Switch::Off,
|
||||
on T::Idle => {
|
||||
cast TCast::Arm => T::Armed,
|
||||
cast TCast::Disarm => unhandled,
|
||||
state_timeout => unhandled,
|
||||
}
|
||||
|
||||
// State-independent queries: reply, then stay via `prev`. Boom panics
|
||||
// (`boom()` is typed as a state tag so the arm stays well-formed).
|
||||
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 _ => {
|
||||
call Call::GetFlips(r) => { r.reply(data.flips); prev },
|
||||
call Call::GetEnters(r) => { r.reply(data.enters); prev },
|
||||
call Call::Boom(_r) => boom(),
|
||||
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() -> Switch {
|
||||
fn boom() -> T {
|
||||
panic!("boom")
|
||||
}
|
||||
// Casts are applied in order and a later call observes the accumulated data.
|
||||
|
||||
// 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 || {
|
||||
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 });
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On (flips = 1)
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // On -> Off (no flip count)
|
||||
let flips = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
|
||||
*got2.lock().unwrap() = flips;
|
||||
// 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(), 1, "turned On once across the two flips");
|
||||
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
|
||||
@@ -80,14 +188,14 @@ 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 sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 }); // enter -> 1
|
||||
let after_start = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
// Two stays (the reads) must not bump enters.
|
||||
let _ = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
|
||||
let still = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On -> enter -> 2
|
||||
let after_flip = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
*got2.lock().unwrap() = (after_start, still, after_flip);
|
||||
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));
|
||||
}
|
||||
@@ -100,8 +208,8 @@ fn call_to_panicking_handler_is_down() {
|
||||
let got = Arc::new(Mutex::new(None::<Result<u32, CallError>>));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 });
|
||||
let r = sw.call(|rep| Ev::Call(Call::Boom(rep)));
|
||||
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)));
|
||||
|
||||
Reference in New Issue
Block a user