gen_statem: postpone events for replay after a transition

A `=> postpone` row (cast/call/info) defers the current event untouched,
to be replayed after the next real transition. `handle` is now two-phase:
a borrow-only postpone pre-pass that hands the event back as
`Step::Postponed(ev)`, then the existing consuming `match (state, event)`.
The loop owns a FIFO queue, drained in the new state ahead of further
intake; a replayed event may postpone again. A postponed `call` keeps its
Reply, so a later state answers it.

`handle` returns `Step` (Postponed / Transitioned / Stayed) so the loop
can see both deferral and transition without reading the state cell.
`Resolution::Postpone` is removed: postpone is a pre-dispatch routing
decision, not a consuming-dispatch outcome.
This commit is contained in:
smarm-agent
2026-06-20 13:02:15 +00:00
parent acf67fef06
commit 531571bfa5
4 changed files with 439 additions and 66 deletions
+47 -12
View File
@@ -36,7 +36,7 @@
#![deny(dead_code, unreachable_patterns)]
use smarm::run;
use smarm::gen_statem::{spawn, Cx, Machine, Reply, Resolution, GenStatemRef};
use smarm::gen_statem::{spawn, Cx, Machine, Reply, Resolution, Step, GenStatemRef};
// === user types ============================================================
@@ -50,6 +50,7 @@ enum Door {
struct Data {
enters: u32, // total state entries (incl. initial)
pushes: u32, // times a push closed the door
knocks: u32, // knocks answered (a Locked knock is postponed, then counted)
}
enum Cast {
@@ -57,12 +58,14 @@ enum Cast {
Pull,
Lock,
Unlock(u32), // carries a key
Knock, // counted when the door is reachable; postponed while Locked
}
enum Call {
GetState(Reply<Door>),
GetEnters(Reply<u32>),
GetPushes(Reply<u32>),
GetKnocks(Reply<u32>),
}
enum Ev {
@@ -120,7 +123,7 @@ impl DoorSm {
fn start(init: Door) -> GenStatemRef<DoorSm> {
spawn(DoorSm {
state: init,
data: Data { enters: 0, pushes: 0 },
data: Data { enters: 0, pushes: 0, knocks: 0 },
})
}
@@ -150,18 +153,32 @@ impl Machine for DoorSm {
self.enter(cx);
}
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) {
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) -> Step<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.
// ---- phase 1: postpone routing (borrow-only) ----------------------
// A deferred event is handed back untouched for the loop's postpone
// queue; no handler code runs on it. Here: a knock at a locked door.
// (The macro emits this as a `match (state, &ev)` yielding a bool; the
// hand-written form can just test directly.)
if let (Door::Locked, Ev::Cast(Cast::Knock)) = (prev, &ev) {
return Step::Postponed(ev);
}
// ---- phase 2: the consuming transition table ----------------------
// Total over (Door, Ev). Read it as the declared graph: each `=> To(x)`
// is an edge, each `=> Unhandled` an explicit refusal. The postponed
// pair above reappears as `unreachable!` so the match stays total.
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::Knock)) => {
self.data.knocks += 1;
Resolution::To(prev)
}
(Door::Open, Ev::Cast(Cast::Pull | Cast::Lock | Cast::Unlock(_))) => {
Resolution::Unhandled
}
@@ -169,12 +186,20 @@ impl Machine for DoorSm {
// --- 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::Knock)) => {
self.data.knocks += 1;
Resolution::To(prev)
}
(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())
}
// Routed out in phase 1; listed only to keep this match total.
(Door::Locked, Ev::Cast(Cast::Knock)) => {
unreachable!("postponed event is replayed, not dispatched here")
}
(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock)) => {
Resolution::Unhandled
}
@@ -192,6 +217,10 @@ impl Machine for DoorSm {
r.reply(self.data.pushes);
Resolution::To(prev)
}
(_, Ev::Call(Call::GetKnocks(r))) => {
r.reply(self.data.knocks);
Resolution::To(prev)
}
// --- timeouts: an Open door auto-closes; others have none armed --
(Door::Open, Ev::StateTimeout) => Resolution::To(Door::Closed),
@@ -209,14 +238,17 @@ impl Machine for DoorSm {
// ---- apply the resolution -----------------------------------------
match res {
Resolution::To(s) if s == prev => {} // stay: no enter
Resolution::To(s) if s == prev => Step::Stayed, // 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);
Step::Transitioned
}
Resolution::Unhandled => {
cx.on_unhandled();
Step::Stayed
}
Resolution::Postpone => unreachable!("postpone is not generated yet"),
Resolution::Unhandled => cx.on_unhandled(),
}
}
}
@@ -226,9 +258,10 @@ fn main() {
let door = DoorSm::start(Door::Closed);
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
door.send(Ev::Cast(Cast::Knock)).unwrap(); // Locked: postponed (not yet counted)
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::Unlock(0))).unwrap(); // Locked: wrong key -> stay (knock still deferred)
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed; deferred Knock replays here
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
@@ -241,11 +274,13 @@ fn main() {
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();
let knocks = door.call(|r| Ev::Call(Call::GetKnocks(r))).unwrap();
println!("state={st:?} enters={enters} pushes={pushes}");
println!("state={st:?} enters={enters} pushes={pushes} knocks={knocks}");
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
assert_eq!(knocks, 1); // the locked-door knock, replayed once unlocked
println!("ok");
});
}
+16 -4
View File
@@ -33,6 +33,7 @@ enum Door {
struct Data {
enters: u32, // total state entries (incl. initial)
pushes: u32, // times a push closed the door
knocks: u32, // knocks answered (a Locked knock is postponed, then counted)
}
enum Cast {
@@ -40,12 +41,14 @@ enum Cast {
Pull,
Lock,
Unlock(u32), // carries a key
Knock, // counted when the door is reachable; postponed while Locked
}
enum Call {
GetState(Reply<Door>),
GetEnters(Reply<u32>),
GetPushes(Reply<u32>),
GetKnocks(Reply<u32>),
}
const CODE: u32 = 1234;
@@ -100,15 +103,20 @@ gen_statem! {
on Door::Open => {
cast Cast::Push => { on_push(data); Door::Closed },
cast Cast::Knock => { data.knocks += 1; prev },
cast Cast::Pull | Cast::Lock | Cast::Unlock(_) => unhandled,
}
on Door::Closed => {
cast Cast::Pull => Door::Open,
cast Cast::Lock => Door::Locked,
cast Cast::Knock => { data.knocks += 1; prev },
cast Cast::Push | Cast::Unlock(_) => unhandled,
}
on Door::Locked => {
cast Cast::Unlock(key) => on_unlock(key), // branch -> UnlockOutcome
// A knock at a locked door waits: defer it until the door is reachable,
// where the replay counts it.
cast Cast::Knock => postpone,
cast Cast::Push | Cast::Pull | Cast::Lock => unhandled,
}
@@ -117,6 +125,7 @@ gen_statem! {
call Call::GetState(r) => { r.reply(prev); prev },
call Call::GetEnters(r) => { r.reply(data.enters); prev },
call Call::GetPushes(r) => { r.reply(data.pushes); prev },
call Call::GetKnocks(r) => { r.reply(data.knocks); prev },
// This machine arms no timeouts, so refuse them everywhere. (Info has a
// built-in silent-drop default, so it needs no row.)
state_timeout => unhandled,
@@ -126,23 +135,26 @@ gen_statem! {
fn main() {
run(|| {
let door = DoorSm::start(Door::Closed, Data { enters: 0, pushes: 0 });
let door = DoorSm::start(Door::Closed, Data { enters: 0, pushes: 0, knocks: 0 });
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
door.send(Ev::Cast(Cast::Knock)).unwrap(); // Locked: postponed (not yet counted)
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::Unlock(0))).unwrap(); // Locked: wrong key -> stay (knock still deferred)
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed; deferred Knock replays here
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open
door.send(Ev::Cast(Cast::Push)).unwrap(); // Open -> Closed (pushes=1)
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();
let knocks = door.call(|r| Ev::Call(Call::GetKnocks(r))).unwrap();
println!("state={st:?} enters={enters} pushes={pushes}");
println!("state={st:?} enters={enters} pushes={pushes} knocks={knocks}");
assert_eq!(st, Door::Closed);
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
assert_eq!(pushes, 1);
assert_eq!(knocks, 1); // the locked-door knock, replayed once unlocked
println!("ok");
});
}