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
+5
View File
@@ -27,6 +27,7 @@ pub mod registry;
pub mod pg;
pub mod link;
pub mod gen_server;
pub mod statem;
pub mod introspect;
#[cfg(feature = "observer")]
pub mod observer;
@@ -57,6 +58,10 @@ pub use gen_server::{
call, cast, shutdown, whereis_server, CallError, CallTimeoutError, CastError, GenServer,
NamedServerBuilder, ServerBuilder, ServerCtx, ServerName, ServerRef, TimerHandle, Watcher,
};
pub use statem::{
CallError as StatemCallError, Cx, Machine, Reply, Resolution, SendError as StatemSendError,
StatemRef,
};
pub use introspect::{
actor_info, snapshot, tree, tree_from, ActorInfo, ActorState, RuntimeSnapshot, RuntimeTree,
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!();
}
}