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
+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)));
}