From acf67fef066a3aeee402b09b9867738b67922a0c Mon Sep 17 00:00:00 2001 From: smarm-agent Date: Sat, 20 Jun 2026 11:57:04 +0000 Subject: [PATCH] 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. --- ...statem_fused.rs => gen_statem_expanded.rs} | 51 +- examples/gen_statem_macro.rs | 12 +- src/gen_statem.rs | 471 +++++++++++++++--- tests/gen_statem.rs | 198 ++++++-- 4 files changed, 600 insertions(+), 132 deletions(-) rename examples/{gen_statem_fused.rs => gen_statem_expanded.rs} (79%) diff --git a/examples/gen_statem_fused.rs b/examples/gen_statem_expanded.rs similarity index 79% rename from examples/gen_statem_fused.rs rename to examples/gen_statem_expanded.rs index 4d1531f..9a8a7c6 100644 --- a/examples/gen_statem_fused.rs +++ b/examples/gen_statem_expanded.rs @@ -31,7 +31,7 @@ //! Default build is clean and runs. A BREAK-CASE MENU at the bottom documents //! 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)] @@ -68,6 +68,11 @@ enum Call { enum Ev { Cast(Cast), 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; @@ -119,14 +124,28 @@ impl DoorSm { }) } - fn enter(&mut self, _cx: &mut Cx) { + fn enter(&mut self, cx: &mut Cx) { 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 { 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) { self.enter(cx); } @@ -173,6 +192,19 @@ impl Machine for DoorSm { r.reply(self.data.pushes); 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 ----------------------------------------- @@ -180,6 +212,7 @@ impl Machine for DoorSm { Resolution::To(s) if s == prev => {} // stay: no enter Resolution::To(s) => { self.state = s; // sole writer of the state cell + cx.__reset_state_timeout(); // auto-reset across transitions self.enter(cx); } 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::Unlock(0))).unwrap(); // Locked: wrong key -> stay 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(); // Open -> Closed (pushes=1) + door.send(Ev::Cast(Cast::Push)).unwrap(); // Closed: Push invalid -> Unhandled + 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 enters = door.call(|r| Ev::Call(Call::GetEnters(r))).unwrap(); let pushes = door.call(|r| Ev::Call(Call::GetPushes(r))).unwrap(); 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!(pushes, 1); + assert_eq!(pushes, 0); // no push ever closed it this run println!("ok"); }); } diff --git a/examples/gen_statem_macro.rs b/examples/gen_statem_macro.rs index 660325c..0f920a9 100644 --- a/examples/gen_statem_macro.rs +++ b/examples/gen_statem_macro.rs @@ -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 //! macro buys: every `// ===` section there that was boilerplate (the `Ev` //! enum, the `DoorSm` struct, `start`, the whole `Machine` impl, the `enter` @@ -21,7 +21,7 @@ use smarm::gen_statem; use smarm::run; 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)] enum Door { @@ -87,7 +87,7 @@ fn on_unlock(key: u32) -> UnlockOutcome { gen_statem! { 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 // `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::GetEnters(r) => { r.reply(data.enters); 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 -// 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): // add `fn on_slam(_d: &mut Data) {}` and don't reference it. diff --git a/src/gen_statem.rs b/src/gen_statem.rs index 0dbfd41..5dce943 100644 --- a/src/gen_statem.rs +++ b/src/gen_statem.rs @@ -19,8 +19,8 @@ //! 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 //! 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 -//! it expands to. +//! the macro form and `examples/gen_statem_expanded.rs` for the hand-written +//! shape it expands to. //! //! ## The unified event //! @@ -38,13 +38,35 @@ //! `gen_server` call round-trip, but with the reply handle riding *inside* the //! user's own event so a handler can answer it. //! -//! [`Resolution::Postpone`] and the timeout arming on [`Cx`] are part of the -//! type surface but are not yet wired up. +//! ## Timeouts +//! +//! 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::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::sync::{Arc, Mutex}; +use std::time::Duration; // --------------------------------------------------------------------------- // Machine @@ -59,10 +81,21 @@ use std::marker::PhantomData; /// [`handle`](Self::handle) per event. pub trait Machine: Send + 'static { /// 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 - /// module docs. + /// `call` enums folded together with the runtime's internal events + /// (`state_timeout`, `timeout(name)`, and an `Info` wrapper). See the module + /// docs. 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 the `enter` arm for the initial state. fn on_start(&mut self, cx: &mut Cx); @@ -109,27 +142,150 @@ impl From for Resolution { // Cx — the per-handler context // --------------------------------------------------------------------------- -/// The context handle injected into [`Machine::on_start`] and -/// [`Machine::handle`]. Non-state outcomes (postpone, timeout arming) live here -/// as method calls rather than keywords. +// --------------------------------------------------------------------------- +// Sys — the loop's internal timer-fire channel +// --------------------------------------------------------------------------- + +/// 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` +/// rather than `Rc` only because `Machine: Send` forces it). /// -/// For now it carries only the [`on_unhandled`](Self::on_unhandled) hook; -/// `cx.state_timeout(d)` / `cx.timeout(name, d)` and `cx.postpone()` will attach -/// here when implemented. It lives only on the actor's own -/// stack and is never sent. +/// Each arming mints a fresh **local id** carried in the fire payload and +/// recorded here alongside the substrate id used to cancel. On fire the loop +/// compares the payload's local id against the recorded one: a fire whose id no +/// 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 { + sys_tx: Sender, + reg: Arc>, _ev: PhantomData Ev>, } impl Cx { - fn new() -> Self { - Cx { _ev: PhantomData } + fn new(sys_tx: Sender, reg: Arc>) -> Self { + Cx { sys_tx, reg, _ev: PhantomData } } - /// The default for an event no arm matched: **log-and-drop**. Overridable - /// hook wiring is a follow-on; for now an unmatched event is - /// silently dropped, as `gen_server` does with unexpected messages. + /// Arm the **state timeout**: fire a `state_timeout` event after `after` in + /// the current state. Auto-reset on any state change (the loop cancels and + /// 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) {} + + /// 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 Cx { /// machine dies, closing the channel). /// /// 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 -/// handle included, moves onto the postpone queue and is answered by a later -/// state. +/// — is what will let a **postponed call** work: the whole event, reply handle +/// included, moves onto the postpone queue and is answered by a later state. pub struct Reply { tx: Sender, } @@ -211,9 +366,7 @@ impl GenStatemRef { /// stack unwinds, closing the channel and waking the caller). /// /// `make` wraps the [`Reply`] all the way to `M::Ev` — typically - /// `|r| Ev::Call(MyCall::GetCount(r))`. (The deferred macro would generate a - /// thinner `call` that hides the `Ev::Call` wrap and takes the bare variant - /// constructor.) + /// `|r| Ev::Call(MyCall::GetCount(r))`. pub fn call(&self, make: F) -> Result where T: Send + 'static, @@ -241,18 +394,71 @@ pub fn spawn(machine: M) -> GenStatemRef { GenStatemRef { tx, pid: handle.pid() } } -/// The machine actor body: `on_start`, then one `handle` per inbox event until -/// the inbox closes (all refs dropped → graceful shutdown). Chunk 1 parks on -/// the inbox alone — no system arm, no timers — the analogue of `gen_server`'s -/// plain-inbox park. +/// The machine actor body: `on_start`, then one `handle` per event until the +/// inbox closes (all refs dropped → graceful shutdown). +/// +/// 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(rx: Receiver, mut machine: M) { - let mut cx = Cx::new(); + let (sys_tx, sys_rx) = channel::(); + 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); loop { - match rx.recv() { - Ok(ev) => machine.handle(ev, &mut cx), - // All StatemRefs dropped → inbox closed → shutdown. - Err(_) => break, + // Timer arm first: a ready fire is taken in preference to the inbox. + let i = select(&[&sys_rx, &rx]); + if i == 0 { + 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 // cancellable. @@ -261,14 +467,14 @@ fn statem_loop(rx: Receiver, 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 /// transition table, and free handler functions. /// -/// This is the **fused** authoring surface (see `examples/statem_fused.rs` for -/// the same machine written out by hand). You keep ownership of every type that +/// This is the authoring surface (see `examples/gen_statem_expanded.rs` for the +/// 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, /// and any per-row successor enums — and the macro emits only the mechanical /// scaffolding: the unified event enum, the machine struct and its private @@ -308,12 +514,12 @@ fn statem_loop(rx: Receiver, mut machine: M) { /// 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 /// `#![deny]` nor `#![forbid]` overrides the suppression. This is the one -/// guarantee a `macro_rules!` cannot carry across the crate boundary; the -/// RFC's proc-macro could (it would stamp the arms with call-site spans). +/// guarantee a `macro_rules!` cannot carry across the crate boundary; a +/// 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. /// /// 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 /// 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. @@ -323,14 +529,16 @@ fn statem_loop(rx: Receiver, mut machine: M) { /// ```ignore /// // ── your types (the macro never generates or inspects these) ─────────── /// #[derive(Clone, Copy, PartialEq, Eq, Debug)] -/// enum Switch { Off, On } -/// struct Counts { flips: u32, enters: u32 } -/// enum SwitchCast { Flip } -/// enum SwitchCall { GetCount(Reply) } +/// enum Door { Open, Closed, Locked } +/// struct Data { enters: u32 } +/// enum DoorCast { Push, Pull, Lock, Unlock(u32) } +/// enum DoorCall { GetState(Reply) } /// /// gen_statem! { -/// machine: SwitchSm { state: Switch, data: Counts }; -/// event: Ev { cast: SwitchCast, call: SwitchCall }; +/// machine: DoorSm { state: Door, data: Data }; +/// // 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 /// // hand you its own `self`/`cx` (hygiene), so you choose the identifiers @@ -339,26 +547,48 @@ fn statem_loop(rx: Receiver, mut machine: M) { /// context(data, prev, cx); /// /// // `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 { +/// Door::Open => { +/// data.enters += 1; +/// cx.state_timeout(std::time::Duration::from_secs(30)); // auto-close +/// } /// _ => data.enters += 1, /// } /// /// // The transition table. Group rows by current state with `on `. -/// // A row is: cast|call [if ] => , +/// // A row is: [if ] => , +/// // where is one of `cast`, `call`, `info`, `state_timeout` +/// // (no pattern — it is a unit event), or `timeout `, /// // and the tail is one of: -/// // * a state tag `Switch::On` (transition, or "stay" -/// // if it equals current) -/// // * a block ending in one `{ data.flips += 1; Switch::On }` -/// // * a successor-enum value `unlock(key)` (branching row) -/// // * the keyword `unhandled` (explicit refusal) -/// on Switch::Off => { -/// cast SwitchCast::Flip => { data.flips += 1; Switch::On }, -/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev }, +/// // * a state tag `Door::Closed` (transition, or "stay" +/// // if it equals current) +/// // * a block ending in one `{ data.enters += 1; Door::Closed }` +/// // * a successor-enum value `on_unlock(key)` (branching row) +/// // * the keyword `unhandled` (explicit refusal) +/// on Door::Open => { +/// cast DoorCast::Push => Door::Closed, +/// // An armed state-timeout surfaces as an ordinary event: +/// state_timeout => Door::Closed, +/// cast DoorCast::Pull | DoorCast::Lock | DoorCast::Unlock(_) => unhandled, /// } -/// on Switch::On => { -/// cast SwitchCast::Flip => Switch::Off, -/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev }, +/// on Door::Closed => { +/// cast DoorCast::Pull => Door::Open, +/// 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(rx: Receiver, mut machine: M) { /// * **Every row ends in a comma** (block-bodied ones too) and every `on` block /// is `on => { … }`. These two are macro-grammar requirements, not /// style: a declarative matcher can't otherwise tell where a row's tail ends. -/// * **Qualify event patterns** (`SwitchCast::Flip`) and **state tags** -/// (`Switch::On`). A declarative macro can't prepend the enum name inside an -/// opaque pattern fragment, so the `cast`/`call` keyword only selects the -/// event wrapper — it does not qualify for you. The keyword also reads as a -/// sync/async marker at a glance. +/// * **Qualify event patterns** (`DoorCast::Push`) and **state tags** +/// (`Door::Closed`). A declarative macro can't prepend the enum name inside an +/// opaque pattern fragment, so the row keyword only selects the event wrapper +/// — it does not qualify for you. `cast`/`call` also read as a sync/async +/// marker at a glance. +/// * **Timeouts surface as events.** `state_timeout` (a unit event) and +/// `timeout ` (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 /// bound to the pre-handler state for exactly this — handy in any-state /// (`on _`) rows where there is no single literal tag to write. /// * **`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 -/// 2–3) arm timeouts or postpone via `cx`. You never write `self`. +/// mutate `data`, reply through a `Reply` bound in the pattern, and arm +/// timeouts via `cx`. You never write `self`. /// * **Guards** are plain match guards. A guarded arm does not count toward /// exhaustiveness, so pair it with an unguarded fallback or you'll (correctly) /// trip `E0004`. @@ -387,11 +628,13 @@ fn statem_loop(rx: Receiver, mut machine: M) { /// /// # What it emits /// -/// `enum $Ev { Cast($Cast), Call($Call) }`, `struct $Sm { state, data }`, -/// `$Sm::start(init, data) -> GenStatemRef<$Sm>`, the `Machine` impl (`on_start` -/// running the initial `enter`; `handle` = the dispatch match + the -/// stay/transition/unhandled apply-tail, the cell's sole writer), and the -/// `enter` dispatch. Chunk 1: real time, no timers, no postpone. +/// The unified `enum $Ev` (the `Cast`/`Call`/`Info` wrappers plus the internal +/// `StateTimeout` / `Timeout` events), `struct $Sm { state, data }`, +/// `$Sm::start(init, data) -> GenStatemRef<$Sm>`, and the `Machine` impl: +/// `on_start` runs the initial `enter`; `handle` is the dispatch match plus the +/// 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 /// @@ -403,16 +646,23 @@ macro_rules! gen_statem { // ===== public entry ===================================================== ( 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 ) ; enter { $( $est:pat => $ebody:expr ),+ $(,)? } $( on $st:pat => { $($rows:tt)* } )+ ) => { - /// Unified inbox payload: the user's `cast`/`call` enums folded together - /// (chunks 2–3 add the runtime's internal timeout variants here). + /// Unified inbox payload: the user's `cast`/`call`/`info` enums folded + /// together with the runtime's internal timeout events. enum $Ev { Cast($Cast), 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 ` rows). + Timeout(&'static str), } struct $sm { @@ -438,6 +688,14 @@ macro_rules! gen_statem { impl $crate::gen_statem::Machine for $sm { 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>) { let s = self.state; 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) => { 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); } $crate::gen_statem::Resolution::Postpone => { @@ -470,9 +731,17 @@ macro_rules! gen_statem { }; // ===== @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)* ]) => { - 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. (@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()), ] ($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 (@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat) { } { $($more:tt)* } diff --git a/tests/gen_statem.rs b/tests/gen_statem.rs index 3efafe8..bb2b8a8 100644 --- a/tests/gen_statem.rs +++ b/tests/gen_statem.rs @@ -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), - GetEnters(Reply), - Boom(Reply), +enum TCall { + Enters(Reply), + StFires(Reply), + NamedFires(Reply), + Boom(Reply), // 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::>)); 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)));