A transition whose target is already active (an ancestor of the innermost active state) now unwinds only the subtree strictly below the target. The target's entry/exit actions do not fire and its default() does not re-descend — you cannot re-enter a state you never left. The old behavior could loop infinitely when a child transitioned to its own parent. Self-transitions (target == innermost) and lateral transitions are unchanged. See spec §2.6 and tests §T2.8 / T2.8b / T2.8c. Also in this release: * Full workspace justfile (check, test, coverage, mutants, pre-release) * Unit tests covering all __private internals; `cargo mutants` now exits clean (38 caught, 178 unviable, 0 missed, 0 timeouts). * .config/nextest.toml with slow-timeout/terminate-after so hung tests fail fast rather than wedging cargo-mutants runs. BREAKING CHANGE: transition-to-ancestor semantics. Machines that relied on the old re-entry behavior of up-transitions will observe different exit/entry action sequences.
41 KiB
hsmc — Hierarchical State Machine Crate for Rust
Specification v0.1
1. Overview
hsmc is a Rust crate for defining and running Harel-style hierarchical state machines (statecharts). It provides a statechart! proc macro for declarative machine definition, separating structure from behavior. The crate targets no_std embedded systems through std async runtimes, with near-zero runtime cost by doing as much work as possible at compile time.
1.1 Design Principles
-
Separation of structure and behavior. The statechart macro defines states, transitions, and wiring. Action implementations live in a separate
implblock on a generated trait. A developer can read the statechart and reason about flow without seeing any implementation code. -
No framework leakage. The user's context struct is their data. No framework fields, no marker types, no special derives. The crate wraps the context internally and provides
emit()without polluting the user's type. -
Compile-time maximalism. State enums, transition tables, and structural validation are resolved at compile time. Invalid statecharts (duplicate transitions on the same event, missing default states, terminate outside root) are compile errors, not runtime panics.
-
Runtime portability. The same statechart definition and action implementations work across bare metal (
no_std), Embassy, and Tokio. The runtime is selected via a Cargo feature flag. No code changes required. -
Minimal surface area. The crate provides exactly the primitives from Harel's statecharts that are needed: states, substates, entry/exit actions, event-driven transitions, timer-driven transitions, event-driven actions, timer-driven actions, default initial states, and termination. No guards, no orthogonal regions, no history states, no deferred events.
2. Concepts and Semantics
2.1 Machine
A machine is the root-level container. It is itself a state (the root state) and may contain all the same elements as any other state, plus terminate and default declarations. A machine owns its context, its event queue, its timer state, and its current state configuration.
2.2 States
A state is a named node in the state hierarchy. States may be nested arbitrarily deep. A state may contain:
entry:— one or more action function references, called in declaration order when the state is entered.exit:— one or more action function references, called in declaration order when the state is exited.default(ChildState)— required if and only if the state has child states. Specifies which child state to enter when this state becomes active.state ChildName { ... }— nested child states.transition(trigger) => TargetState;— at most one per trigger per state.action(trigger) => function_name;— zero or more per trigger per state. Multiple actions on the same trigger are called in declaration order.
2.3 State Configuration
At any point in time, the machine is in exactly one "state configuration," which is a path from the root state down through the hierarchy to the innermost active state. The innermost active state may be a state with no declared children, or a composite state that has become the innermost active state via a transition to an already-active ancestor (§2.6) and therefore has no active child. If state A contains state B which contains state C, and C is the innermost active state, then the machine is simultaneously "in" states A, B, and C.
When a state becomes newly active — that is, it was not in the previous configuration — and it has children, the machine automatically descends into the default child, recursively, until it reaches a state with no children or no default. A transition that targets a state which is already active (see §2.6) does NOT trigger default descent, because the target is not newly entered.
2.4 Events
Events are variants of a user-defined Rust enum. They are the primary external input to the machine. Events are delivered to the machine via a channel (async runtimes) or polled queue (bare metal). Action functions may also emit events back into the machine via self.emit().
2.5 Triggers
A trigger is either:
- An event: a variant of the event enum (e.g.,
Failure,ButtonPressed). - A duration: a
core::time::Durationvalue (e.g.,Duration::from_secs(5)). This starts an internal timer when the containing state is entered. The timer fires once. If the state is exited before the timer fires, the timer is cancelled. Descending into a child state is NOT an exit and does NOT cancel the parent's timer.
2.6 Transitions
A transition specifies: when trigger T occurs in state S, move to state Target.
Transitions are classified by the relationship between Target and the innermost active state I at the time the transition fires:
- Up-transition:
Targetis already active — that is,Targetlies on the path from the root down toI, andTarget ≠ I. You cannot enter a state you never left. An up-transition therefore only unwinds the subtree strictly belowTarget. - Self-transition:
Target = I. The innermost active state is exited and re-entered (timers restart; entry/exit actions fire). - Lateral transition:
Targetis not on the active path. Standard LCA semantics apply.
The transition algorithm:
- Classify the transition as up, self, or lateral (see above).
- Compute the effective LCA:
- Up: effective LCA =
Target. - Self: effective LCA = parent of
Target(soTargetis exited and re-entered). - Lateral: effective LCA = LCA(
I,Target).
- Up: effective LCA =
- Exit states from
Iupward, calling exit actions inner-to-outer, stopping before the effective LCA. (The effective LCA is never exited.) - Entry behavior:
- Up: no entry actions fire.
Targetwas already active and remains so; it is now the innermost active state, with no active child. - Self and Lateral: enter states from the effective LCA down to
Target(outer-to-inner), then ifTargethas children, descend into defaults recursively, calling entry actions along the way.
- Up: no entry actions fire.
Transitions may target any state in the entire machine, not just siblings.
Only one transition per trigger per state is allowed. This is enforced at compile time.
2.7 Actions
An action specifies: when trigger T occurs in state S, call function F. No state change occurs. Multiple actions may be registered on the same trigger within the same state. They are called in declaration order.
If both actions and a transition are registered on the same trigger in the same state, the actions are called first (in declaration order), then the transition executes (which triggers exit/entry actions as described above).
2.8 Event Dispatch
When an event is received (or a timer fires), the machine searches for a matching transition or action starting from the innermost active state and walking up toward the root. The first state that has a handler for the trigger processes it. If no state handles the trigger, the event is silently discarded.
Timer triggers do NOT bubble. A timer belongs to the state that declared it and only fires in that state's scope. Event triggers DO bubble up the hierarchy.
2.9 Entry and Exit Actions
- Entry actions are called when a state is entered, in declaration order. Multiple entry actions are allowed per state.
- Exit actions are called when a state is exited, in declaration order. Multiple exit actions are allowed per state.
- Entry and exit actions are called during transitions (as described in §2.6), during initial machine startup (entering the default state path), and during termination (exiting all active states).
2.10 Default State
default(ChildState) specifies the child state to descend into when the containing state is newly entered (either at initial machine startup, or via a lateral/self-transition that enters it fresh). It is required in any state that has children. It is required at the root level. The referenced state must be a direct child of the containing state. This is enforced at compile time.
default does NOT fire when a state becomes the innermost active state via an up-transition (§2.6): the parent was already active, so there is no "new entry" to descend from.
2.11 Terminate
terminate(EventVariant) may only appear at the root level of the machine. It specifies that when the given event is received, the machine shuts down. Shutdown means:
- Exit the innermost active state, calling its exit actions.
- Exit each ancestor state up to and including the root state, calling exit actions in inner-to-outer order.
- The machine is now terminated.
run()returns.is_terminated()returns true.
Terminate is only valid at the root level. Placing terminate inside a child state is a compile error.
2.12 Emitting Events from Actions
Inside any action function, the user may call self.emit(EventVariant) to push an event into the machine's internal queue. Emitted events are processed after the current event's handling is complete (i.e., after all actions and the transition for the current trigger have fully resolved, including all entry/exit actions). This prevents re-entrant state changes.
2.13 Timer Semantics
- A timer is started when its containing state is entered.
- A timer is cancelled when its containing state is exited.
- Entering a child state does NOT cancel the parent's timer.
- Exiting a child state (to a sibling) does NOT cancel the parent's timer.
- Exiting the parent state cancels the timer.
- A timer fires at most once. After firing, it is not restarted unless the state is re-entered.
- If multiple timers expire at the same instant, they are processed in innermost-state-first order.
3. Macro Syntax
3.1 Grammar
statechart! <MachineName> {
context: <ContextType>;
events: <EventType>;
[entry: <fn_name> [, <fn_name>]* ;]*
[exit: <fn_name> [, <fn_name>]* ;]*
[default(<StateName>);]
[terminate(<EventVariant>);]
[transition(<trigger>) => <StateName>;]*
[action(<trigger>) => <fn_name>;]*
[state <StateName> {
[entry: <fn_name> [, <fn_name>]* ;]*
[exit: <fn_name> [, <fn_name>]* ;]*
[default(<ChildStateName>);]
[transition(<trigger>) => <StateName>;]*
[action(<trigger>) => <fn_name>;]*
[state <ChildStateName> { ... }]*
}]*
}
Where <trigger> is one of:
- An event variant name (e.g.,
Failure,ButtonPressed) - A
Durationexpression (e.g.,Duration::from_secs(5),Duration::from_millis(200))
3.2 Entry and Exit Shorthand
Both of these are valid and equivalent:
// Comma-separated on one line
entry: setup, configure, log_start;
// Repeated keyword on separate lines
entry: setup;
entry: configure;
entry: log_start;
// Mixed
entry: setup, configure;
entry: log_start;
All forms result in the functions being called in the order they appear, top to bottom, left to right.
3.3 Declaration Order
Within a state, declarations may appear in any order. The following orderings are all valid:
state Foo {
exit: cleanup;
entry: setup;
transition(SomeEvent) => Bar;
}
However, the relative order of multiple entry:, exit:, or action: declarations IS significant. They are called in declaration order.
4. Generated Code
The statechart! macro generates the following items, where <Name> is the machine name:
4.1 <Name>State Enum
An enum with one variant per state declared in the machine (including nested states). Derives Debug, Clone, Copy, PartialEq, Eq. The exact representation of nested states is an implementation detail but must support comparison and debugging.
4.2 <Name>Actions Trait
A trait with one method per unique action function name referenced anywhere in the statechart (entry, exit, action, across all states). Every method has the signature:
fn <fn_name>(&mut self);
The trait is implemented by the user on <Name>ActionContext<'_>.
4.3 <Name>ActionContext<'a> Struct
A struct that wraps &'a mut <ContextType> and provides:
Deref<Target = ContextType>andDerefMutso thatself.fieldaccesses the user's context directly.fn emit(&mut self, event: <EventType>)to push an event into the machine's internal queue.
The user implements <Name>Actions on this type.
4.4 <Name> Struct (The Machine)
The machine struct, with the following public API:
impl<Name> {
/// Create a new machine instance. Does NOT enter the initial state.
/// Call `run()` or `step()` to start.
fn new(context: ContextType) -> Self;
/// Returns a cloneable sender handle for pushing events
/// into the machine from external code (other tasks, ISRs, threads).
fn sender(&self) -> <Name>Sender;
/// Run the machine to completion (async runtimes).
/// Enters the default state on first call.
/// Returns when the machine terminates.
/// Returns Err if the machine is already terminated.
async fn run(&mut self) -> Result<(), HsmcError>;
/// Process one unit of work: one pending event or one expired timer.
/// For bare metal / synchronous use.
/// `elapsed` is the time since the last call to `step()`.
/// Enters the default state on first call.
/// Returns the duration until the next timer fires, or None.
fn step(&mut self, elapsed: Duration) -> Option<Duration>;
/// Returns true if the machine has terminated.
fn is_terminated(&self) -> bool;
/// Returns the innermost active state (see §2.3). This is typically a
/// leaf, but may be a composite state that became innermost via an
/// up-transition (§2.6).
fn current_state(&self) -> <Name>State;
/// Consumes the machine, returns the user's context.
fn into_context(self) -> ContextType;
}
4.5 <Name>Sender Struct
A cloneable, send-safe handle for pushing events into the machine.
impl<Name>Sender {
/// Push an event. Non-blocking.
/// Returns Err if the queue is full.
fn send(&self, event: EventType) -> Result<(), HsmcError>;
}
5. Runtime Configuration
5.1 Feature Flags
| Feature | Channel Backend | Timer Backend | run() available |
step() available |
|---|---|---|---|---|
| (default) | heapless::spsc::Queue |
manual via step(elapsed) |
No | Yes |
embassy |
embassy_sync::channel::Channel |
embassy_time::Timer |
Yes (async) | Yes |
tokio |
tokio::sync::mpsc |
tokio::time::sleep |
Yes (async) | Yes |
Only one async feature may be enabled at a time. Enabling both embassy and tokio is a compile error.
step() is always available regardless of feature flags, for testing and for mixed-use scenarios.
5.2 Queue Capacity
The default internal event queue capacity is 8. This may be overridden at instantiation:
let mut machine = Stoplight::with_queue_capacity::<16>(context);
6. Error Handling
6.1 HsmcError Enum
#[derive(Debug, Clone, PartialEq, Eq)]
enum HsmcError {
/// The event queue is full. Event was not enqueued.
QueueFull,
/// The machine has already terminated. No further operations are valid.
AlreadyTerminated,
}
6.2 Compile-Time Errors
The macro produces compile errors for:
| Condition | Error Message |
|---|---|
| Duplicate transition on same trigger in same state | "duplicate transition on trigger X in state Y" |
terminate inside a non-root state |
"terminate is only valid at the root level" |
default missing in a state that has children |
"state X has children but no default declaration" |
default referencing a non-direct-child |
"default(Y) in state X: Y is not a direct child of X" |
default in a state that has no children |
"default in state X which has no child states" |
| Action function name used but not in trait impl | Standard Rust trait error: "method X not found" |
| State referenced in transition not found | "unknown state X in transition" |
| Event variant in trigger not found in event enum | "unknown event variant X" |
| Empty state (no entries, exits, transitions, actions, or children) | "empty state X" |
Multiple default declarations in one state |
"multiple default declarations in state X" |
Multiple terminate declarations at root |
"multiple terminate declarations" |
7. Complete Example: Traffic Light
use hsmc::{statechart, Duration};
// --- Events ---
#[derive(Event, Debug, Clone)]
enum StoplightEvent {
Failure,
}
// --- User Context ---
struct StoplightContext {
led_controller: LedController,
}
// --- Statechart Definition ---
statechart! Stoplight {
context: StoplightContext;
events: StoplightEvent;
entry: do_post;
default(Red);
terminate(Failure);
exit: enable_red_flash_mode;
state Red {
entry: red_on;
exit: red_off;
transition(Duration::from_secs(5)) => Green;
}
state Green {
entry: green_on;
exit: green_off;
transition(Duration::from_secs(5)) => Yellow;
}
state Yellow {
entry: yellow_on;
exit: yellow_off;
transition(Duration::from_millis(1000)) => Red;
}
}
// --- Action Implementations ---
impl StoplightActions for StoplightActionContext<'_> {
fn do_post(&mut self) {
assert!(self.led_controller.self_test());
}
fn enable_red_flash_mode(&mut self) {
self.led_controller.flash(Led::Red);
}
fn red_on(&mut self) { self.led_controller.set(Led::Red, true); }
fn red_off(&mut self) { self.led_controller.set(Led::Red, false); }
fn green_on(&mut self) { self.led_controller.set(Led::Green, true); }
fn green_off(&mut self) { self.led_controller.set(Led::Green, false); }
fn yellow_on(&mut self) { self.led_controller.set(Led::Yellow, true); }
fn yellow_off(&mut self){ self.led_controller.set(Led::Yellow, false); }
}
// --- Embassy Usage ---
#[embassy_executor::task]
async fn traffic_light_task() {
let ctx = StoplightContext {
led_controller: LedController::new(),
};
let mut light = Stoplight::new(ctx);
let sender = light.sender();
// Wire up fault detection
spawn(async move {
if fault_detected().await {
sender.send(StoplightEvent::Failure).unwrap();
}
});
light.run().await.unwrap();
}
// --- Bare Metal Usage ---
fn main() -> ! {
let ctx = StoplightContext {
led_controller: LedController::new(),
};
let mut light = Stoplight::new(ctx);
loop {
let elapsed = hw_timer.lap();
light.step(elapsed);
if light.is_terminated() {
enter_safe_mode();
}
}
}
8. Complete Example: Nested States (Parse Test)
This exercises the full grammar.
use hsmc::{statechart, Duration};
#[derive(Event, Debug, Clone)]
enum MyEvent {
Event1,
Event2,
Halt,
}
struct MyContext {
log: Vec<String>,
}
statechart! Name {
context: MyContext;
events: MyEvent;
default(FirstState);
entry: mainentry, mainentry2, mainentry3;
exit: mainexit;
terminate(Halt);
// Root-level transitions and actions
transition(Duration::from_secs(1)) => FirstState;
action(Duration::from_secs_f64(0.99)) => asldkfj;
action(Duration::from_secs_f64(0.99)) => asldkfj1;
action(Duration::from_secs_f64(0.99)) => asldkfj2;
state FirstState {
entry: blah_entry;
exit: blah_exit;
transition(Event2) => SecondState;
action(Event1) => x_action;
state SubState {
default(SubSubState);
state SubSubState {
default(SubSubSubState);
state SubSubSubState {
}
}
}
}
state SecondState {
transition(Event1) => FirstState;
}
state XSecondState {
entry: x_entry;
}
state SomeEndState {
entry: end_entry;
exit: end_exit;
action(Duration::from_secs_f64(0.23)) => something;
}
}
impl NameActions for NameActionContext<'_> {
fn mainentry(&mut self) { self.log.push("mainentry".into()); }
fn mainentry2(&mut self) { self.log.push("mainentry2".into()); }
fn mainentry3(&mut self) { self.log.push("mainentry3".into()); }
fn mainexit(&mut self) { self.log.push("mainexit".into()); }
fn blah_entry(&mut self) { self.log.push("blah_entry".into()); }
fn blah_exit(&mut self) { self.log.push("blah_exit".into()); }
fn x_action(&mut self) { self.log.push("x_action".into()); }
fn asldkfj(&mut self) { self.log.push("asldkfj".into()); }
fn asldkfj1(&mut self) { self.log.push("asldkfj1".into()); }
fn asldkfj2(&mut self) { self.log.push("asldkfj2".into()); }
fn x_entry(&mut self) { self.log.push("x_entry".into()); }
fn end_entry(&mut self) { self.log.push("end_entry".into()); }
fn end_exit(&mut self) { self.log.push("end_exit".into()); }
fn something(&mut self) { self.log.push("something".into()); }
}
9. Test Cases
All test cases use step() with manual time advancement for deterministic testing.
9.1 Initialization
T1.1: Default state entry on first step
Given: A machine with default(Red), root entry: [do_post], Red entry: [red_on]
When: First call to step(Duration::ZERO)
Then: Actions called in order: [do_post, red_on]
current_state() == Red
is_terminated() == false
T1.2: Nested default descent on initialization
Given: A machine with:
default(A)
state A { default(B); state B { default(C); state C {} } }
Root entry: [r_entry], A entry: [a_entry], B entry: [b_entry], C entry: [c_entry]
When: First call to step(Duration::ZERO)
Then: Actions called in order: [r_entry, a_entry, b_entry, c_entry]
current_state() == C
T1.3: Machine not started until first step
Given: A new machine created with Stoplight::new(ctx)
When: No calls to step() or run()
Then: is_terminated() == false
No action functions have been called
9.2 Transitions
T2.1: Event-driven transition between siblings
Given: Machine in state Red, with transition(GoGreen) => Green
Red exit: [red_off], Green entry: [green_on]
When: Event GoGreen is delivered
Then: Actions called in order: [red_off, green_on]
current_state() == Green
T2.2: Timer-driven transition
Given: Machine in state Red, with transition(Duration::from_secs(5)) => Green
When: step() called with cumulative elapsed time reaching 5 seconds
Then: Transition fires. current_state() == Green
T2.3: Timer cancelled on state exit
Given: Machine in state Red, with:
transition(Duration::from_secs(5)) => Green
transition(EarlyExit) => Yellow
When: At t=3s, event EarlyExit is delivered
At t=5s, step() is called
Then: Machine is in Yellow (not Green). The 5s timer was cancelled when Red was exited.
T2.4: Cross-hierarchy transition
Given: Machine with:
state A { state B { state C { transition(Jump) => D } } }
state D {}
Machine is in state C.
When: Event Jump is delivered
Then: Exit order: [C_exit, B_exit, A_exit]
Entry order: [D_entry]
current_state() == D
T2.5: Transition to a state with children descends to default
Given: Machine with:
state A { transition(Go) => B }
state B { default(C); state C { default(D); state D {} } }
Machine is in state A.
When: Event Go is delivered
Then: Entry order: [B_entry, C_entry, D_entry]
current_state() == D
T2.6: Self-transition (transition to own state)
Given: Machine in state Red, with transition(Reset) => Red
Red exit: [red_off], Red entry: [red_on]
When: Event Reset is delivered
Then: Actions called: [red_off, red_on]
current_state() == Red
Any timers in Red are restarted
T2.7: Transition from child to sibling of parent
Given: Machine with:
state A { state B { transition(Go) => C } }
state C {}
Machine is in B.
When: Event Go is delivered
Then: Exit order: [B_exit, A_exit]
Entry order: [C_entry]
current_state() == C
T2.8: Up-transition to an ancestor state
A transition whose target is already active (an ancestor of the innermost active state) only unwinds the subtree strictly below the target. The target itself is not exited and not re-entered. Its default is not followed — it was already active, so there is no "new entry."
Given: Machine with:
state A { default(B); state B { transition(Up) => A } }
Machine is in B.
When: Event Up is delivered
Then: Exit order: [B_exit]
Entry order: []
current_state() == A
T2.8b: Up-transition across multiple levels
Given: Machine with:
state A { default(B); state B { default(C); state C { transition(Up) => A } } }
Machine is in C.
When: Event Up is delivered
Then: Exit order: [C_exit, B_exit]
Entry order: []
current_state() == A
T2.8c: Handler on ancestor, target is a mid-ancestor
When the handler bubbles up to an ancestor, classification is based on the innermost active state at the time the transition fires — not on where the handler was found. If the target is on the active path and not the innermost, it is still an up-transition.
Given: Machine with:
state A {
default(B);
transition(Up) => B; // handler on A, target B
state B { default(C); state C {} }
}
Machine is in C.
When: Event Up is delivered
Then: Exit order: [C_exit]
Entry order: []
current_state() == B
9.3 Actions
T3.1: Action does not change state
Given: Machine in state Red, with action(Beep) => honk
When: Event Beep is delivered
Then: honk() is called
current_state() == Red (unchanged)
T3.2: Multiple actions on same trigger, declaration order
Given: Machine in state Red, with:
action(Beep) => honk1;
action(Beep) => honk2;
action(Beep) => honk3;
When: Event Beep is delivered
Then: Actions called in order: [honk1, honk2, honk3]
T3.3: Actions fire before transition on same trigger
Given: Machine in state Red, with:
action(Go) => log_go;
action(Go) => prepare;
transition(Go) => Green;
Red exit: [red_off], Green entry: [green_on]
When: Event Go is delivered
Then: Actions called in order: [log_go, prepare, red_off, green_on]
current_state() == Green
T3.4: Timer-driven action
Given: Machine in state Red, with:
action(Duration::from_millis(500)) => heartbeat;
When: step() called with cumulative elapsed time reaching 500ms
Then: heartbeat() is called
current_state() == Red (unchanged)
Timer does NOT restart automatically
9.4 Event Dispatch and Bubbling
T4.1: Event handled by current leaf state
Given: Machine in state C (inside B inside A)
C has transition(Foo) => D
When: Event Foo is delivered
Then: Transition fires from C. C handled the event.
T4.2: Event bubbles to parent
Given: Machine in state C (inside B inside A)
C has no handler for Foo
B has no handler for Foo
A has transition(Foo) => D
When: Event Foo is delivered
Then: A handles the event. Exit order: [C_exit, B_exit, A_exit]. Entry: [D_entry].
T4.3: Event bubbles to root
Given: Machine in state C (inside B inside A)
Root has action(Foo) => root_handler
No other state handles Foo
When: Event Foo is delivered
Then: root_handler() is called
T4.4: Leaf state shadows parent handler
Given: Machine in state C (inside A)
C has transition(Foo) => D
A has transition(Foo) => E
When: Event Foo is delivered
Then: C's handler wins. Machine transitions to D, not E.
T4.5: Unhandled event is silently discarded
Given: Machine in state Red. No state handles event Unknown.
When: Event Unknown is delivered
Then: No actions called. No state change. No error.
T4.6: Timer triggers do NOT bubble
Given: Machine in state C (inside B inside A)
A has transition(Duration::from_secs(10)) => D
C has no timer
When: A's timer fires while machine is in C
Then: A's timer handler fires (machine is "in" A). This is NOT bubbling;
A's timer belongs to A and fires because A is active.
9.5 Entry and Exit Ordering
T5.1: Multiple entry actions in declaration order
Given: State A with entry: alpha, beta, gamma;
When: State A is entered
Then: Called in order: [alpha, beta, gamma]
T5.2: Multiple exit actions in declaration order
Given: State A with:
exit: cleanup1;
exit: cleanup2;
When: State A is exited
Then: Called in order: [cleanup1, cleanup2]
T5.3: Entry across hierarchy (outer to inner)
Given: Transition into state C (inside B inside A), all having entry actions
When: Transition fires
Then: Entry order: [A_entry, B_entry, C_entry]
T5.4: Exit across hierarchy (inner to outer)
Given: Machine in state C (inside B inside A), all having exit actions
When: Transition out of the entire subtree fires
Then: Exit order: [C_exit, B_exit, A_exit]
T5.5: Transition between states with a common ancestor
Given: Machine with:
state Parent {
default(A);
state A { transition(Go) => B }
state B {}
}
Machine is in A.
When: Event Go is delivered
Then: Exit: [A_exit] (Parent is NOT exited — it's the LCA)
Entry: [B_entry] (Parent is NOT re-entered)
Parent's timers remain active
9.6 Terminate
T6.1: Terminate event exits all states
Given: Machine in state C (inside B inside A, inside root)
Root has terminate(Halt)
All states have exit actions
When: Event Halt is delivered
Then: Exit order: [C_exit, B_exit, A_exit, root_exit]
is_terminated() == true
T6.2: Terminated machine rejects further events
Given: Machine is terminated
When: sender.send(SomeEvent) is called
Then: Returns Err(HsmcError::QueueFull) or similar
(or the event is silently dropped — TBD based on runtime)
T6.3: run() returns after termination
Given: Machine running via run().await
When: Terminate event is received and processed
Then: run() returns Ok(())
T6.4: step() after termination
Given: Machine is terminated
When: step(elapsed) is called
Then: Returns None. No actions called. No state changes.
9.7 Emit (Events from Actions)
T7.1: Emitted event is processed after current event completes
Given: Machine in state A.
A has action(Foo) => emit_bar, where emit_bar calls self.emit(Bar)
A has transition(Bar) => B
When: Event Foo is delivered
Then: emit_bar() runs first.
After Foo is fully processed, Bar is dequeued.
Transition to B fires.
current_state() == B
T7.2: Emitted events during transition entry/exit
Given: Machine in state A, transition(Go) => B
A exit action calls self.emit(Cleanup)
Root has action(Cleanup) => do_cleanup
When: Event Go is delivered
Then: A_exit runs (emits Cleanup).
B_entry runs.
Transition completes.
Then Cleanup event is processed: do_cleanup() runs.
T7.3: Queue overflow from emit
Given: Machine with queue capacity 8
An action calls self.emit() 9 times
When: The 9th emit() is called
Then: Returns Err(HsmcError::QueueFull) or panics (TBD — define behavior)
9.8 Timer Semantics
T8.1: Timer starts on state entry
Given: State Red with transition(Duration::from_secs(5)) => Green
When: Red is entered at t=0
step() called at t=4.9s
Then: No transition. current_state() == Red
When: step() called at t=5.0s
Then: Transition fires. current_state() == Green
T8.2: Timer cancelled on state exit
Given: State Red with:
transition(Duration::from_secs(5)) => Green
transition(Interrupt) => Yellow
When: Red entered at t=0
Event Interrupt at t=2s -> transitions to Yellow
step() at t=5s
Then: current_state() == Yellow (timer was cancelled, no transition to Green)
T8.3: Parent timer survives child state transitions
Given: State Parent with:
transition(Duration::from_secs(10)) => Done
default(ChildA)
state ChildA { transition(Duration::from_secs(3)) => ChildB }
state ChildB {}
When: Parent entered at t=0 (descends to ChildA)
t=3s: ChildA transitions to ChildB
t=10s: Parent timer fires
Then: At t=3s: current_state() == ChildB, Parent timer still running
At t=10s: Parent timer fires, machine transitions to Done
Exit order: [ChildB_exit, Parent_exit, Done_entry]
T8.4: Timer restarts on re-entry
Given: State Red with transition(Duration::from_secs(5)) => Green
State Green with transition(Duration::from_secs(5)) => Red
When: Red entered at t=0
t=5s: transitions to Green
t=10s: transitions back to Red (timer restarts)
t=14s: step()
Then: current_state() == Red (timer hasn't fired yet, only 4s since re-entry)
When: t=15s: step()
Then: current_state() == Green (timer fires at 5s after re-entry)
T8.5: Multiple timers in different hierarchy levels
Given: State A with:
action(Duration::from_secs(2)) => ping
default(B)
state B { transition(Duration::from_secs(5)) => C }
state C {}
When: A entered at t=0 (descends to B)
t=2s: A's action timer fires
Then: ping() called. current_state() == B (unchanged)
When: t=5s: B's transition timer fires
Then: current_state() == C
A's timer has already fired and does not repeat
9.9 Compile-Time Validation
T9.1: Duplicate transition on same trigger
state Red {
transition(Go) => Green;
transition(Go) => Yellow; // COMPILE ERROR
}
T9.2: Missing default with children
state Parent {
state ChildA {}
state ChildB {}
// No default — COMPILE ERROR
}
T9.3: Terminate in child state
state SomeChild {
terminate(Halt); // COMPILE ERROR
}
T9.4: Default references non-child
state Parent {
default(Cousin); // COMPILE ERROR — Cousin is not a direct child
state Child {}
}
T9.5: Default in childless state
state Leaf {
default(Ghost); // COMPILE ERROR — Leaf has no children
}
T9.6: Empty state
state Empty {} // COMPILE ERROR
T9.7: Duplicate default
state Parent {
default(A);
default(B); // COMPILE ERROR
state A {}
state B {}
}
T9.8: Unknown state in transition target
state A {
transition(Go) => NonExistent; // COMPILE ERROR
}
T9.9: Multiple terminate declarations
statechart! Foo {
terminate(Halt);
terminate(Abort); // COMPILE ERROR
}
9.10 Edge Cases
T10.1: Same action name used in multiple states
Given: States A and B both reference entry: shared_setup
When: A is entered, then later B is entered
Then: shared_setup() is called both times. Single trait method, reused.
T10.2: Machine with no events (timer-only)
#[derive(Event)]
enum NoEvents {} // empty enum
statechart! TimerOnly {
context: Ctx;
events: NoEvents;
default(A);
state A {
transition(Duration::from_secs(1)) => B;
}
state B {
transition(Duration::from_secs(1)) => A;
}
}
// Must compile and run. Machine oscillates between A and B on timers alone.
T10.3: Deeply nested states (5+ levels)
Given: States nested 5 levels deep: A > B > C > D > E
Default chain descends all the way to E
When: Machine starts
Then: Entry order: [root_entry, A_entry, B_entry, C_entry, D_entry, E_entry]
current_state() == E
T10.4: Rapid event emission chain
Given: State A action on Foo emits Bar
State A action on Bar emits Baz
State A transition on Baz => B
When: Event Foo delivered
Then: Foo processed (Bar emitted), then Bar processed (Baz emitted),
then Baz processed (transition to B).
No stack overflow. Events are queued, not recursed.
T10.5: Event emitted during termination exit cascade
Given: Root has terminate(Halt)
State A exit calls self.emit(Cleanup)
When: Halt event received while in A
Then: A_exit runs (emits Cleanup). Root_exit runs.
Machine is terminated.
Cleanup event is NOT processed (machine is terminated).
T10.6: Transition from root level
Given: Root has transition(Duration::from_secs(60)) => FirstState
Machine is in some deep nested state
When: Root timer fires
Then: All nested states exit (inner to outer), then FirstState is entered
via default descent. Root is NOT exited (it's the LCA of itself).
T10.7: into_context() after termination
Given: Machine has terminated
When: into_context() is called
Then: Returns the user's context struct with any mutations made by actions
10. Acceptance Criteria
The crate is considered complete when:
-
All test cases in §9 pass. Every test case must have a corresponding automated test.
-
Compiles with
#![no_std]with default features (noembassy, notokio). -
Zero dynamic allocation with default features. No
alloccrate dependency. Thetokiofeature may use heap allocation for channels. -
All compile-time validations in §9.9 produce clear, actionable error messages pointing to the offending line in the macro invocation.
-
The generated
StoplightActionstrait exactly matches the set of unique action function names in the statechart, with no extra methods and no missing methods. -
Deref/DerefMutonActionContextallows transparent access to all fields of the user's context struct without qualification. -
emit()onActionContextsuccessfully enqueues events that are processed after the current event completes. -
sender()returns a handle that isSend + Clone(when the event type isSend), usable from other threads, tasks, or ISRs. -
Feature flag mutual exclusion. Enabling both
embassyandtokioproduces a compile error. -
step()is deterministic. Given the same sequence of events and elapsed durations, the same sequence of actions is always called in the same order. -
Timer precision. Timers fire on the first
step()call where cumulative elapsed time meets or exceeds the duration. They do not fire early. -
No unsafe code in the public API or generated code (implementation may use unsafe internally if justified and documented).
-
Documentation. Every public type, method, and trait has rustdoc with examples.
11. Out of Scope
The following are explicitly NOT part of this design:
- Guard conditions on transitions
- Orthogonal / parallel regions
- History states (shallow or deep)
- Deferred events
- Internal transitions (transitions that don't trigger exit/re-entry)
- State-local context / per-state data
- Visual statechart rendering or diagram export
- Runtime statechart modification
- Event priority or ordering guarantees beyond FIFO
- Inter-machine communication primitives
These may be considered for future versions but are not part of v0.1.
12. Open Questions
-
emit()overflow behavior. Shouldemit()returnResult<(), HsmcError>(forcing the action to handle it), or panic on overflow (simpler action code, fails loud)? Recommendation: returnResultforno_stdsafety, provide aemit_or_panic()convenience method. -
Empty event enum. Should a timer-only machine require an event enum at all, or should
events:be optional in the macro? Recommendation: make it optional; if omitted, nosender()is generated. -
step()return semantics. Shouldstep()return how many events/timers it processed, or just the next deadline? Recommendation: returnOption<Duration>(time until next timer), plus a separatehas_pending_events()method. -
Action context lifetime. The
Derefapproach means users writeimpl StoplightActions for StoplightActionContext<'_>. Is the lifetime annotation too noisy? Alternative: use a type alias. Recommendation: providetype StoplightCtx<'a> = StoplightActionContext<'a>;as a convenience alias. -
Const Duration expressions in macro.
Duration::from_secs(5)works in const context, butDuration::from_secs_f64(0.23)does not (as of Rust 1.82). Should the macro accept float literals and convert them internally? Recommendation: yes, accept bare float literals like5.0and integer literals like5as seconds, converting toDurationinternally. ReserveDuration::syntax for when the user needs explicit units.