Files
smarm/src/gen_statem.rs
T
smarm-agent 0cf6b80396 gen_statem: GenStatem* type prefix + cleanup
- rename StatemRef/StatemCallError/StatemSendError -> GenStatem*
- move the inline unit test out of src; consolidate the Switch coverage
  onto a single macro-driven harness in tests/gen_statem.rs
- drop the redundant hand-written Switch test machine and the two
  untracked rejected-direction probes (succ_enums, typed_edges)
- rename examples statem_{fused,macro}.rs -> gen_statem_{fused,macro}.rs
- strip RFC/chunk/spike provenance and fix the mislabeled "throwaway"
  example header and dead cross-references
2026-06-20 10:48:33 +00:00

525 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! gen_statem — generic finite state machine behaviour.
//!
//! The sibling of [`gen_server`](crate::gen_server): where a `gen_server`
//! carries one undifferentiated blob of state and a single `handle` that
//! re-derives "what mode am I in" on every message, a `gen_statem` makes the
//! state an explicit **tag** and routes event handling by it.
//!
//! ## What this layer is
//!
//! This module is the **runtime support** a state machine runs on. A machine is
//! any type implementing [`Machine`]: it owns its state tag and data, and its
//! [`handle`](Machine::handle) reduces a `(state, event)` pair to a
//! [`Resolution`]. The loop here drives it — spawn,
//! [`on_start`](Machine::on_start), then one [`handle`](Machine::handle) per
//! inbox event — mirroring `gen_server`'s spawn/teardown idioms.
//!
//! The [`gen_statem!`](crate::gen_statem) macro is the authoring surface: it
//! *generates* a `Machine` impl from per-state handler blocks. Edge-validity is
//! 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 unified event
//!
//! A machine's [`Ev`](Machine::Ev) is the single payload its inbox carries. By
//! convention (and in the macro's desugaring) it folds the user's `cast` and
//! `call` enums together:
//!
//! ```ignore
//! enum Ev { Cast(MyCast), Call(MyCall) }
//! ```
//!
//! [`GenStatemRef::send`] pushes any event (a cast is just a `send`);
//! [`GenStatemRef::call`] builds a one-shot [`Reply`] channel, hands it to a
//! `call` variant, and parks until the machine answers — exactly the
//! `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.
use crate::channel::{channel, Receiver, Sender};
use crate::pid::Pid;
use crate::scheduler::spawn as spawn_actor;
use std::marker::PhantomData;
// ---------------------------------------------------------------------------
// Machine
// ---------------------------------------------------------------------------
/// A finite state machine driven by the [`statem`](crate::gen_statem) loop.
///
/// The implementor owns its current state tag and its persistent data. It is
/// the **sole writer** of the state cell (the loop never touches it): both
/// transition legality and, later, observer reads have a single source of
/// truth. The loop only calls [`on_start`](Self::on_start) once and
/// [`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.
type Ev: Send + 'static;
/// 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>);
/// React to one event. The body matches `(state, event)`, performs side
/// effects / replies, and ends each arm in a [`Resolution`] — typically a
/// state tag via `Tag.into()` (transition, or "stay" when it equals the
/// current tag). On a transition the body sets the state cell and runs the
/// new state's `enter`.
fn handle(&mut self, ev: Self::Ev, cx: &mut Cx<Self::Ev>);
}
// ---------------------------------------------------------------------------
// Resolution
// ---------------------------------------------------------------------------
/// The outcome of handling one event, before the loop/handler acts on it:
/// dispatch reduces to `(state, event) -> Resolution<State>`.
///
/// [`From<S>`](From) is why a bare state tag works as an arm tail and why
/// "stay" needs no keyword — `Tag.into()` is `To(Tag)`, and the handler treats
/// `To(s)` with `s == current` as stay.
pub enum Resolution<S> {
/// End in state `s`. A **transition** when `s != current` (set the cell,
/// run `enter`); a **stay** when `s == current` (no `enter`).
To(S),
/// Defer the current event onto the postpone queue, to be replayed after
/// the next real transition. Produced by `cx.postpone()`; present
/// here for forward-compatibility but not yet generated.
Postpone,
/// No arm matched `(state, event)`: log-and-drop via
/// [`Cx::on_unhandled`], mirroring `gen_server`'s handling of unexpected
/// messages.
Unhandled,
}
impl<S> From<S> for Resolution<S> {
fn from(s: S) -> Self {
Resolution::To(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.
///
/// 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.
pub struct Cx<Ev> {
_ev: PhantomData<fn() -> Ev>,
}
impl<Ev> Cx<Ev> {
fn new() -> Self {
Cx { _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.
pub fn on_unhandled(&mut self) {}
}
// ---------------------------------------------------------------------------
// Reply — the move-only reply handle a `call` variant carries
// ---------------------------------------------------------------------------
/// The reply side of a synchronous `call`, carried *inside* the machine's own
/// `call` event variant (`GetCount(Reply<u32>)`). Move-only: answering consumes
/// it, so a handler replies at most once. Built by [`GenStatemRef::call`]; the
/// caller parks on the matching receiver until `reply` is invoked (or the
/// 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.
pub struct Reply<T> {
tx: Sender<T>,
}
impl<T> Reply<T> {
/// Answer the call. A dropped/abandoned caller (e.g. one that timed out)
/// makes the send fail harmlessly — the machine's reply is simply
/// discarded, exactly as `gen_server`'s reply send behaves.
pub fn reply(self, value: T) {
let _ = self.tx.send(value);
}
}
// ---------------------------------------------------------------------------
// Client handle
// ---------------------------------------------------------------------------
/// Returned by [`GenStatemRef::call`] when the machine is unreachable.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CallError {
/// The machine was already gone, or died before replying.
Down,
}
/// Returned by [`GenStatemRef::send`] when the machine's inbox is closed.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum SendError {
/// The machine is gone (its inbox is closed).
Down,
}
/// A clonable handle to a running machine. Cloning yields another sender to the
/// same inbox; the machine lives until the last `GenStatemRef` is dropped, at which
/// point its inbox closes and the loop exits.
pub struct GenStatemRef<M: Machine> {
tx: Sender<M::Ev>,
pid: Pid,
}
impl<M: Machine> Clone for GenStatemRef<M> {
fn clone(&self) -> Self {
GenStatemRef { tx: self.tx.clone(), pid: self.pid }
}
}
impl<M: Machine> GenStatemRef<M> {
/// The machine actor's pid — usable with `monitor`, `request_stop`, `link`.
pub fn pid(&self) -> Pid {
self.pid
}
/// Push one event into the inbox and return immediately (fire-and-forget).
/// A cast is just a `send` of the cast-tagged event. [`SendError::Down`] if
/// the inbox is already closed.
pub fn send(&self, ev: M::Ev) -> Result<(), SendError> {
self.tx.send(ev).map_err(|_| SendError::Down)
}
/// Synchronous request-reply. Builds a one-shot [`Reply`] channel, hands it
/// to `make` to construct the call-tagged event, sends it, and parks until
/// the machine replies — or returns [`CallError::Down`] if the machine is or
/// becomes unreachable first (the reply sender is dropped as the machine's
/// 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.)
pub fn call<T, F>(&self, make: F) -> Result<T, CallError>
where
T: Send + 'static,
F: FnOnce(Reply<T>) -> M::Ev,
{
let (tx, rx) = channel::<T>();
let ev = make(Reply { tx });
self.send(ev).map_err(|_| CallError::Down)?;
rx.recv().map_err(|_| CallError::Down)
}
}
// ---------------------------------------------------------------------------
// Spawn + loop
// ---------------------------------------------------------------------------
/// Spawn `machine` as an actor and hand back its [`GenStatemRef`]. Shape mirrors
/// `gen_server::start`: make the inbox, spawn the loop, return the ref; the
/// backing join handle is dropped (lifetime is governed by refs, not joining).
///
/// Panics if called outside `Runtime::run()`.
pub fn spawn<M: Machine>(machine: M) -> GenStatemRef<M> {
let (tx, rx) = channel::<M::Ev>();
let handle = spawn_actor(move || statem_loop(rx, machine));
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.
fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
let mut cx = Cx::new();
machine.on_start(&mut cx);
loop {
match rx.recv() {
Ok(ev) => machine.handle(ev, &mut cx),
// All StatemRefs dropped → inbox closed → shutdown.
Err(_) => break,
}
// Observation point so a machine fed a hot inbox stays preemptible and
// cancellable.
crate::check!();
}
}
// ---------------------------------------------------------------------------
// gen_statem! — the authoring macro (fused total-match variant)
// ---------------------------------------------------------------------------
/// 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
/// 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
/// state cell, `start`, the `Machine` impl, and the `enter` dispatch.
///
/// The macro is **pure sugar with no semantic analysis of its own** — it never
/// inspects your types and never validates the graph. Every safety property is
/// a property of the code it emits, enforced by the *compiler* — which is what
/// lets a plain `macro_rules!` carry almost the whole guarantee set a proc-macro
/// would (the one exception, conflicting rows across a crate boundary, is
/// spelled out below).
///
/// # The four guarantees (and exactly how strong each is)
///
/// Two of these are hard `rustc` errors that fire unconditionally. The other
/// two are *lints*, so they need a deny in force — and lints interact with
/// macros, which is called out below. Put `#![deny(dead_code)]` on the crate
/// using the macro (the dispatch already denies its own pattern lint).
///
/// 1. **Forgotten `(state, event)` pair → `E0004` (hard error, always).** The
/// dispatch is a *total* `match (state, event)` with **no macro-injected
/// catch-all**: a pair you never wrote makes the match non-exhaustive.
/// Totality is the price — refuse the events you don't handle with explicit
/// `=> unhandled` rows (OR-patterns keep that to one row per state).
/// 4. **Out-of-set branch target → `E0599` (hard error, always).** A branching
/// row returns a per-state *successor enum* (its variants are that state's
/// declared targets); naming a tag outside it is a missing variant.
/// 3. **Orphan handler → `dead_code` (lint; needs your `#![deny(dead_code)]`).**
/// Handlers are ordinary module-private `fn`s in *your* crate, reachable only
/// through the table, so this lint sees them normally.
/// 2. **Duplicated / conflicting row → `unreachable_patterns` (lint; with a
/// caveat).** Your patterns are emitted verbatim into one match, so a second
/// arm for the same pair is unreachable. The macro applies
/// `#[deny(unreachable_patterns)]` to the dispatch itself — **but** `rustc`
/// silences this lint for code expanded from a macro defined in *another*
/// crate (`in_external_macro`). So a conflicting row is a hard error when the
/// 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).
/// 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
/// 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.
///
/// # Surface
///
/// ```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>) }
///
/// gen_statem! {
/// machine: SwitchSm { state: Switch, data: Counts };
/// event: Ev { cast: SwitchCast, call: SwitchCall };
///
/// // Name the bindings your handler bodies use. A declarative macro can't
/// // hand you its own `self`/`cx` (hygiene), so you choose the identifiers
/// // and the macro binds them: `data` = &mut your Data, `prev` = the
/// // current state tag, `cx` = the context handle.
/// 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.
/// enter {
/// _ => data.enters += 1,
/// }
///
/// // The transition table. Group rows by current state with `on <pat>`.
/// // A row is: cast|call <event-pattern> [if <guard>] => <tail> ,
/// // 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 },
/// }
/// on Switch::On => {
/// cast SwitchCast::Flip => Switch::Off,
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev },
/// }
/// }
/// ```
///
/// # Writing the rows
///
/// * **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.
/// * **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`.
/// * **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`.
/// * **Branching rows** return a successor enum that `impl`s `From<_>` for the
/// state type; the macro supplies the outer `.into()`.
///
/// # 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.
///
/// # Limitation
///
/// Diagnostics point at the macro expansion, not the offending source row —
/// the cost of staying a `macro_rules!`. The errors are the standard `E0004` /
/// `E0599` / lint messages, just sited at the invocation.
#[macro_export]
macro_rules! gen_statem {
// ===== public entry =====================================================
(
machine: $sm:ident { state: $State:ty, data: $Data:ty } ;
event: $Ev:ident { cast: $Cast:ty, call: $Call: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).
enum $Ev {
Cast($Cast),
Call($Call),
}
struct $sm {
state: $State,
data: $Data,
}
impl $sm {
fn start(init: $State, data: $Data) -> $crate::gen_statem::GenStatemRef<$sm> {
$crate::gen_statem::spawn($sm { state: init, data })
}
#[allow(unused_variables)]
#[deny(unreachable_patterns)]
fn enter(&mut self, state: $State, $cx: &mut $crate::gen_statem::Cx<$Ev>) {
let $data = &mut self.data;
match state {
$( $est => { $ebody } ),+
}
}
}
impl $crate::gen_statem::Machine for $sm {
type Ev = $Ev;
fn on_start(&mut self, $cx: &mut $crate::gen_statem::Cx<$Ev>) {
let s = self.state;
self.enter(s, $cx);
}
#[allow(unused_variables)]
#[deny(unreachable_patterns)] // conflicting rows must fail even though
// this match is external-macro-expanded
fn handle(&mut self, ev: $Ev, $cx: &mut $crate::gen_statem::Cx<$Ev>) {
// Caller-named bindings (shared call-site hygiene, so row bodies
// can see them): `$cur` = current state tag, `$data` = &mut Data.
let $cur = self.state;
let $data = &mut self.data;
let next: $crate::gen_statem::Resolution<$State> =
$crate::gen_statem!(@arms ($Ev) ($cur, ev) [ ]
$( on $st => { $($rows)* } )+);
match next {
$crate::gen_statem::Resolution::To(s) if s == $cur => {}
$crate::gen_statem::Resolution::To(s) => {
self.state = s; // <- sole writer of the state cell
self.enter(s, $cx);
}
$crate::gen_statem::Resolution::Postpone => {
unreachable!("postpone is not generated yet")
}
$crate::gen_statem::Resolution::Unhandled => $cx.on_unhandled(),
}
}
}
};
// ===== @arms: build the dispatch match ==================================
// No more on-blocks: emit the (total, catch-all-free) match.
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]) => {
match ($ss, $se) { $($arms)* }
};
// Open an on-block: remember its state pat, drain its rows, then continue.
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]
on $st:pat => { $($rows:tt)* } $($more:tt)*
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se) [ $($arms)* ] ($st)
{ $($rows)* } { $($more)* })
};
// ===== @rows: drain one on-block's rows, threading the global acc ========
// cast, explicit refusal
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ cast $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
($st) { $($rows)* } { $($more)* })
};
// cast, transition / stay / branch
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ cast $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => $crate::gen_statem::Resolution::To($tail.into()), ]
($st) { $($rows)* } { $($more)* })
};
// call, explicit refusal
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ call $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::gen_statem::Resolution::Unhandled, ]
($st) { $($rows)* } { $($more)* })
};
// call, transition / stay / branch
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
{ call $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
) => {
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
[ $($arms)* ($st, $Ev::Call($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)* }
) => {
$crate::gen_statem!(@arms ($Ev) ($ss, $se) [ $($arms)* ] $($more)*)
};
}