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:
@@ -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<Ev>) {
|
||||
fn enter(&mut self, cx: &mut Cx<Ev>) {
|
||||
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<Ev>) {
|
||||
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");
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+394
-77
@@ -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<Self::Ev>);
|
||||
@@ -109,27 +142,150 @@ impl<S> From<S> for Resolution<S> {
|
||||
// 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<Mutex>`
|
||||
/// rather than `Rc<RefCell>` 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<Ev> {
|
||||
sys_tx: Sender<Sys>,
|
||||
reg: Arc<Mutex<Timers>>,
|
||||
_ev: PhantomData<fn() -> Ev>,
|
||||
}
|
||||
|
||||
impl<Ev> Cx<Ev> {
|
||||
fn new() -> Self {
|
||||
Cx { _ev: PhantomData }
|
||||
fn new(sys_tx: Sender<Sys>, reg: Arc<Mutex<Timers>>) -> 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<Ev> Cx<Ev> {
|
||||
/// 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<T> {
|
||||
tx: Sender<T>,
|
||||
}
|
||||
@@ -211,9 +366,7 @@ impl<M: Machine> GenStatemRef<M> {
|
||||
/// 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<T, F>(&self, make: F) -> Result<T, CallError>
|
||||
where
|
||||
T: Send + 'static,
|
||||
@@ -241,18 +394,71 @@ pub fn spawn<M: Machine>(machine: M) -> GenStatemRef<M> {
|
||||
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<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);
|
||||
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<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
|
||||
/// 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<M: Machine>(rx: Receiver<M::Ev>, 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<M: Machine>(rx: Receiver<M::Ev>, 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<u32>) }
|
||||
/// enum Door { Open, Closed, Locked }
|
||||
/// struct Data { enters: u32 }
|
||||
/// enum DoorCast { Push, Pull, Lock, Unlock(u32) }
|
||||
/// enum DoorCall { GetState(Reply<Door>) }
|
||||
///
|
||||
/// 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<M: Machine>(rx: Receiver<M::Ev>, 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 <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:
|
||||
/// // * 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<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
|
||||
/// * **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
|
||||
/// 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 <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
|
||||
/// 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<M: Machine>(rx: Receiver<M::Ev>, 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 <pat>` 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)* }
|
||||
|
||||
+153
-45
@@ -1,76 +1,184 @@
|
||||
//! gen_statem behaviour tests, driven through the `gen_statem!` macro: cast/call
|
||||
//! round-trip, `enter` firing on start and on every real transition (but not on
|
||||
//! a stay), and the machine-down path when a handler panics.
|
||||
//! a stay), the machine-down path when a handler panics, and the timeout
|
||||
//! surface (state-timeout firing + auto-reset across transitions; named
|
||||
//! timeouts surviving transitions; cancellation).
|
||||
|
||||
use smarm::gen_statem;
|
||||
use smarm::gen_statem::{CallError, Reply};
|
||||
use smarm::run;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
// A two-state machine: Flip toggles, calls read counters, Boom panics.
|
||||
// ===========================================================================
|
||||
// Timeouts
|
||||
// ===========================================================================
|
||||
//
|
||||
// A small timer machine. `Idle` is quiet; `Armed` arms a state-timeout on entry
|
||||
// that — when it fires — bumps `st_fires` and returns to `Idle`. A named
|
||||
// timeout ("ping") is armed independently, survives the Idle/Armed transition,
|
||||
// and bumps `named_fires` when it fires. Counters are read back over calls.
|
||||
//
|
||||
// Durations are tiny and waits use `smarm::sleep` (parks the actor, leaves the
|
||||
// timer wheel turning). These tests run against real time, so they are a touch
|
||||
// slow; a controllable clock would tighten them.
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Switch {
|
||||
Off,
|
||||
On,
|
||||
enum T {
|
||||
Idle,
|
||||
Armed,
|
||||
}
|
||||
|
||||
struct Counts {
|
||||
flips: u32,
|
||||
enters: u32,
|
||||
struct TData {
|
||||
enters: u32, // total state entries (incl. initial)
|
||||
st_fires: u32, // state-timeout fires
|
||||
named_fires: u32, // named-timeout fires
|
||||
st_window: u64, // ms for the Armed state-timeout (set per test intent)
|
||||
}
|
||||
|
||||
enum Cast {
|
||||
Flip,
|
||||
enum TCast {
|
||||
Arm, // Idle -> Armed
|
||||
Disarm, // Armed -> Idle (a transition that should auto-reset the state-timeout)
|
||||
Ping(u64), // arm a named "ping" timeout after the given ms
|
||||
CancelPing, // cancel the named "ping"
|
||||
}
|
||||
|
||||
enum Call {
|
||||
GetFlips(Reply<u32>),
|
||||
GetEnters(Reply<u32>),
|
||||
Boom(Reply<u32>),
|
||||
enum TCall {
|
||||
Enters(Reply<u32>),
|
||||
StFires(Reply<u32>),
|
||||
NamedFires(Reply<u32>),
|
||||
Boom(Reply<u32>), // panics, to exercise the call-to-dead-machine path
|
||||
}
|
||||
|
||||
gen_statem! {
|
||||
machine: Sm { state: Switch, data: Counts };
|
||||
event: Ev { cast: Cast, call: Call };
|
||||
machine: TimerSm { state: T, data: TData };
|
||||
event: Ev2 { cast: TCast, call: TCall, info: () };
|
||||
context(data, prev, cx);
|
||||
|
||||
enter {
|
||||
_ => data.enters += 1,
|
||||
// On entering Armed, arm the state-timeout. On Idle, nothing — and the
|
||||
// loop has already auto-reset any pending state-timeout on the way in.
|
||||
T::Armed => { data.enters += 1; cx.state_timeout(Duration::from_millis(data.st_window)); },
|
||||
T::Idle => data.enters += 1,
|
||||
}
|
||||
|
||||
on Switch::Off => {
|
||||
cast Cast::Flip => { data.flips += 1; Switch::On },
|
||||
}
|
||||
on Switch::On => {
|
||||
cast Cast::Flip => Switch::Off,
|
||||
on T::Idle => {
|
||||
cast TCast::Arm => T::Armed,
|
||||
cast TCast::Disarm => unhandled,
|
||||
state_timeout => unhandled,
|
||||
}
|
||||
|
||||
// State-independent queries: reply, then stay via `prev`. Boom panics
|
||||
// (`boom()` is typed as a state tag so the arm stays well-formed).
|
||||
on T::Armed => {
|
||||
// The state-timeout elapsed while still Armed: count it and go Idle.
|
||||
state_timeout => { data.st_fires += 1; T::Idle },
|
||||
cast TCast::Disarm => T::Idle,
|
||||
cast TCast::Arm => unhandled,
|
||||
}
|
||||
|
||||
// State-independent rows: the named-timeout (survives transitions), the
|
||||
// arm/cancel casts, the counter reads, and the panicking call.
|
||||
on _ => {
|
||||
call Call::GetFlips(r) => { r.reply(data.flips); prev },
|
||||
call Call::GetEnters(r) => { r.reply(data.enters); prev },
|
||||
call Call::Boom(_r) => boom(),
|
||||
cast TCast::Ping(ms) => { cx.timeout("ping", Duration::from_millis(ms)); prev },
|
||||
cast TCast::CancelPing => { cx.cancel_timeout("ping"); prev },
|
||||
timeout "ping" => { data.named_fires += 1; prev },
|
||||
timeout _ => unhandled,
|
||||
call TCall::Enters(r) => { r.reply(data.enters); prev },
|
||||
call TCall::StFires(r) => { r.reply(data.st_fires); prev },
|
||||
call TCall::NamedFires(r) => { r.reply(data.named_fires); prev },
|
||||
call TCall::Boom(_r) => boom(),
|
||||
}
|
||||
}
|
||||
|
||||
fn boom() -> Switch {
|
||||
fn boom() -> T {
|
||||
panic!("boom")
|
||||
}
|
||||
// Casts are applied in order and a later call observes the accumulated data.
|
||||
|
||||
// A state-timeout armed on entry to Armed fires after its window, bumping the
|
||||
// counter and returning the machine to Idle on its own.
|
||||
#[test]
|
||||
fn state_timeout_fires() {
|
||||
let got = Arc::new(Mutex::new(0u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 5 });
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed, arms 5ms state-timeout
|
||||
smarm::sleep(Duration::from_millis(40)); // let it fire
|
||||
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 1, "state-timeout fired exactly once");
|
||||
}
|
||||
|
||||
// Leaving Armed before the window elapses auto-resets the state-timeout: it
|
||||
// must not fire afterward, even though we wait well past its original window.
|
||||
#[test]
|
||||
fn state_timeout_auto_resets_on_transition() {
|
||||
let got = Arc::new(Mutex::new(99u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
// Long window so the explicit Disarm beats it comfortably.
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 50 });
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed, arms 50ms state-timeout
|
||||
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle, auto-resets it
|
||||
smarm::sleep(Duration::from_millis(80)); // past the original window
|
||||
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 0, "auto-reset cancelled the pending state-timeout");
|
||||
}
|
||||
|
||||
// A named timeout survives a state change: armed in Idle, it still fires after
|
||||
// the machine has moved to Armed and back.
|
||||
#[test]
|
||||
fn named_timeout_survives_transition() {
|
||||
let got = Arc::new(Mutex::new(0u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
// Armed's own state-timeout is long so it doesn't interfere.
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 200 });
|
||||
m.send(Ev2::Cast(TCast::Ping(20))).unwrap(); // arm "ping" for 20ms (in Idle)
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // -> Armed (ping must survive this)
|
||||
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // -> Idle (and this)
|
||||
smarm::sleep(Duration::from_millis(60)); // let "ping" fire
|
||||
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::NamedFires(r))).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 1, "named timeout fired across the transitions");
|
||||
}
|
||||
|
||||
// Cancelling a named timeout before its window prevents the fire.
|
||||
#[test]
|
||||
fn named_timeout_cancel() {
|
||||
let got = Arc::new(Mutex::new(99u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 200 });
|
||||
m.send(Ev2::Cast(TCast::Ping(30))).unwrap(); // arm "ping" for 30ms
|
||||
m.send(Ev2::Cast(TCast::CancelPing)).unwrap(); // cancel before it fires
|
||||
smarm::sleep(Duration::from_millis(60)); // past the original window
|
||||
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::NamedFires(r))).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 0, "cancel prevented the named-timeout fire");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Core behaviour (cast/call round-trip, enter semantics, panic -> Down)
|
||||
// ===========================================================================
|
||||
|
||||
// Casts are applied in order and a later call observes the resulting state via
|
||||
// its counters: two Arm/Disarm round-trips leave the machine back in Idle.
|
||||
#[test]
|
||||
fn cast_then_call_roundtrip() {
|
||||
let got = Arc::new(Mutex::new(0u32));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 });
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On (flips = 1)
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // On -> Off (no flip count)
|
||||
let flips = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
|
||||
*got2.lock().unwrap() = flips;
|
||||
// Long state-timeout window so it never fires during the test.
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 });
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed (enter)
|
||||
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (enter)
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed (enter)
|
||||
m.send(Ev2::Cast(TCast::Disarm)).unwrap(); // Armed -> Idle (enter)
|
||||
// enters = 1 (start) + 4 transitions = 5.
|
||||
*got2.lock().unwrap() = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), 1, "turned On once across the two flips");
|
||||
assert_eq!(*got.lock().unwrap(), 5, "one enter on start, one per real transition");
|
||||
}
|
||||
|
||||
// `enter` fires once on start and once per *real* transition; a stay (a call
|
||||
@@ -80,14 +188,14 @@ fn enter_on_start_and_each_transition_but_not_stay() {
|
||||
let got = Arc::new(Mutex::new((0u32, 0u32, 0u32)));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 }); // enter -> 1
|
||||
let after_start = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
// Two stays (the reads) must not bump enters.
|
||||
let _ = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
|
||||
let still = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On -> enter -> 2
|
||||
let after_flip = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||
*got2.lock().unwrap() = (after_start, still, after_flip);
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 }); // enter -> 1
|
||||
let after_start = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
|
||||
// A stay (a counter read returns `prev`) must not bump enters.
|
||||
let _ = m.call(|r| Ev2::Call(TCall::StFires(r))).unwrap();
|
||||
let still = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
|
||||
m.send(Ev2::Cast(TCast::Arm)).unwrap(); // Idle -> Armed -> enter -> 2
|
||||
let after_arm = m.call(|r| Ev2::Call(TCall::Enters(r))).unwrap();
|
||||
*got2.lock().unwrap() = (after_start, still, after_arm);
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), (1, 1, 2));
|
||||
}
|
||||
@@ -100,8 +208,8 @@ fn call_to_panicking_handler_is_down() {
|
||||
let got = Arc::new(Mutex::new(None::<Result<u32, CallError>>));
|
||||
let got2 = got.clone();
|
||||
run(move || {
|
||||
let sw = Sm::start(Switch::Off, Counts { flips: 0, enters: 0 });
|
||||
let r = sw.call(|rep| Ev::Call(Call::Boom(rep)));
|
||||
let m = TimerSm::start(T::Idle, TData { enters: 0, st_fires: 0, named_fires: 0, st_window: 10_000 });
|
||||
let r = m.call(|rep| Ev2::Call(TCall::Boom(rep)));
|
||||
*got2.lock().unwrap() = Some(r);
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::Down)));
|
||||
|
||||
Reference in New Issue
Block a user