A `=> postpone` row (cast/call/info) defers the current event untouched, to be replayed after the next real transition. `handle` is now two-phase: a borrow-only postpone pre-pass that hands the event back as `Step::Postponed(ev)`, then the existing consuming `match (state, event)`. The loop owns a FIFO queue, drained in the new state ahead of further intake; a replayed event may postpone again. A postponed `call` keeps its Reply, so a later state answers it. `handle` returns `Step` (Postponed / Transitioned / Stayed) so the loop can see both deferral and transition without reading the state cell. `Resolution::Postpone` is removed: postpone is a pre-dispatch routing decision, not a consuming-dispatch outcome.
381 lines
15 KiB
Rust
381 lines
15 KiB
Rust
//! 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<u32>),
|
|
StFires(Reply<u32>),
|
|
NamedFires(Reply<u32>),
|
|
Boom(Reply<u32>), // 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::<Result<u32, CallError>>));
|
|
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<u32>), // Filled: reply value & empty; Empty: postpone until filled
|
|
Takes(Reply<u32>), // completed-take count (stay)
|
|
Status(Reply<L>), // 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::<u32>));
|
|
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::<u32>));
|
|
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<u32>),
|
|
State(Reply<R>),
|
|
}
|
|
|
|
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");
|
|
}
|