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.
274 lines
9.9 KiB
Rust
274 lines
9.9 KiB
Rust
//! The hand-written **expansion target** of the `gen_statem!` macro: the same
|
|
//! machine as `examples/gen_statem_macro.rs`, written out in full so the
|
|
//! primitives can be judged standing on their own. The macro generates exactly
|
|
//! this shape; nothing here needs the macro to be correct or safe.
|
|
//!
|
|
//! The design:
|
|
//!
|
|
//! * States and events are real enums. An invalid state is unrepresentable;
|
|
//! there are no bitflags, no `u32` superpositions, no unsafe unions.
|
|
//!
|
|
//! * The dispatch `match (state, event)` IS the transition table. It is
|
|
//! *total* — no catch-all `_` arm — so:
|
|
//! - a forgotten (state, event) pair is a non-exhaustive `match` (E0004),
|
|
//! - a duplicated/conflicting row is `unreachable_patterns` (denied below).
|
|
//!
|
|
//! * Single-target rows name their target in the table; the handler (if any)
|
|
//! is side-effect-only. The table owns the target, so it cannot be wrong.
|
|
//!
|
|
//! * Branching rows have the handler return a per-row *successor enum*. A
|
|
//! target outside that enum is E0599; a missing one is E0004. (See
|
|
//! `UnlockOutcome` and `on_unlock`.)
|
|
//!
|
|
//! * Handlers are module-private. The only way to reach one is through the
|
|
//! actor's message interface via this table, so a handler that is never
|
|
//! wired in is dead code — and dead_code is denied below, making an orphan
|
|
//! handler a compile error.
|
|
//!
|
|
//! * Drops onto the `Machine` / `Resolution` / `Cx` primitives with no change
|
|
//! to `src/gen_statem.rs`.
|
|
//!
|
|
//! Default build is clean and runs. A BREAK-CASE MENU at the bottom documents
|
|
//! how to make each of the four guarantees fire.
|
|
//!
|
|
//! Run: `cargo run --example gen_statem_expanded`
|
|
|
|
#![deny(dead_code, unreachable_patterns)]
|
|
|
|
use smarm::run;
|
|
use smarm::gen_statem::{spawn, Cx, Machine, Reply, Resolution, GenStatemRef};
|
|
|
|
// === user types ============================================================
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
enum Door {
|
|
Open,
|
|
Closed,
|
|
Locked,
|
|
}
|
|
|
|
struct Data {
|
|
enters: u32, // total state entries (incl. initial)
|
|
pushes: u32, // times a push closed the door
|
|
}
|
|
|
|
enum Cast {
|
|
Push,
|
|
Pull,
|
|
Lock,
|
|
Unlock(u32), // carries a key
|
|
}
|
|
|
|
enum Call {
|
|
GetState(Reply<Door>),
|
|
GetEnters(Reply<u32>),
|
|
GetPushes(Reply<u32>),
|
|
}
|
|
|
|
enum Ev {
|
|
Cast(Cast),
|
|
Call(Call),
|
|
// The runtime's internal events. `Info` is out-of-band (here unused, so
|
|
// `()`); `StateTimeout` / `Timeout` are timer fires the loop feeds back in.
|
|
Info(()),
|
|
StateTimeout,
|
|
Timeout(&'static str),
|
|
}
|
|
|
|
const CODE: u32 = 1234;
|
|
|
|
// === per-row successor enum for the one branching row ======================
|
|
// `Locked + Unlock` may end in Closed (right key) or Locked (wrong key) — and
|
|
// nothing else. This type IS that declared set; `on_unlock` cannot name Open.
|
|
enum UnlockOutcome {
|
|
Closed,
|
|
Locked,
|
|
}
|
|
|
|
impl From<UnlockOutcome> for Door {
|
|
fn from(o: UnlockOutcome) -> Door {
|
|
match o {
|
|
UnlockOutcome::Closed => Door::Closed,
|
|
UnlockOutcome::Locked => Door::Locked,
|
|
}
|
|
}
|
|
}
|
|
|
|
// === module-private handlers (side effects / branch choice only) ===========
|
|
// Reachable solely through the table below. Orphan one and it is dead_code.
|
|
|
|
fn on_push(data: &mut Data) {
|
|
data.pushes += 1;
|
|
}
|
|
|
|
fn on_unlock(key: u32) -> UnlockOutcome {
|
|
if key == CODE {
|
|
UnlockOutcome::Closed
|
|
} else {
|
|
UnlockOutcome::Locked // wrong key: caller will see this == current -> stay
|
|
}
|
|
}
|
|
|
|
// === the machine ===========================================================
|
|
|
|
struct DoorSm {
|
|
state: Door,
|
|
data: Data,
|
|
}
|
|
|
|
impl DoorSm {
|
|
fn start(init: Door) -> GenStatemRef<DoorSm> {
|
|
spawn(DoorSm {
|
|
state: init,
|
|
data: Data { enters: 0, pushes: 0 },
|
|
})
|
|
}
|
|
|
|
fn enter(&mut self, cx: &mut Cx<Ev>) {
|
|
self.data.enters += 1;
|
|
// A state-timeout: an Open door auto-closes after a quiet window. The
|
|
// loop auto-resets it on any transition, so it fires only if the door is
|
|
// still Open when it elapses.
|
|
if self.state == Door::Open {
|
|
cx.state_timeout(std::time::Duration::from_millis(5));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Machine for DoorSm {
|
|
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 Cx<Ev>) {
|
|
self.enter(cx);
|
|
}
|
|
|
|
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) {
|
|
let prev = self.state;
|
|
|
|
// ---- the transition table -----------------------------------------
|
|
// This match is total over (Door, Ev). Read it as the declared graph:
|
|
// each `=> To(x)` is an edge, each `=> Unhandled` an explicit refusal.
|
|
let res: Resolution<Door> = match (self.state, ev) {
|
|
// --- Open -------------------------------------------------------
|
|
(Door::Open, Ev::Cast(Cast::Push)) => {
|
|
on_push(&mut self.data);
|
|
Resolution::To(Door::Closed)
|
|
}
|
|
(Door::Open, Ev::Cast(Cast::Pull | Cast::Lock | Cast::Unlock(_))) => {
|
|
Resolution::Unhandled
|
|
}
|
|
|
|
// --- Closed -----------------------------------------------------
|
|
(Door::Closed, Ev::Cast(Cast::Pull)) => Resolution::To(Door::Open),
|
|
(Door::Closed, Ev::Cast(Cast::Lock)) => Resolution::To(Door::Locked),
|
|
(Door::Closed, Ev::Cast(Cast::Push | Cast::Unlock(_))) => Resolution::Unhandled,
|
|
|
|
// --- Locked (branching row: handler picks within UnlockOutcome) -
|
|
(Door::Locked, Ev::Cast(Cast::Unlock(key))) => {
|
|
Resolution::To(on_unlock(key).into())
|
|
}
|
|
(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock)) => {
|
|
Resolution::Unhandled
|
|
}
|
|
|
|
// --- state-independent queries (reply, then stay) ---------------
|
|
(_, Ev::Call(Call::GetState(r))) => {
|
|
r.reply(prev);
|
|
Resolution::To(prev)
|
|
}
|
|
(_, Ev::Call(Call::GetEnters(r))) => {
|
|
r.reply(self.data.enters);
|
|
Resolution::To(prev)
|
|
}
|
|
(_, Ev::Call(Call::GetPushes(r))) => {
|
|
r.reply(self.data.pushes);
|
|
Resolution::To(prev)
|
|
}
|
|
|
|
// --- timeouts: an Open door auto-closes; others have none armed --
|
|
(Door::Open, Ev::StateTimeout) => Resolution::To(Door::Closed),
|
|
(_, Ev::StateTimeout) => Resolution::Unhandled,
|
|
(_, Ev::Timeout(name)) => {
|
|
// No named timeout is armed in this run; a real handler would
|
|
// dispatch on `name`. Acknowledge it to exercise the field.
|
|
let _ = name;
|
|
Resolution::Unhandled
|
|
}
|
|
|
|
// --- out-of-band info: silent drop (the gen_server default) ------
|
|
(_, Ev::Info(_)) => Resolution::Unhandled,
|
|
};
|
|
|
|
// ---- apply the resolution -----------------------------------------
|
|
match res {
|
|
Resolution::To(s) if s == prev => {} // stay: no enter
|
|
Resolution::To(s) => {
|
|
self.state = s; // sole writer of the state cell
|
|
cx.__reset_state_timeout(); // auto-reset across transitions
|
|
self.enter(cx);
|
|
}
|
|
Resolution::Postpone => unreachable!("postpone is not generated yet"),
|
|
Resolution::Unhandled => cx.on_unhandled(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
run(|| {
|
|
let door = DoorSm::start(Door::Closed);
|
|
|
|
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
|
|
door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled
|
|
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay
|
|
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed
|
|
door.send(Ev::Cast(Cast::Push)).unwrap(); // Closed: Push invalid -> Unhandled
|
|
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open (arms 5ms auto-close)
|
|
door.send(Ev::Info(())).unwrap(); // out-of-band: silently dropped
|
|
|
|
// Wait past the auto-close window: the state-timeout fires and the door
|
|
// closes itself, with no further input. (`smarm::sleep` parks the actor
|
|
// without blocking a worker thread, so the timer wheel keeps turning.)
|
|
smarm::sleep(std::time::Duration::from_millis(40));
|
|
|
|
let st = door.call(|r| Ev::Call(Call::GetState(r))).unwrap();
|
|
let enters = door.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
|
let pushes = door.call(|r| Ev::Call(Call::GetPushes(r))).unwrap();
|
|
|
|
println!("state={st:?} enters={enters} pushes={pushes}");
|
|
assert_eq!(st, Door::Closed); // auto-closed by the state-timeout
|
|
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
|
|
assert_eq!(pushes, 0); // no push ever closed it this run
|
|
println!("ok");
|
|
});
|
|
}
|
|
|
|
// ===========================================================================
|
|
// BREAK-CASE MENU — each makes one compile-time guarantee fire.
|
|
//
|
|
// 1. ORPHAN HANDLER (dead_code -> error):
|
|
// add `fn on_slam(_d: &mut Data) {}` and don't reference it.
|
|
// => error: function `on_slam` is never used
|
|
//
|
|
// 2. CONFLICTING ROW (unreachable_patterns -> error):
|
|
// duplicate an arm, e.g. add a second
|
|
// `(Door::Open, Ev::Cast(Cast::Push)) => Resolution::Unhandled,`
|
|
// => error: unreachable pattern
|
|
//
|
|
// 3. MISSING PAIR (non-exhaustive match, E0004):
|
|
// delete the `(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock))`
|
|
// arm.
|
|
// => error[E0004]: non-exhaustive patterns: ... not covered
|
|
//
|
|
// 4. OUT-OF-SET TARGET (E0599):
|
|
// in `on_unlock`, return `UnlockOutcome::Open`.
|
|
// => error[E0599]: no variant ... named `Open` found for enum `UnlockOutcome`
|
|
// ===========================================================================
|