gen_statem: state and named timeouts, info events

Add the two timeout flavours, both surfacing as ordinary events matched
in on-state arms:

- cx.state_timeout(d): fires a state_timeout event after d in the current
  state, auto-reset by the loop on every real transition.
- cx.timeout(name, d): fires a timeout(name) event after d, surviving state
  changes, keyed by name, with cx.cancel_timeout(name).

Both ride the existing timer min-heap via send_after_to onto a new per-loop
system channel, selected above the inbox so a fire can't be starved by inbox
traffic. A local-id stamp on each fire lets a reset/cancel that loses the race
discard a stale fire. The macro grows an info: clause and folds three internal
Ev variants (Info, StateTimeout, Timeout) alongside cast/call, with new row
keywords info / state_timeout / timeout. Unmatched info silently drops (the
gen_server default); state/named timeouts have no default, so a state that can
see one must handle it or the match is non-exhaustive.

Rename the hand-written expansion-target example fused -> expanded, retire the
deprecated Switch demo machine (its round-trip / enter / panic-down coverage
moves onto the timer machine), and refresh the macro docs to the door machine.

cargo build --all-targets warning-free; cargo test green.
This commit is contained in:
smarm-agent
2026-06-20 12:22:45 +00:00
parent bfa513cd6d
commit acf67fef06
4 changed files with 600 additions and 132 deletions
+394 -77
View File
@@ -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
/// 23) 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 23 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)* }