RFC 017 chunk 1: gen_statem primitives (no macro yet)

Runtime support layer for gen_statem, built against existing public API
(channel + scheduler::spawn), sibling to gen_server. No macro: per review,
build the primitives first and hand-write the Switch example to evaluate
whether a statem! macro earns its place before committing to one.

- src/statem.rs: Machine trait (on_start/handle), Resolution<S> with
  From<S>, Cx (on_unhandled), Reply<T> move-only reply handle, StatemRef
  (send/call), spawn + inbox loop. Real time only; Postpone + Cx timeout
  arming are in the type surface but not yet acted on (chunks 2-3).
- examples/statem_switch.rs: the RFC Switch machine hand-written against the
  primitives, tagged USER vs MACRO to mark what a macro would generate.
  Asserts the RFC end-state (flips=1, enters=3).
- tests/statem.rs: call/cast round-trip, enter-on-start/transition-not-stay,
  panicking-handler -> Down.

Reply<T> included (the call helper needs a handle type; keeps the example
true to the RFC surface) but isolated and trivially removable if we drop it.
This commit is contained in:
smarm-agent
2026-06-19 19:52:02 +00:00
parent 0d6fc970a7
commit acc37c5fc9
4 changed files with 565 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
//! A hand-written `gen_statem`, written directly against the chunk-1 primitives
//! in `smarm::statem` — no macro. This is the RFC 017 `Switch` example, and its
//! purpose is to be the **evaluation artifact**: it shows exactly the shape the
//! deferred `statem!` macro would have to generate, so we can judge what the
//! macro actually buys before committing to building one.
//!
//! Run with: `cargo run --example statem_switch`
//!
//! Sections are tagged // USER (you'd hand-write this with or without a macro)
//! and // MACRO (the boilerplate a `statem!` would emit for you).
use smarm::run;
use smarm::statem::{self, Cx, Machine, Reply, Resolution, StatemRef};
// ---- USER: the four hand-written types ------------------------------------
// In the macro world these are unchanged — the macro never generates them.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Switch {
Off,
On,
}
struct Counts {
flips: u32,
enters: u32,
}
enum SwitchCast {
Flip,
}
enum SwitchCall {
GetCount(Reply<u32>),
GetEnters(Reply<u32>),
}
// ---- MACRO: the unified event ---------------------------------------------
// The user's two message enums folded together. Chunk 1 has no internal
// variants yet; chunks 23 add `StateTimeout` / `Timeout(name)` here.
enum Ev {
Cast(SwitchCast),
Call(SwitchCall),
}
// ---- MACRO: the machine struct + state cell -------------------------------
// `state` is the private cell; the `handle` body below is its sole writer.
struct SwitchSm {
state: Switch,
data: Counts,
}
impl SwitchSm {
// MACRO: `Switch::start` in the RFC; spawns the actor, hands back a ref.
fn start(init: Switch, data: Counts) -> StatemRef<SwitchSm> {
statem::spawn(SwitchSm { state: init, data })
}
// MACRO: the `enter` dispatch, assembled from the per-state `enter` arms.
// USER wrote the arm bodies (`data.enters += 1`); the macro wrote the match
// and the `()` return. `enter` runs side effects only — it cannot transition.
fn enter(&mut self, s: Switch, _cx: &mut Cx<Ev>) {
match s {
Switch::Off => self.data.enters += 1,
Switch::On => self.data.enters += 1,
}
}
}
impl Machine for SwitchSm {
type Ev = Ev;
// MACRO: run the initial state's enter.
fn on_start(&mut self, cx: &mut Cx<Ev>) {
let s = self.state;
self.enter(s, cx);
}
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) {
let prev = self.state;
// MACRO: the `(state, event)` dispatch table. USER wrote each arm tail
// (the body + the ending state tag); the macro qualified bare tags
// (`On` -> `Switch::On`), wrapped them in `.into()`, assembled the match,
// and — in the real macro — would have edge-checked each tail tag
// against the `transitions { Off => On, On => Off }` table at expand
// time. Here that check is on the honour system.
let next: Resolution<Switch> = match (self.state, ev) {
(Switch::Off, Ev::Cast(SwitchCast::Flip)) => {
self.data.flips += 1;
Switch::On.into()
}
(Switch::On, Ev::Cast(SwitchCast::Flip)) => Switch::Off.into(),
(Switch::Off, Ev::Call(SwitchCall::GetCount(r))) => {
r.reply(self.data.flips);
Switch::Off.into() // stay = return the current tag
}
(Switch::On, Ev::Call(SwitchCall::GetCount(r))) => {
r.reply(self.data.flips);
Switch::On.into()
}
(Switch::Off, Ev::Call(SwitchCall::GetEnters(r))) => {
r.reply(self.data.enters);
Switch::Off.into()
}
(Switch::On, Ev::Call(SwitchCall::GetEnters(r))) => {
r.reply(self.data.enters);
Switch::On.into()
}
// The macro would emit `_ => Resolution::Unhandled` here for a
// machine whose table is non-exhaustive; this one covers every
// (state, event) pair, so an added arm would be unreachable.
};
// MACRO: the resolution dispatch — identical in every generated machine.
match next {
Resolution::To(s) if s == prev => {} // stay: no enter, no reset
Resolution::To(s) => {
self.state = s; // <- sole writer of the state cell
// chunk 2: self.timers.clear_state_timeout();
self.enter(s, cx);
// chunk 3: cx.replay(&mut self.postponed);
}
Resolution::Postpone => {} // chunk 3
Resolution::Unhandled => cx.on_unhandled(),
}
}
}
fn main() {
run(|| {
// USER: the client side. With a macro these would be `sw.cast(..)` /
// `sw.call(SwitchCall::GetCount)`; without it, the `Ev::Cast` / `Ev::Call`
// wrap is explicit — note how mechanical it is.
let sw = SwitchSm::start(Switch::Off, Counts { flips: 0, enters: 0 });
sw.send(Ev::Cast(SwitchCast::Flip)).unwrap(); // Off -> On
sw.send(Ev::Cast(SwitchCast::Flip)).unwrap(); // On -> Off
let flips = sw.call(|r| Ev::Call(SwitchCall::GetCount(r))).unwrap();
let enters = sw.call(|r| Ev::Call(SwitchCall::GetEnters(r))).unwrap();
// RFC's stated end state after two flips: Counts { flips: 1, enters: 3 }
// (initial Off entry + On entry + Off entry).
println!("after two flips: flips={flips}, enters={enters}");
assert_eq!(flips, 1, "turned On once across the two flips");
assert_eq!(enters, 3, "initial Off + On + Off entries");
println!("ok");
});
}
+5
View File
@@ -27,6 +27,7 @@ pub mod registry;
pub mod pg; pub mod pg;
pub mod link; pub mod link;
pub mod gen_server; pub mod gen_server;
pub mod statem;
pub mod introspect; pub mod introspect;
#[cfg(feature = "observer")] #[cfg(feature = "observer")]
pub mod observer; pub mod observer;
@@ -57,6 +58,10 @@ pub use gen_server::{
call, cast, shutdown, whereis_server, CallError, CallTimeoutError, CastError, GenServer, call, cast, shutdown, whereis_server, CallError, CallTimeoutError, CastError, GenServer,
NamedServerBuilder, ServerBuilder, ServerCtx, ServerName, ServerRef, TimerHandle, Watcher, NamedServerBuilder, ServerBuilder, ServerCtx, ServerName, ServerRef, TimerHandle, Watcher,
}; };
pub use statem::{
CallError as StatemCallError, Cx, Machine, Reply, Resolution, SendError as StatemSendError,
StatemRef,
};
pub use introspect::{ pub use introspect::{
actor_info, snapshot, tree, tree_from, ActorInfo, ActorState, RuntimeSnapshot, RuntimeTree, actor_info, snapshot, tree, tree_from, ActorInfo, ActorState, RuntimeSnapshot, RuntimeTree,
TreeNode, SNAPSHOT_FORMAT_VERSION, TreeNode, SNAPSHOT_FORMAT_VERSION,
+266
View File
@@ -0,0 +1,266 @@
//! gen_statem — generic finite state machine behaviour (RFC 017).
//!
//! 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**, routes event handling by it, and (in later
//! chunks) adds the machinery state machines need — state-entry callbacks, a
//! timeout taxonomy, and event postponement.
//!
//! ## What this layer is
//!
//! This module is the **runtime support** a state machine runs on, *not* the
//! authoring surface. 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 RFC's `statem!` macro (deferred) would *generate* a `Machine` impl from
//! a declarative `transitions { … }` graph plus per-state handler blocks, and
//! add the expansion-time edge-lint. Until then a machine is hand-written
//! against these primitives; see `examples/statem_switch.rs` for the shape the
//! macro would target.
//!
//! ## 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 with the runtime's own internal events:
//!
//! ```ignore
//! enum Ev { Cast(MyCast), Call(MyCall) /* later: StateTimeout, Timeout(name) */ }
//! ```
//!
//! [`StatemRef::send`] pushes any event (a cast is just a `send`);
//! [`StatemRef::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 (or, later, postpone) it.
//!
//! ## Chunk status (RFC 017 §Sequencing)
//!
//! This is **chunk 1**: macro-free dispatch against real time — spawn,
//! `on_start`, stay/transition, and the `enter` callback (a method on the
//! machine, run on entry to a state). [`Resolution::Postpone`] and the timeout
//! arming on [`Cx`] are part of the type surface but are not yet acted on; they
//! land in chunks 23.
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::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>` (RFC §Semantics).
///
/// [`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()` (chunk 3); 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 (RFC §"Non-state outcomes live on cx").
///
/// In chunk 1 it carries only the [`on_unhandled`](Self::on_unhandled) hook;
/// `cx.state_timeout(d)` / `cx.timeout(name, d)` (chunk 2) and `cx.postpone()`
/// (chunk 3) attach here as those chunks land. 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 (RFC Open Q5); 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 [`StatemRef::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 (RFC §Postpone).
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 [`StatemRef::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 [`StatemRef::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 `StatemRef` is dropped, at which
/// point its inbox closes and the loop exits.
pub struct StatemRef<M: Machine> {
tx: Sender<M::Ev>,
pid: Pid,
}
impl<M: Machine> Clone for StatemRef<M> {
fn clone(&self) -> Self {
StatemRef { tx: self.tx.clone(), pid: self.pid }
}
}
impl<M: Machine> StatemRef<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 [`StatemRef`]. 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) -> StatemRef<M> {
let (tx, rx) = channel::<M::Ev>();
let handle = spawn_actor(move || statem_loop(rx, machine));
StatemRef { 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!();
}
}
+142
View File
@@ -0,0 +1,142 @@
//! gen_statem (RFC 017) chunk-1 tests: call/cast round-trip against a
//! hand-written machine, `enter` firing on start and on every real transition
//! (but not on a stay), and the machine-down path when a handler panics.
//!
//! These drive the `smarm::statem` primitives directly, the same way a
//! `statem!`-generated machine eventually will.
use smarm::run;
use smarm::statem::{self, CallError, Cx, Machine, Reply, Resolution, StatemRef};
use std::sync::{Arc, Mutex};
// ---------------------------------------------------------------------------
// A hand-written two-state machine: Flip toggles, calls read, Boom panics.
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Switch {
Off,
On,
}
struct Counts {
flips: u32,
enters: u32,
}
enum Cast {
Flip,
}
enum Call {
GetFlips(Reply<u32>),
GetEnters(Reply<u32>),
Boom(Reply<u32>),
}
enum Ev {
Cast(Cast),
Call(Call),
}
struct Sm {
state: Switch,
data: Counts,
}
impl Sm {
fn start(init: Switch) -> StatemRef<Sm> {
statem::spawn(Sm { state: init, data: Counts { flips: 0, enters: 0 } })
}
fn enter(&mut self, _s: Switch, _cx: &mut Cx<Ev>) {
self.data.enters += 1;
}
}
impl Machine for Sm {
type Ev = Ev;
fn on_start(&mut self, cx: &mut Cx<Ev>) {
let s = self.state;
self.enter(s, cx);
}
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) {
let prev = self.state;
let next: Resolution<Switch> = match (self.state, ev) {
(Switch::Off, Ev::Cast(Cast::Flip)) => {
self.data.flips += 1;
Switch::On.into()
}
(Switch::On, Ev::Cast(Cast::Flip)) => Switch::Off.into(),
(s, Ev::Call(Call::GetFlips(r))) => {
r.reply(self.data.flips);
s.into() // stay
}
(s, Ev::Call(Call::GetEnters(r))) => {
r.reply(self.data.enters);
s.into() // stay
}
(_, Ev::Call(Call::Boom(_r))) => panic!("boom"),
};
match next {
Resolution::To(s) if s == prev => {}
Resolution::To(s) => {
self.state = s;
self.enter(s, cx);
}
Resolution::Postpone => {}
Resolution::Unhandled => cx.on_unhandled(),
}
}
}
// Casts are applied in order and a later call observes the accumulated data.
#[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);
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On
sw.send(Ev::Cast(Cast::Flip)).unwrap(); // On -> Off
let flips = sw.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
*got2.lock().unwrap() = flips;
});
assert_eq!(*got.lock().unwrap(), 1, "turned On once across the two flips");
}
// `enter` fires once on start and once per *real* transition; a stay (a call
// that returns the current tag) does not re-enter.
#[test]
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); // on_start enter -> enters = 1
let after_start = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
// Two stays (the reads above + below) 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 -> enters = 2
let after_flip = sw.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
*got2.lock().unwrap() = (after_start, still, after_flip);
});
assert_eq!(*got.lock().unwrap(), (1, 1, 2));
}
// A handler that panics tears the loop down; the in-flight call's reply channel
// closes as the stack unwinds, so the parked caller wakes with Down (mirrors
// gen_server's panicking-handler path).
#[test]
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);
let r = sw.call(|rep| Ev::Call(Call::Boom(rep)));
*got2.lock().unwrap() = Some(r);
});
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::Down)));
}