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:
smarm-agent
2026-06-20 12:22:45 +00:00
parent bfa513cd6d
commit acf67fef06
4 changed files with 600 additions and 132 deletions
@@ -31,7 +31,7 @@
//! Default build is clean and runs. A BREAK-CASE MENU at the bottom documents //! Default build is clean and runs. A BREAK-CASE MENU at the bottom documents
//! how to make each of the four guarantees fire. //! how to make each of the four guarantees fire.
//! //!
//! Run: `cargo run --example gen_statem_fused` //! Run: `cargo run --example gen_statem_expanded`
#![deny(dead_code, unreachable_patterns)] #![deny(dead_code, unreachable_patterns)]
@@ -68,6 +68,11 @@ enum Call {
enum Ev { enum Ev {
Cast(Cast), Cast(Cast),
Call(Call), Call(Call),
// The runtime's internal events. `Info` is out-of-band (here unused, so
// `()`); `StateTimeout` / `Timeout` are timer fires the loop feeds back in.
Info(()),
StateTimeout,
Timeout(&'static str),
} }
const CODE: u32 = 1234; const CODE: u32 = 1234;
@@ -119,14 +124,28 @@ impl DoorSm {
}) })
} }
fn enter(&mut self, _cx: &mut Cx<Ev>) { fn enter(&mut self, cx: &mut Cx<Ev>) {
self.data.enters += 1; self.data.enters += 1;
// A state-timeout: an Open door auto-closes after a quiet window. The
// loop auto-resets it on any transition, so it fires only if the door is
// still Open when it elapses.
if self.state == Door::Open {
cx.state_timeout(std::time::Duration::from_millis(5));
}
} }
} }
impl Machine for DoorSm { impl Machine for DoorSm {
type Ev = Ev; type Ev = Ev;
fn state_timeout_ev() -> Ev {
Ev::StateTimeout
}
fn timeout_ev(name: &'static str) -> Ev {
Ev::Timeout(name)
}
fn on_start(&mut self, cx: &mut Cx<Ev>) { fn on_start(&mut self, cx: &mut Cx<Ev>) {
self.enter(cx); self.enter(cx);
} }
@@ -173,6 +192,19 @@ impl Machine for DoorSm {
r.reply(self.data.pushes); r.reply(self.data.pushes);
Resolution::To(prev) Resolution::To(prev)
} }
// --- timeouts: an Open door auto-closes; others have none armed --
(Door::Open, Ev::StateTimeout) => Resolution::To(Door::Closed),
(_, Ev::StateTimeout) => Resolution::Unhandled,
(_, Ev::Timeout(name)) => {
// No named timeout is armed in this run; a real handler would
// dispatch on `name`. Acknowledge it to exercise the field.
let _ = name;
Resolution::Unhandled
}
// --- out-of-band info: silent drop (the gen_server default) ------
(_, Ev::Info(_)) => Resolution::Unhandled,
}; };
// ---- apply the resolution ----------------------------------------- // ---- apply the resolution -----------------------------------------
@@ -180,6 +212,7 @@ impl Machine for DoorSm {
Resolution::To(s) if s == prev => {} // stay: no enter Resolution::To(s) if s == prev => {} // stay: no enter
Resolution::To(s) => { Resolution::To(s) => {
self.state = s; // sole writer of the state cell self.state = s; // sole writer of the state cell
cx.__reset_state_timeout(); // auto-reset across transitions
self.enter(cx); self.enter(cx);
} }
Resolution::Postpone => unreachable!("postpone is not generated yet"), Resolution::Postpone => unreachable!("postpone is not generated yet"),
@@ -196,17 +229,23 @@ fn main() {
door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open door.send(Ev::Cast(Cast::Push)).unwrap(); // Closed: Push invalid -> Unhandled
door.send(Ev::Cast(Cast::Push)).unwrap(); // Open -> Closed (pushes=1) door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open (arms 5ms auto-close)
door.send(Ev::Info(())).unwrap(); // out-of-band: silently dropped
// Wait past the auto-close window: the state-timeout fires and the door
// closes itself, with no further input. (`smarm::sleep` parks the actor
// without blocking a worker thread, so the timer wheel keeps turning.)
smarm::sleep(std::time::Duration::from_millis(40));
let st = door.call(|r| Ev::Call(Call::GetState(r))).unwrap(); let st = door.call(|r| Ev::Call(Call::GetState(r))).unwrap();
let enters = door.call(|r| Ev::Call(Call::GetEnters(r))).unwrap(); let enters = door.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
let pushes = door.call(|r| Ev::Call(Call::GetPushes(r))).unwrap(); let pushes = door.call(|r| Ev::Call(Call::GetPushes(r))).unwrap();
println!("state={st:?} enters={enters} pushes={pushes}"); println!("state={st:?} enters={enters} pushes={pushes}");
assert_eq!(st, Door::Closed); assert_eq!(st, Door::Closed); // auto-closed by the state-timeout
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
assert_eq!(pushes, 1); assert_eq!(pushes, 0); // no push ever closed it this run
println!("ok"); println!("ok");
}); });
} }
+8 -4
View File
@@ -1,4 +1,4 @@
//! The **same** machine as `examples/gen_statem_fused.rs`, written through the //! The **same** machine as `examples/gen_statem_expanded.rs`, written through the
//! `gen_statem!` macro. Diff this file against that one to see exactly what the //! `gen_statem!` macro. Diff this file against that one to see exactly what the
//! macro buys: every `// ===` section there that was boilerplate (the `Ev` //! macro buys: every `// ===` section there that was boilerplate (the `Ev`
//! enum, the `DoorSm` struct, `start`, the whole `Machine` impl, the `enter` //! enum, the `DoorSm` struct, `start`, the whole `Machine` impl, the `enter`
@@ -21,7 +21,7 @@ use smarm::gen_statem;
use smarm::run; use smarm::run;
use smarm::gen_statem::Reply; use smarm::gen_statem::Reply;
// === user types (identical to gen_statem_fused.rs) ========================= // === user types (identical to gen_statem_expanded.rs) =========================
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Door { enum Door {
@@ -87,7 +87,7 @@ fn on_unlock(key: u32) -> UnlockOutcome {
gen_statem! { gen_statem! {
machine: DoorSm { state: Door, data: Data }; machine: DoorSm { state: Door, data: Data };
event: Ev { cast: Cast, call: Call }; event: Ev { cast: Cast, call: Call, info: () };
// You name the bindings the bodies use; the macro can't lend you its own // You name the bindings the bodies use; the macro can't lend you its own
// `self`/`cx` across macro hygiene. `data` = &mut Data, `prev` = current // `self`/`cx` across macro hygiene. `data` = &mut Data, `prev` = current
@@ -117,6 +117,10 @@ gen_statem! {
call Call::GetState(r) => { r.reply(prev); prev }, call Call::GetState(r) => { r.reply(prev); prev },
call Call::GetEnters(r) => { r.reply(data.enters); prev }, call Call::GetEnters(r) => { r.reply(data.enters); prev },
call Call::GetPushes(r) => { r.reply(data.pushes); prev }, call Call::GetPushes(r) => { r.reply(data.pushes); prev },
// This machine arms no timeouts, so refuse them everywhere. (Info has a
// built-in silent-drop default, so it needs no row.)
state_timeout => unhandled,
timeout _ => unhandled,
} }
} }
@@ -145,7 +149,7 @@ fn main() {
// =========================================================================== // ===========================================================================
// BREAK-CASE MENU — the four guarantees, through the macro. Each fires exactly // BREAK-CASE MENU — the four guarantees, through the macro. Each fires exactly
// as it does in the hand-written gen_statem_fused.rs. // as it does in the hand-written gen_statem_expanded.rs.
// //
// 1. ORPHAN HANDLER (dead_code -> error): // 1. ORPHAN HANDLER (dead_code -> error):
// add `fn on_slam(_d: &mut Data) {}` and don't reference it. // add `fn on_slam(_d: &mut Data) {}` and don't reference it.
+394 -77
View File
@@ -19,8 +19,8 @@
//! not a separate check — it falls out of the generated total `match (state, //! not a separate check — it falls out of the generated total `match (state,
//! event)` under denied lints (a forgotten or duplicated pair is a compile //! event)` under denied lints (a forgotten or duplicated pair is a compile
//! error), so no proc-macro is needed. See `examples/gen_statem_macro.rs` for //! error), so no proc-macro is needed. See `examples/gen_statem_macro.rs` for
//! the macro form and `examples/gen_statem_fused.rs` for the hand-written shape //! the macro form and `examples/gen_statem_expanded.rs` for the hand-written
//! it expands to. //! shape it expands to.
//! //!
//! ## The unified event //! ## The unified event
//! //!
@@ -38,13 +38,35 @@
//! `gen_server` call round-trip, but with the reply handle riding *inside* the //! `gen_server` call round-trip, but with the reply handle riding *inside* the
//! user's own event so a handler can answer it. //! user's own event so a handler can answer it.
//! //!
//! [`Resolution::Postpone`] and the timeout arming on [`Cx`] are part of the //! ## Timeouts
//! type surface but are not yet wired up. //!
//! Two timeout flavours, both armed from a handler through [`Cx`] and both
//! surfacing back as ordinary **events** the machine matches in its `on State`
//! arms — unlike `gen_server`, which routes fires to a separate handler:
//!
//! - [`cx.state_timeout(d)`](Cx::state_timeout) fires a `state_timeout` event
//! after `d` *in the current state*, and is auto-reset on any state change.
//! Only one is ever pending; arming again replaces it.
//! - [`cx.timeout(name, d)`](Cx::timeout) fires a `timeout(name)` event after
//! `d`, **survives** state changes, and is keyed by `name` so several can be
//! in flight and each is independently [`cancel_timeout`](Cx::cancel_timeout)led.
//!
//! Both ride the same timer min-heap as `gen_server` (no separate timer
//! mechanism): a fire lands on the loop's own system channel — above the inbox,
//! so a timeout can't be starved by inbox traffic — and the loop turns it into
//! the corresponding internal event and runs it through the normal `handle`
//! dispatch.
//!
//! [`Resolution::Postpone`] is part of the type surface but is not yet wired up.
use crate::channel::{channel, Receiver, Sender}; use crate::channel::{channel, select, Receiver, Sender};
use crate::pid::Pid; use crate::pid::Pid;
use crate::scheduler::spawn as spawn_actor; use crate::scheduler::{cancel_timer, send_after_to, spawn as spawn_actor};
use crate::timer::TimerId;
use std::collections::HashMap;
use std::marker::PhantomData; use std::marker::PhantomData;
use std::sync::{Arc, Mutex};
use std::time::Duration;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Machine // Machine
@@ -59,10 +81,21 @@ use std::marker::PhantomData;
/// [`handle`](Self::handle) per event. /// [`handle`](Self::handle) per event.
pub trait Machine: Send + 'static { pub trait Machine: Send + 'static {
/// The single payload this machine's inbox carries — the user's `cast` and /// The single payload this machine's inbox carries — the user's `cast` and
/// `call` enums folded together with the runtime's internal events. See the /// `call` enums folded together with the runtime's internal events
/// module docs. /// (`state_timeout`, `timeout(name)`, and an `Info` wrapper). See the module
/// docs.
type Ev: Send + 'static; type Ev: Send + 'static;
/// Wrap a fired state-timeout into this machine's event. The loop calls this
/// when the pending state-timeout fires, then runs the result through
/// [`handle`](Self::handle) like any other event. The macro generates it as
/// `Ev::StateTimeout`.
fn state_timeout_ev() -> Self::Ev;
/// Wrap a fired named timeout into this machine's event, carrying the name
/// it was armed under. The macro generates it as `Ev::Timeout(name)`.
fn timeout_ev(name: &'static str) -> Self::Ev;
/// Runs once inside the actor before the first event. The canonical body /// Runs once inside the actor before the first event. The canonical body
/// runs the `enter` arm for the initial state. /// runs the `enter` arm for the initial state.
fn on_start(&mut self, cx: &mut Cx<Self::Ev>); fn on_start(&mut self, cx: &mut Cx<Self::Ev>);
@@ -109,27 +142,150 @@ impl<S> From<S> for Resolution<S> {
// Cx — the per-handler context // Cx — the per-handler context
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// The context handle injected into [`Machine::on_start`] and // ---------------------------------------------------------------------------
/// [`Machine::handle`]. Non-state outcomes (postpone, timeout arming) live here // Sys — the loop's internal timer-fire channel
/// as method calls rather than keywords. // ---------------------------------------------------------------------------
/// A timer fire landing on the loop's own system channel, selected above the
/// inbox so a timeout cannot be starved by inbox traffic. Each fire carries the
/// local id it was armed under so the loop can confirm it is still the live one
/// (a fire that a reset/cancel beat onto the channel is discarded).
enum Sys {
/// The state-timeout fired, carrying the local id it was armed under so the
/// loop can drop a stale fire (one a reset/cancel beat onto the channel).
StateTimeout(u64),
/// A named timeout fired, carrying its name and the local id it was armed
/// under.
Timeout(&'static str, u64),
}
// ---------------------------------------------------------------------------
// Timers — shared timer bookkeeping
// ---------------------------------------------------------------------------
/// Per-machine timer bookkeeping, shared between the loop and every [`Cx`]
/// borrow (a machine is single-threaded — handler calls and the loop never run
/// concurrently — so this `Mutex` is always uncontended; it is `Arc<Mutex>`
/// rather than `Rc<RefCell>` only because `Machine: Send` forces it).
/// ///
/// For now it carries only the [`on_unhandled`](Self::on_unhandled) hook; /// Each arming mints a fresh **local id** carried in the fire payload and
/// `cx.state_timeout(d)` / `cx.timeout(name, d)` and `cx.postpone()` will attach /// recorded here alongside the substrate id used to cancel. On fire the loop
/// here when implemented. It lives only on the actor's own /// compares the payload's local id against the recorded one: a fire whose id no
/// stack and is never sent. /// longer matches — a reset/cancel armed a newer one or removed the entry after
/// this fire already escaped onto the channel — is stale and dropped. The local
/// id is minted up front (unlike the substrate id, which only exists once armed),
/// so it can ride the payload with no chicken-and-egg.
struct Timers {
/// Monotonic minter for local ids, kept distinct from substrate ids (the
/// latter are what we hand to [`cancel_timer`]).
next_local: u64,
/// The currently-armed state-timeout's `(local id, substrate id)`. Cancelled
/// and cleared on every real state change (auto-reset), replaced on re-arm.
state: Option<(u64, TimerId)>,
/// Live named timeouts: name → `(local id, substrate id)`. Survives state
/// changes; an entry lives until it fires or is cancelled.
named: HashMap<&'static str, (u64, TimerId)>,
}
impl Timers {
fn new() -> Self {
Timers { next_local: 0, state: None, named: HashMap::new() }
}
fn mint(&mut self) -> u64 {
let id = self.next_local;
self.next_local = self.next_local.wrapping_add(1);
id
}
}
// ---------------------------------------------------------------------------
// Cx — the per-handler context
// ---------------------------------------------------------------------------
/// The context handle injected into [`Machine::on_start`] and
/// [`Machine::handle`]. Non-state outcomes — timeout arming, and later
/// postpone — live here as method calls rather than keywords. It lives only on
/// the actor's own stack and is never sent.
///
/// Timeouts armed here land on the loop's system channel through `sys_tx` and
/// are tracked in the shared `reg` so reset/cancel can find the pending one.
pub struct Cx<Ev> { pub struct Cx<Ev> {
sys_tx: Sender<Sys>,
reg: Arc<Mutex<Timers>>,
_ev: PhantomData<fn() -> Ev>, _ev: PhantomData<fn() -> Ev>,
} }
impl<Ev> Cx<Ev> { impl<Ev> Cx<Ev> {
fn new() -> Self { fn new(sys_tx: Sender<Sys>, reg: Arc<Mutex<Timers>>) -> Self {
Cx { _ev: PhantomData } Cx { sys_tx, reg, _ev: PhantomData }
} }
/// The default for an event no arm matched: **log-and-drop**. Overridable /// Arm the **state timeout**: fire a `state_timeout` event after `after` in
/// hook wiring is a follow-on; for now an unmatched event is /// the current state. Auto-reset on any state change (the loop cancels and
/// silently dropped, as `gen_server` does with unexpected messages. /// clears it on every real transition), so it measures quiet time *within* a
/// state. Only one is ever pending — arming again cancels and replaces the
/// previous one.
pub fn state_timeout(&mut self, after: Duration) {
let mut reg = self.reg.lock().unwrap();
if let Some((_, old_sub)) = reg.state.take() {
cancel_timer(old_sub);
}
let local = reg.mint();
let sub = send_after_to(after, self.sys_tx.clone(), Sys::StateTimeout(local));
reg.state = Some((local, sub));
}
/// Arm a **named generic timeout**: fire a `timeout(name)` event after
/// `after`. Survives state changes; several may be in flight keyed by
/// `name`. Arming the same `name` again cancels and replaces the pending one
/// for that name.
pub fn timeout(&mut self, name: &'static str, after: Duration) {
let mut reg = self.reg.lock().unwrap();
if let Some((_, old_sub)) = reg.named.remove(name) {
cancel_timer(old_sub);
}
let local = reg.mint();
let sub = send_after_to(after, self.sys_tx.clone(), Sys::Timeout(name, local));
reg.named.insert(name, (local, sub));
}
/// Cancel the named timeout `name` if pending. Returns `true` if the cancel
/// beat the fire, `false` if it had already fired / was never armed.
pub fn cancel_timeout(&mut self, name: &'static str) -> bool {
let mut reg = self.reg.lock().unwrap();
match reg.named.remove(name) {
Some((_, sub)) => cancel_timer(sub),
None => false,
}
}
/// Cancel the pending state-timeout if any. Returns `true` if the cancel
/// beat the fire. Rarely needed by hand (the loop auto-resets on
/// transition); exposed for a handler that wants to disarm within a state.
pub fn cancel_state_timeout(&mut self) -> bool {
let mut reg = self.reg.lock().unwrap();
match reg.state.take() {
Some((_, sub)) => cancel_timer(sub),
None => false,
}
}
/// The default for an event no arm matched: **log-and-drop**, as
/// `gen_server` does with unexpected messages.
pub fn on_unhandled(&mut self) {} pub fn on_unhandled(&mut self) {}
/// Cancel and clear the pending state-timeout. Called by the macro on every
/// real transition (the state-timeout is auto-reset across state changes);
/// not part of the authoring surface. A handler re-arms in the new state's
/// `enter` if it wants one.
#[doc(hidden)]
pub fn __reset_state_timeout(&mut self) {
let mut reg = self.reg.lock().unwrap();
if let Some((_, sub)) = reg.state.take() {
cancel_timer(sub);
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -143,9 +299,8 @@ impl<Ev> Cx<Ev> {
/// machine dies, closing the channel). /// machine dies, closing the channel).
/// ///
/// Carrying the handle in the event — rather than the loop owning a reply slot /// Carrying the handle in the event — rather than the loop owning a reply slot
/// — is what lets a later chunk **postpone a call**: the whole event, reply /// — is what will let a **postponed call** work: the whole event, reply handle
/// handle included, moves onto the postpone queue and is answered by a later /// included, moves onto the postpone queue and is answered by a later state.
/// state.
pub struct Reply<T> { pub struct Reply<T> {
tx: Sender<T>, tx: Sender<T>,
} }
@@ -211,9 +366,7 @@ impl<M: Machine> GenStatemRef<M> {
/// stack unwinds, closing the channel and waking the caller). /// stack unwinds, closing the channel and waking the caller).
/// ///
/// `make` wraps the [`Reply`] all the way to `M::Ev` — typically /// `make` wraps the [`Reply`] all the way to `M::Ev` — typically
/// `|r| Ev::Call(MyCall::GetCount(r))`. (The deferred macro would generate a /// `|r| Ev::Call(MyCall::GetCount(r))`.
/// thinner `call` that hides the `Ev::Call` wrap and takes the bare variant
/// constructor.)
pub fn call<T, F>(&self, make: F) -> Result<T, CallError> pub fn call<T, F>(&self, make: F) -> Result<T, CallError>
where where
T: Send + 'static, T: Send + 'static,
@@ -241,18 +394,71 @@ pub fn spawn<M: Machine>(machine: M) -> GenStatemRef<M> {
GenStatemRef { tx, pid: handle.pid() } GenStatemRef { tx, pid: handle.pid() }
} }
/// The machine actor body: `on_start`, then one `handle` per inbox event until /// The machine actor body: `on_start`, then one `handle` per event until the
/// the inbox closes (all refs dropped → graceful shutdown). Chunk 1 parks on /// inbox closes (all refs dropped → graceful shutdown).
/// the inbox alone — no system arm, no timers — the analogue of `gen_server`'s ///
/// plain-inbox park. /// Two intake sources are selected each iteration with the **timer arm above
/// the inbox**, so a timeout fire is never starved by inbox traffic: `sys_rx`
/// carries timer fires armed through `cx`, `rx` is the user inbox. A fire is
/// turned into the matching internal event (`state_timeout` / `timeout(name)`)
/// and run through the same `handle` dispatch as an inbox event — the
/// gen_statem model, where timeouts surface as ordinary events.
fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) { fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
let mut cx = Cx::new(); let (sys_tx, sys_rx) = channel::<Sys>();
let reg = Arc::new(Mutex::new(Timers::new()));
// The loop owns `cx` (and through it a `sys_tx` clone) for its whole life,
// so the sys arm never closes from under us — no auto-close dance needed.
let mut cx = Cx::new(sys_tx, reg.clone());
machine.on_start(&mut cx); machine.on_start(&mut cx);
loop { loop {
match rx.recv() { // Timer arm first: a ready fire is taken in preference to the inbox.
Ok(ev) => machine.handle(ev, &mut cx), let i = select(&[&sys_rx, &rx]);
// All StatemRefs dropped → inbox closed → shutdown. if i == 0 {
Err(_) => break, match sys_rx.try_recv() {
Ok(Some(fire)) => {
// Confirm the fire is still the live one before dispatching:
// a reset/cancel may have replaced/removed it after it
// escaped onto the channel. A stale fire is dropped.
let ev = match fire {
Sys::StateTimeout(local) => {
let mut t = reg.lock().unwrap();
match t.state {
Some((live, _)) if live == local => {
t.state = None; // retire: it has now fired
Some(M::state_timeout_ev())
}
_ => None,
}
}
Sys::Timeout(name, local) => {
let mut t = reg.lock().unwrap();
match t.named.get(name) {
Some(&(live, _)) if live == local => {
t.named.remove(name); // retire on fire
Some(M::timeout_ev(name))
}
_ => None,
}
}
};
if let Some(ev) = ev {
machine.handle(ev, &mut cx);
}
}
// Single-receiver: nothing can drain the arm between select's
// ready and our try_recv.
Ok(None) => debug_assert!(false, "ready system arm was empty"),
// The loop holds a sys_tx for its whole life, so this is
// unreachable; fall through defensively.
Err(_) => {}
}
} else {
match rx.try_recv() {
Ok(Some(ev)) => machine.handle(ev, &mut cx),
Ok(None) => debug_assert!(false, "ready inbox was empty"),
// All GenStatemRefs dropped → inbox closed → shutdown.
Err(_) => break,
}
} }
// Observation point so a machine fed a hot inbox stays preemptible and // Observation point so a machine fed a hot inbox stays preemptible and
// cancellable. // cancellable.
@@ -261,14 +467,14 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// gen_statem! — the authoring macro (fused total-match variant) // gen_statem! — the authoring macro (total-match dispatch)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Assemble a complete [`Machine`] from hand-written types, a glanceable /// Assemble a complete [`Machine`] from hand-written types, a glanceable
/// transition table, and free handler functions. /// transition table, and free handler functions.
/// ///
/// This is the **fused** authoring surface (see `examples/statem_fused.rs` for /// This is the authoring surface (see `examples/gen_statem_expanded.rs` for the
/// the same machine written out by hand). You keep ownership of every type that /// same machine written out by hand). You keep ownership of every type that
/// carries meaning — the state enum, the data struct, the `cast`/`call` enums, /// carries meaning — the state enum, the data struct, the `cast`/`call` enums,
/// and any per-row successor enums — and the macro emits only the mechanical /// and any per-row successor enums — and the macro emits only the mechanical
/// scaffolding: the unified event enum, the machine struct and its private /// scaffolding: the unified event enum, the machine struct and its private
@@ -308,12 +514,12 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
/// machine lives in the same crate as this macro, and is **silently dropped /// machine lives in the same crate as this macro, and is **silently dropped
/// when you call `gen_statem!` from a downstream crate** — neither a crate /// when you call `gen_statem!` from a downstream crate** — neither a crate
/// `#![deny]` nor `#![forbid]` overrides the suppression. This is the one /// `#![deny]` nor `#![forbid]` overrides the suppression. This is the one
/// guarantee a `macro_rules!` cannot carry across the crate boundary; the /// guarantee a `macro_rules!` cannot carry across the crate boundary; a
/// RFC's proc-macro could (it would stamp the arms with call-site spans). /// proc-macro could (it would stamp the arms with call-site spans).
/// Guard against it in review, or with an in-crate test of the machine. /// Guard against it in review, or with an in-crate test of the machine.
/// ///
/// There is deliberately **no separate `transitions { … }` adjacency block**: /// There is deliberately **no separate `transitions { … }` adjacency block**:
/// in this variant the `match` *is* the table and the successor enums *are* the /// the `match` *is* the table and the successor enums *are* the
/// per-state target sets, so a second listing would be redundant and /// per-state target sets, so a second listing would be redundant and
/// un-cross-checkable without a proc-macro. The graph is read off the `on` /// un-cross-checkable without a proc-macro. The graph is read off the `on`
/// blocks and the successor enums at the top of the file. /// blocks and the successor enums at the top of the file.
@@ -323,14 +529,16 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
/// ```ignore /// ```ignore
/// // ── your types (the macro never generates or inspects these) ─────────── /// // ── your types (the macro never generates or inspects these) ───────────
/// #[derive(Clone, Copy, PartialEq, Eq, Debug)] /// #[derive(Clone, Copy, PartialEq, Eq, Debug)]
/// enum Switch { Off, On } /// enum Door { Open, Closed, Locked }
/// struct Counts { flips: u32, enters: u32 } /// struct Data { enters: u32 }
/// enum SwitchCast { Flip } /// enum DoorCast { Push, Pull, Lock, Unlock(u32) }
/// enum SwitchCall { GetCount(Reply<u32>) } /// enum DoorCall { GetState(Reply<Door>) }
/// ///
/// gen_statem! { /// gen_statem! {
/// machine: SwitchSm { state: Switch, data: Counts }; /// machine: DoorSm { state: Door, data: Data };
/// event: Ev { cast: SwitchCast, call: SwitchCall }; /// // The event clause folds three user enums together. `info` is for
/// // out-of-band messages; use `()` if you have none.
/// event: Ev { cast: DoorCast, call: DoorCall, info: () };
/// ///
/// // Name the bindings your handler bodies use. A declarative macro can't /// // Name the bindings your handler bodies use. A declarative macro can't
/// // hand you its own `self`/`cx` (hygiene), so you choose the identifiers /// // hand you its own `self`/`cx` (hygiene), so you choose the identifiers
@@ -339,26 +547,48 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
/// context(data, prev, cx); /// context(data, prev, cx);
/// ///
/// // `enter` runs on entry to a state; side effects only, returns (). /// // `enter` runs on entry to a state; side effects only, returns ().
/// // Match the state tag (or `_`); the arm body is statements. /// // Match the state tag (or `_`); the arm body is statements. Arm any
/// // state-timeout here (it is auto-reset on the way into a new state).
/// enter { /// enter {
/// Door::Open => {
/// data.enters += 1;
/// cx.state_timeout(std::time::Duration::from_secs(30)); // auto-close
/// }
/// _ => data.enters += 1, /// _ => data.enters += 1,
/// } /// }
/// ///
/// // The transition table. Group rows by current state with `on <pat>`. /// // The transition table. Group rows by current state with `on <pat>`.
/// // A row is: cast|call <event-pattern> [if <guard>] => <tail> , /// // A row is: <kind> <event-pattern> [if <guard>] => <tail> ,
/// // where <kind> is one of `cast`, `call`, `info`, `state_timeout`
/// // (no pattern — it is a unit event), or `timeout <name-pattern>`,
/// // and the tail is one of: /// // and the tail is one of:
/// // * a state tag `Switch::On` (transition, or "stay" /// // * a state tag `Door::Closed` (transition, or "stay"
/// // if it equals current) /// // if it equals current)
/// // * a block ending in one `{ data.flips += 1; Switch::On }` /// // * a block ending in one `{ data.enters += 1; Door::Closed }`
/// // * a successor-enum value `unlock(key)` (branching row) /// // * a successor-enum value `on_unlock(key)` (branching row)
/// // * the keyword `unhandled` (explicit refusal) /// // * the keyword `unhandled` (explicit refusal)
/// on Switch::Off => { /// on Door::Open => {
/// cast SwitchCast::Flip => { data.flips += 1; Switch::On }, /// cast DoorCast::Push => Door::Closed,
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev }, /// // An armed state-timeout surfaces as an ordinary event:
/// state_timeout => Door::Closed,
/// cast DoorCast::Pull | DoorCast::Lock | DoorCast::Unlock(_) => unhandled,
/// } /// }
/// on Switch::On => { /// on Door::Closed => {
/// cast SwitchCast::Flip => Switch::Off, /// cast DoorCast::Pull => Door::Open,
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev }, /// cast DoorCast::Lock => Door::Locked,
/// cast DoorCast::Push | DoorCast::Unlock(_) => unhandled,
/// // No state-timeout armed here, so refuse it.
/// state_timeout => unhandled,
/// }
/// on Door::Locked => {
/// cast DoorCast::Unlock(key) => on_unlock(key), // -> successor enum
/// cast DoorCast::Push | DoorCast::Pull | DoorCast::Lock => unhandled,
/// state_timeout => unhandled,
/// }
///
/// // state-independent queries (reply, then stay via `prev`)
/// on _ => {
/// call DoorCall::GetState(r) => { r.reply(prev); prev },
/// } /// }
/// } /// }
/// ``` /// ```
@@ -368,17 +598,28 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
/// * **Every row ends in a comma** (block-bodied ones too) and every `on` block /// * **Every row ends in a comma** (block-bodied ones too) and every `on` block
/// is `on <state-pat> => { … }`. These two are macro-grammar requirements, not /// is `on <state-pat> => { … }`. These two are macro-grammar requirements, not
/// style: a declarative matcher can't otherwise tell where a row's tail ends. /// style: a declarative matcher can't otherwise tell where a row's tail ends.
/// * **Qualify event patterns** (`SwitchCast::Flip`) and **state tags** /// * **Qualify event patterns** (`DoorCast::Push`) and **state tags**
/// (`Switch::On`). A declarative macro can't prepend the enum name inside an /// (`Door::Closed`). A declarative macro can't prepend the enum name inside an
/// opaque pattern fragment, so the `cast`/`call` keyword only selects the /// opaque pattern fragment, so the row keyword only selects the event wrapper
/// event wrapper — it does not qualify for you. The keyword also reads as a /// — it does not qualify for you. `cast`/`call` also read as a sync/async
/// sync/async marker at a glance. /// marker at a glance.
/// * **Timeouts surface as events.** `state_timeout` (a unit event) and
/// `timeout <name>` (matching the `&'static str` a generic timeout was armed
/// under) are matched in `on` rows just like casts and calls — arm them via
/// `cx`, handle the fire here. Because a `timeout` name is an `&str`, a row
/// that matches specific names needs a `timeout _ => …` fallback to stay
/// exhaustive.
/// * **`info` defaults to a silent drop.** An out-of-band `info` you do not
/// match anywhere is dropped (the `gen_server` default), so a machine that
/// ignores info writes no `info` rows at all. State-timeouts and named
/// timeouts have **no** such default: a state that can see one must handle it
/// (or `unhandled` it) or the match is non-exhaustive.
/// * **Stay** = return the current tag. The `prev` you named in `context` is /// * **Stay** = return the current tag. The `prev` you named in `context` is
/// bound to the pre-handler state for exactly this — handy in any-state /// bound to the pre-handler state for exactly this — handy in any-state
/// (`on _`) rows where there is no single literal tag to write. /// (`on _`) rows where there is no single literal tag to write.
/// * **`data` and `cx`** (the names you chose) are in scope in every body: /// * **`data` and `cx`** (the names you chose) are in scope in every body:
/// mutate `data`, reply through a `Reply` bound in the pattern, and (chunks /// mutate `data`, reply through a `Reply` bound in the pattern, and arm
/// 23) arm timeouts or postpone via `cx`. You never write `self`. /// timeouts via `cx`. You never write `self`.
/// * **Guards** are plain match guards. A guarded arm does not count toward /// * **Guards** are plain match guards. A guarded arm does not count toward
/// exhaustiveness, so pair it with an unguarded fallback or you'll (correctly) /// exhaustiveness, so pair it with an unguarded fallback or you'll (correctly)
/// trip `E0004`. /// trip `E0004`.
@@ -387,11 +628,13 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
/// ///
/// # What it emits /// # What it emits
/// ///
/// `enum $Ev { Cast($Cast), Call($Call) }`, `struct $Sm { state, data }`, /// The unified `enum $Ev` (the `Cast`/`Call`/`Info` wrappers plus the internal
/// `$Sm::start(init, data) -> GenStatemRef<$Sm>`, the `Machine` impl (`on_start` /// `StateTimeout` / `Timeout` events), `struct $Sm { state, data }`,
/// running the initial `enter`; `handle` = the dispatch match + the /// `$Sm::start(init, data) -> GenStatemRef<$Sm>`, and the `Machine` impl:
/// stay/transition/unhandled apply-tail, the cell's sole writer), and the /// `on_start` runs the initial `enter`; `handle` is the dispatch match plus the
/// `enter` dispatch. Chunk 1: real time, no timers, no postpone. /// stay/transition/unhandled apply-tail (the cell's sole writer, which also
/// auto-resets the state-timeout on every real transition); and the `enter`
/// dispatch.
/// ///
/// # Limitation /// # Limitation
/// ///
@@ -403,16 +646,23 @@ macro_rules! gen_statem {
// ===== public entry ===================================================== // ===== public entry =====================================================
( (
machine: $sm:ident { state: $State:ty, data: $Data:ty } ; machine: $sm:ident { state: $State:ty, data: $Data:ty } ;
event: $Ev:ident { cast: $Cast:ty, call: $Call:ty } ; event: $Ev:ident { cast: $Cast:ty, call: $Call:ty, info: $Info:ty } ;
context ( $data:ident , $cur:ident , $cx:ident ) ; context ( $data:ident , $cur:ident , $cx:ident ) ;
enter { $( $est:pat => $ebody:expr ),+ $(,)? } enter { $( $est:pat => $ebody:expr ),+ $(,)? }
$( on $st:pat => { $($rows:tt)* } )+ $( on $st:pat => { $($rows:tt)* } )+
) => { ) => {
/// Unified inbox payload: the user's `cast`/`call` enums folded together /// Unified inbox payload: the user's `cast`/`call`/`info` enums folded
/// (chunks 23 add the runtime's internal timeout variants here). /// together with the runtime's internal timeout events.
enum $Ev { enum $Ev {
Cast($Cast), Cast($Cast),
Call($Call), Call($Call),
/// Out-of-band message, matched in `info` rows. An unmatched info is
/// silently dropped (the `gen_server` default).
Info($Info),
/// The state-timeout fired (matched in `state_timeout` rows).
StateTimeout,
/// A named timeout fired (matched in `timeout <pat>` rows).
Timeout(&'static str),
} }
struct $sm { struct $sm {
@@ -438,6 +688,14 @@ macro_rules! gen_statem {
impl $crate::gen_statem::Machine for $sm { impl $crate::gen_statem::Machine for $sm {
type Ev = $Ev; type Ev = $Ev;
fn state_timeout_ev() -> $Ev {
$Ev::StateTimeout
}
fn timeout_ev(name: &'static str) -> $Ev {
$Ev::Timeout(name)
}
fn on_start(&mut self, $cx: &mut $crate::gen_statem::Cx<$Ev>) { fn on_start(&mut self, $cx: &mut $crate::gen_statem::Cx<$Ev>) {
let s = self.state; let s = self.state;
self.enter(s, $cx); self.enter(s, $cx);
@@ -458,6 +716,9 @@ macro_rules! gen_statem {
$crate::gen_statem::Resolution::To(s) if s == $cur => {} $crate::gen_statem::Resolution::To(s) if s == $cur => {}
$crate::gen_statem::Resolution::To(s) => { $crate::gen_statem::Resolution::To(s) => {
self.state = s; // <- sole writer of the state cell self.state = s; // <- sole writer of the state cell
// A real transition auto-resets the state-timeout: the
// new state re-arms in its `enter` if it wants one.
$cx.__reset_state_timeout();
self.enter(s, $cx); self.enter(s, $cx);
} }
$crate::gen_statem::Resolution::Postpone => { $crate::gen_statem::Resolution::Postpone => {
@@ -470,9 +731,17 @@ macro_rules! gen_statem {
}; };
// ===== @arms: build the dispatch match ================================== // ===== @arms: build the dispatch match ==================================
// No more on-blocks: emit the (total, catch-all-free) match. // No more on-blocks: emit the (catch-all-free for cast/call/timeouts) match.
// The one macro-injected arm is the `Info` silent-drop fallback (3c): info
// is out-of-band and defaults to a drop, matching `gen_server`. It is last
// and broadest, so per-state `info` rows above it stay reachable. Cast,
// call, and both timeout events get NO fallback — a forgotten pair is still
// a non-exhaustive-match error.
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]) => { (@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]) => {
match ($ss, $se) { $($arms)* } match ($ss, $se) {
$($arms)*
(_, $Ev::Info(_)) => $crate::gen_statem::Resolution::Unhandled,
}
}; };
// Open an on-block: remember its state pat, drain its rows, then continue. // Open an on-block: remember its state pat, drain its rows, then continue.
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] (@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]
@@ -515,6 +784,54 @@ macro_rules! gen_statem {
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ] [ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
($st) { $($rows)* } { $($more)* }) ($st) { $($rows)* } { $($more)* })
}; };
// info, explicit refusal
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ info $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Info($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
($st) { $($rows)* } { $($more)* })
};
// info, transition / stay / branch
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ info $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Info($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
($st) { $($rows)* } { $($more)* })
};
// state_timeout, explicit refusal (unit event — no pattern)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ state_timeout $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::StateTimeout) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
($st) { $($rows)* } { $($more)* })
};
// state_timeout, transition / stay / branch
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ state_timeout $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::StateTimeout) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
($st) { $($rows)* } { $($more)* })
};
// timeout, explicit refusal (pattern matches the name)
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ timeout $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Timeout($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
($st) { $($rows)* } { $($more)* })
};
// timeout, transition / stay / branch
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ timeout $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Timeout($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
($st) { $($rows)* } { $($more)* })
};
// this block is drained: hand the remaining on-blocks back to @arms // this block is drained: hand the remaining on-blocks back to @arms
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat) (@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ } { $($more:tt)* } { } { $($more:tt)* }
+153 -45
View File
@@ -1,76 +1,184 @@
//! gen_statem behaviour tests, driven through the `gen_statem!` macro: cast/call //! 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 //! 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;
use smarm::gen_statem::{CallError, Reply}; use smarm::gen_statem::{CallError, Reply};
use smarm::run; use smarm::run;
use std::sync::{Arc, Mutex}; 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)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Switch { enum T {
Off, Idle,
On, Armed,
} }
struct Counts { struct TData {
flips: u32, enters: u32, // total state entries (incl. initial)
enters: u32, 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 { enum TCast {
Flip, 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 { enum TCall {
GetFlips(Reply<u32>), Enters(Reply<u32>),
GetEnters(Reply<u32>), StFires(Reply<u32>),
Boom(Reply<u32>), NamedFires(Reply<u32>),
Boom(Reply<u32>), // panics, to exercise the call-to-dead-machine path
} }
gen_statem! { gen_statem! {
machine: Sm { state: Switch, data: Counts }; machine: TimerSm { state: T, data: TData };
event: Ev { cast: Cast, call: Call }; event: Ev2 { cast: TCast, call: TCall, info: () };
context(data, prev, cx); context(data, prev, cx);
enter { 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 => { on T::Idle => {
cast Cast::Flip => { data.flips += 1; Switch::On }, cast TCast::Arm => T::Armed,
} cast TCast::Disarm => unhandled,
on Switch::On => { state_timeout => unhandled,
cast Cast::Flip => Switch::Off,
} }
// State-independent queries: reply, then stay via `prev`. Boom panics on T::Armed => {
// (`boom()` is typed as a state tag so the arm stays well-formed). // 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 _ => { on _ => {
call Call::GetFlips(r) => { r.reply(data.flips); prev }, cast TCast::Ping(ms) => { cx.timeout("ping", Duration::from_millis(ms)); prev },
call Call::GetEnters(r) => { r.reply(data.enters); prev }, cast TCast::CancelPing => { cx.cancel_timeout("ping"); prev },
call Call::Boom(_r) => boom(), 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") 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] #[test]
fn cast_then_call_roundtrip() { fn cast_then_call_roundtrip() {
let got = Arc::new(Mutex::new(0u32)); let got = Arc::new(Mutex::new(0u32));
let got2 = got.clone(); let got2 = got.clone();
run(move || { run(move || {
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 }); // Long state-timeout window so it never fires during the test.
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On (flips = 1) let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 });
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // On -> Off (no flip count) m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed (enter)
let flips = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap(); m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (enter)
*got2.lock().unwrap() = flips; 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 // `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 got = Arc::new(Mutex::new((0u32, 0u32, 0u32)));
let got2 = got.clone(); let got2 = got.clone();
run(move || { run(move || {
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 }); // enter -> 1 let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 }); // enter -> 1
let after_start = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap(); let after_start = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
// Two stays (the reads) must not bump enters. // A stay (a counter read returns `prev`) must not bump enters.
let _ = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap(); let _ = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap();
let still = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap(); let still = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On -> enter -> 2 m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed -> enter -> 2
let after_flip = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap(); let after_arm = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
*got2.lock().unwrap() = (after_start, still, after_flip); *got2.lock().unwrap() = (after_start, still, after_arm);
}); });
assert_eq!(*got.lock().unwrap(), (1, 1, 2)); 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 got = Arc::new(Mutex::new(None::<Result<u32, CallError>>));
let got2 = got.clone(); let got2 = got.clone();
run(move || { run(move || {
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 }); let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 });
let r = sw.call(|rep| Ev::Call(Call::Boom(rep))); let r = m.call(|rep| Ev2::Call(TCall::Boom(rep)));
*got2.lock().unwrap() = Some(r); *got2.lock().unwrap() = Some(r);
}); });
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::Down))); assert_eq!(*got.lock().unwrap(), Some(Err(CallError::Down)));