Files
hsmc/README.md
T
swaits a6d6cd2674 chore(release): 0.3.0
Bump workspace version 0.2.0 → 0.3.0.

CHANGELOG: documents the additions in this release —
  - `journal` Cargo feature with full TraceEvent enum + machine API
  - root state targetable by chart name
  - 113 deterministic-flow tests
  - verification/ workspace member with mechanical Creusot proofs
  - docs/002 formal semantics doc
  - README "How a chart behaves" building-block section
  - mise + just tooling overhaul (set shell, set script-interpreter)

README: integrates the semantics from docs/002 as eight building-
block rules that build on each other (active path, default-descent,
event bubbling, transitions, up-transition rule, entry/exit
ordering, timer lifecycle, emit / during / termination). Adds an
explicit "what hsmc deliberately does NOT have" table with what to
use instead. New "Verification" section pointing at `just verify`.

No behavior changes for v0.1 / v0.2 charts — every existing chart
compiles unchanged.
2026-04-27 21:32:22 -06:00

19 KiB

hsmc — Hierarchical State Machines for Rust

hsmc is a declarative, proc-macro-based statechart crate for Rust. It targets embedded Rust (embassy) as the primary runtime and supports tokio via LocalSet. Machines are compiled from a statechart! block into monomorphized code with no dynamic dispatch, no interior mutability, and no heap allocations by default.

statechart! {
    Radio {
        context: Ctx;
        events:  Ev;
        default(Idle);

        state Idle {
            on(StartRx) => Receiving;
        }
        state Receiving {
            during: next_packet(lora, rx_buf);
            on(PacketRx { rssi: i16, snr: i16 }) => record_packet;
            on(StopRx) => Idle;
        }
    }
}

What it does

  • Harel-style hierarchical states. Nested states with entry:/exit: actions, LCA-aware transitions, default-child descent, terminate.
  • Event-driven transitions and actions. Typed payload bindings: on(PacketRx { rssi: i16 }) => handler;.
  • Timer triggers. on(after Duration::from_secs(5)) => Next; and on(every Duration::from_millis(100)) => tick;.
  • during: activities. Async functions that run while a state is active and produce events. Multiple durings can borrow disjoint fields of the context concurrently via Rust's native split borrow — no RefCell, no Mutex, no UnsafeCell in user code.
  • m.run().await is the whole task body. Races active durings, the external event channel, and timer deadlines. The MCU sleeps between events (embassy's cooperative executor parks all futures).
  • Deterministic + mechanically verified. The journal feature records every action, transition, timer, and event in order — same inputs, byte-identical journal. The runtime queue and timer table carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge mechanically (just verify). 113 byte-equal expected-vs-actual journal tests anchor every spec rule.
  • Backward-compatible. Every v0.1 / v0.2 statechart compiles under v0.3 unchanged.

How a chart behaves — the building blocks

Eight rules, each building on the previous. Internalize these and every edge case below falls out — you don't have to memorize anything else.

The full canonical reference lives at docs/002. hsmc-semantics-formal.md; this section is the on-ramp.

1. The active path — you are in every state from root to leaf

At any moment, the machine is in a path of states from the root down to one innermost state. If you're in C inside B inside A, you're simultaneously in Root, A, B, and C. The root is always active until termination.

This is the most important rule. Every rule below is downstream of it. If a behavior somewhere else seems weird, come back here — it's probably the resolution.

2. Default-descent — composites auto-enter their default child

Every state with children declares one default(...). When a composite state is entered, its entry actions run first, then the default child is entered, recursively, until a leaf.

So Root with default(A) and A with default(B) means: the machine starts by entering Root → A → B, all on first step.

3. Event bubbling — leaf first, first handler wins

An event arrives. Search starts at the innermost active state. If that state has a handler, it runs and the event is consumed. If not, walk up to the parent and check there. Repeat. If you reach the root with no handler, the event is silently discarded.

Three corollaries:

  • Leaf shadows ancestor for the same trigger.
  • Multiple handlers in one state on the same trigger fire in declaration order; the transition (if any) fires last.
  • Timers don't bubble. A timer belongs to the state that declared it; it fires only in that state's scope.

4. Transitions — exit to LCA, enter to target

Any transition's algorithm is uniform:

  1. Find the LCA (lowest common ancestor) of current and target.
  2. Walk up from current, exiting each state, until you hit the LCA. The LCA itself is not exited.
  3. Walk down from the LCA to target, entering each state. The LCA is not re-entered.
  4. If target has children, default-descend (rule 2).

The LCA always exists because root is always an upper bound. State names are globally unique across the chart, so a transition can target any state — siblings, cousins, ancestors, the root itself (on(Trig) => MyChart;).

5. The up-transition rule — never re-enter what you never left

If your transition target is already on the active path (i.e., it's an ancestor of the innermost state), then steps 3 and 4 of the transition algorithm do nothing. You exit the states strictly between current and target; the target itself is not re-entered.

So from leaf C inside B inside A, an on(Up) => A; exits C then B. A's entry actions don't fire. A's default is not followed. A's timers don't restart. A's durings keep running.

You cannot enter a state you never left.

6. Entry / exit ordering

Direction is set by the path:

  • Entries fire outer-to-inner: when entering down through a path, the outermost state's entries fire first, then the next, then the leaf's. Within a single state, entry actions fire in declaration order. After a state's entries finish, its during: activities start. Then default-descent (rule 2) applies recursively.
  • Exits fire inner-to-outer: leaf first, then parent, then grandparent. Within a single state, exit actions fire in declaration order. Before a state's exits begin, its during: activities are cancelled.

So a full transition reads: cancel durings on the way out → run exits on the way out → run entries on the way in → start durings on the way in.

7. Timers — armed by state lifecycle

A timer (on(after D) => … or on(every D) => …) is armed when its declaring state is entered, cancelled when that state is exited. Descending into a child is not an exit, so the parent's timers keep running while you're in a child. Re-entering a state restarts its timers from zero.

8. emit / during / termination — all built on the rules above

  • emit(ev) from inside an action queues an event. The runtime finishes the current event's handling — including every entry/exit in the resulting transition — before dequeuing the new event. No re-entrant dispatch.
  • during: is an async future tied to its state's lifecycle. It starts after the state's entry actions finish and is cancelled (the future is dropped) before the state's exit actions begin. Multiple durings on the active path race; each borrows disjoint &mut fields of the context (Rust's split-borrow checker enforces this at compile time).
  • Termination is just rules 6 + 4 with target = "outside the chart": exit the entire active path inner-to-outer, then stop. Pending events drop. In-flight durings drop at their next .await.

That's the whole behavior model. The full semantics doc spells out edge cases (queue overflow surfacing, same-tick timer ties, self-transitions on composites) but the rules above cover the overwhelming majority of charts.

What hsmc deliberately does NOT have

The bet is on simple, consistent semantics. These features are omitted on purpose; for each, what to do instead:

Missing feature Use instead
Guard conditions on transitions Split source into two states, or have an action emit() a follow-up event for the chart to branch on
Orthogonal / parallel regions Two charts as two tasks connected by channels, or multiple during: activities in one state
History states Store last child explicitly in your context; transition to it on re-entry
Deferred events Handle where they arrive (drop/log if not relevant), or have producer check state
Internal transitions action(Trig) => fn; (no transition) — exactly that
State-local context All state in the root context; durings borrow disjoint fields
Event priorities beyond FIFO Higher-priority events on a separate channel into a separate, faster machine
Runtime statechart modification Design every variation up front as states

Quickstart

Add to Cargo.toml:

[dependencies]
hsmc = { version = "0.3", features = ["embassy"] }  # or ["tokio"]

Minimal timer-only machine:

use hsmc::{statechart, Duration};

pub struct Ctx { pub ticks: u32 }

statechart! {
    Blinker {
        context: Ctx;
        default(On);

        state On {
            entry:  light_on;
            on(after Duration::from_millis(500)) => Off;
        }
        state Off {
            on(after Duration::from_millis(500)) => On;
        }
    }
}

impl BlinkerActions for BlinkerActionContext<'_> {
    async fn light_on(&mut self) { self.ticks += 1; }
}

Drive it under tokio:

let mut m = Blinker::new(Ctx { ticks: 0 });
tokio::task::LocalSet::new().run_until(m.run()).await.unwrap();

Or under embassy:

static CHANNEL: Channel<CriticalSectionRawMutex, __NoEvents, 8> = Channel::new();
// (timer-only machines don't need a channel; omit `events:` and use `Blinker::new(ctx)`.)

#[embassy_executor::task]
async fn blink_task() {
    let mut m = Blinker::new(Ctx { ticks: 0 });
    let _ = m.run().await;
}

during: — async activities scoped to a state

A during: runs while its state is on the active path. It's a plain async function that takes &mut references to specific fields of the root context and returns one event:

state Receiving {
    during: next_packet(lora, rx_buf);
    on(PacketRx { rssi: i16 }) => count_packet;
    on(StopRx) => Idle;
}

async fn next_packet(
    lora:   &mut LoRaDriver,
    rx_buf: &mut [u8; 256],
) -> RadioInput {
    lora.rx(rx_buf).await.into()
}

The macro emits next_packet(&mut ctx.lora, &mut ctx.rx_buf) and Rust's native split-borrow verifies the fields don't overlap with any other concurrent during on the active path. Overlapping fields produce a compile-time error.

Multiple concurrent durings

A state (or any ancestor on its path) may declare more than one during:. The run loop races all of them against the external channel and the next timer deadline via tokio::select! / embassy_futures::select. Each during borrows disjoint fields, so the split borrow is free.

state Configured {
    during: heartbeat(hb_counter);     // active in every Configured substate
    state Receiving {
        during: next_packet(lora, rx_buf);
    }
}

Cancel-safety contract

A during: future will be dropped at any .await point when a handler fires, a timer expires, an external event arrives, or the state transitions. Write durings as cancel-safe state machines: every .await must be a clean resume point where dropping the future leaves the borrowed fields in a valid, re-enterable state.

Anti-pattern:

async fn bad(acc: &mut u32) -> Ev {
    *acc += 1;                                   // commit happens here
    tokio::time::sleep(Duration::from_ms(100)).await;  // ← may be dropped
    *acc += 1;                                   // may never run
    Ev::Tick
}

Preferred:

async fn good(acc: &mut u32) -> Ev {
    tokio::time::sleep(Duration::from_ms(100)).await;
    *acc = acc.wrapping_add(2);                   // all mutations after the .await
    Ev::Tick
}

Starvation in select-drop semantics

When several durings race, the shortest-to-complete wins every iteration and the others are dropped without making progress. This is expected: firmware I/O activities are naturally cancel-safe (a dropped lora.rx() just stops receiving) and the starved ones would typically be heartbeat-like tasks that deliver their events eventually.

If two durings compete for the same "always ready" resource, combine them into a single during that uses select internally.

Generated API per machine

For statechart! Foo { ... } the macro emits:

Item What it is
FooState Enum of every user-declared state.
FooActions Trait with one async fn per unique action name. User implements.
FooActionContext<'a> Wrapper passed to actions. Derefs to the root context. emit().
FooCtx<'a> Type alias for FooActionContext<'a>.
Foo<const QN: usize = 8> The machine itself.
FooSender Cloneable handle for cross-task / ISR event injection.
Foo::<8>::STATE_CHART &'static str ASCII tree of the hierarchy (useful for defmt/docs).

Machine methods

Method Notes
new(ctx) / new(ctx, channel) Construct. Under embassy, pass an &'static Channel.
with_queue_capacity::<N>(ctx,...) Custom event-queue capacity.
async fn run() Drive the machine to completion (async features).
async fn dispatch(ev) Push event + drain to quiescence. Same task injection.
fn step(elapsed) -> Option<Duration> One unit of work (test/polling driver).
fn send(ev) Raw push, non-draining. For tests.
sender() -> Sender Clone handle for other tasks / ISRs.
current_state() The active leaf state.
is_terminated() True after terminate event.
has_pending_events() Queue non-empty.
context() / context_mut() Borrow the root context.
into_context(self) Consume, return ctx.
STATE_CHART Const ASCII diagram.

Feature flags

Feature Effect
(default) no_std, no async runtime. Drive via step(Duration) + send(ev).
tokio Async actions + run(). Targets single-thread via LocalSet.
embassy Async actions + run(). Targets embassy's cooperative executor. no_std.

embassy and tokio are mutually exclusive — enabling both is a compile error.

Migration from v0.1

v0.1 statecharts continue to compile. New features are purely additive:

1. events: is now optional

Previously required; now omit for pure timer-only machines:

statechart! {
    Blinker {
        context: Ctx;
        default(On);
        state On  { on(after Duration::from_millis(500)) => Off; }
        state Off { on(after Duration::from_millis(500)) => On; }
    }
}

2. dispatch(ev).await replaces the push+drain helper

Before (v0.1, in the dogfood firmware):

async fn push<M>(m: &mut M, ev: E) {
    m.send(ev).expect("queue full");
    while m.has_pending_events() {
        m.step(Duration::ZERO).await;
    }
}
push(&mut machine, ev).await;

After (v0.2):

machine.dispatch(ev).await.unwrap();

3. during: replaces the imperative I/O outer loop

Before — a radio task with per-state match and select:

loop {
    match machine.current_state() {
        RadioState::Receiving => {
            match select(rx_once(&mut lora, &cfg, &mut rx_buf),
                         commands.receive()).await {
                Either::First(r) => handle_rx(r, &mut machine, ...).await,
                Either::Second(c) => handle_cmd(c, &mut machine, ...).await,
            }
        }
        _ => { ... }
    }
}

After — the statechart owns the loop:

state Receiving {
    during: next_packet(lora, rx_buf);
    on(PacketRx { rssi: i16, snr: i16 }) => record_packet;
    on(StopRx) => Idle;
}

#[embassy_executor::task]
async fn radio_task(lora: LoRaDriver, ch: &'static RadioChan) {
    let mut m = Radio::new(RadioCtx { lora, ... }, ch);
    m.run().await.unwrap();
}

Examples

Example Purpose
microwave Full-feature pure-event machine: entry/exit, timers, emit, terminate.
microwave_tui TUI variant of microwave.
during_radio during: activities simulating a radio task with RX + TX loops.
embassy_full Embassy feature demonstration.

Run cargo run --example during_radio --features tokio to see the during: pattern end-to-end.

Verification

hsmc ships with a Creusot deductive-verification crate (verification/) that proves the runtime's EventQueue and TimerTable correct against Pearlite specs. Why3 + alt-ergo + z3 discharge every VC mechanically. Run:

just verify

The recipe is self-installing (mise pulls in the pinned nightly, opam, Why3, the SMT solvers, and cargo-creusot) so a fresh clone gets to a green proof in one command. See verification/INVARIANTS.md for the spec-rule → proof mapping.

The journal Cargo feature gives you a deterministic execution trace of every action, transition, timer arm/cancel/fire, queued emit, event delivery, and termination. Same chart + same events = byte-identical journal. 113 byte-equal expected-vs-actual journal tests anchor every spec rule. cargo mutants runs cleanly with 0 missed mutants under --features tokio,journal.

Status

  • v0.1 spec: fully implemented.
  • v0.2: during:, optional events:, dispatch(), STATE_CHART, drain-on-send. Up-transitions no longer exit/re-enter the target.
  • v0.3: journal feature + deterministic trace + replay; root state targetable by chart name; Creusot verification of the runtime data structures; 113-test deterministic-flow suite.
  • Out of scope (deliberate): guard conditions, orthogonal/parallel regions, history states, deferred events, internal transitions, state-local context, event priorities beyond FIFO, runtime statechart modification, multi-threaded tokio. See docs/002. hsmc-semantics-formal.md § "What hsmc deliberately does NOT have" for the rationale and what to use instead.

License

Dual-licensed MIT OR Apache-2.0.