4 Commits
Author SHA1 Message Date
swaitsandClaude Opus 4.7 2e55484080 chore(release): 0.4.1
Bump workspace version 0.4.0 → 0.4.1.

Patch release: dispatch hot-path performance only. No public API
changes; every chart that compiled against 0.4.0 compiles unchanged
and behaves identically. CHANGELOG.md captures the per-pass writeup
under the 0.4.1 heading.

Headlines (deepest dispatch in the bench suite):
  h8_cross_tree     -82% instructions / -72% cycles  vs pre_lca_fix
  bubble_top        -22% instructions /  -6% cycles  vs handler walk
  terminate_event   -22% instructions / -23% cycles  vs PARENT walk

The runtime LCA helper, is_up_transition, the per-call heapless::Vec
ancestry build, the runtime PARENT bubble walk, and (on timer-free
charts) the entire TimerTable machinery are all gone. Callgrind
reports the entire HSM machinery as a single inlined `step` symbol.

Bench suite expanded from 7 to 13 benches: bubble_leaf/mid/top
quantify handler resolution at varying ancestor depths,
timer_fire_dispatch covers the only path that exercises pop_expired's
non-empty branch, terminate_event covers do_terminate, and
bubble_cross_tree covers the realistic HSM worst case (deep bubble +
deep cross-tree transition).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:11:29 -06:00
swaitsandClaude Opus 4.7 dc97419161 perf: precompute transition/dispatch/terminate paths; shrink TimerEntry to u64
Six codegen-side optimization passes against step() and friends. No
user-facing API changes; all 296 tests pass under no-default, tokio,
embassy, and tokio+journal. Cumulative on h8_cross_tree (deepest
dispatch in the suite): -82% instructions / -72% cycles vs the
pre_lca_fix baseline.

* __PATH_DATA + __PATH_RANGE: per-(from, to) precomputed exit/enter
  sequences. Up/self/lateral semantics fold into one indexed lookup;
  removes lca() and is_up_transition() runtime helpers and the
  per-call heapless::Vec<u16, 16> ancestry build. Hierarchy depth no
  longer bounded by Vec capacity.

* __DISPATCH[(state, event)]: precomputed handler resolution. The
  runtime __PARENT bubble walk + per-hop __handler_lookup call in
  step() collapses to one indexed lookup. Bubble resolution is now
  O(1) regardless of depth -- bubble_top (4-hop) is within 3
  instructions of bubble_leaf (handler on current).
  __handler_lookup retained for timer dispatch.

* __TERMINATE_DATA: per-state precomputed exit chain (root inclusive
  -- chart-root entry/exit actions are valid). do_terminate() indexes
  the slice instead of walking __PARENT, and uses Deque::clear()
  (O(1) in heapless 0.8) instead of pop_front-loop. terminate_event
  bench: -22.5% instr / -23.4% cycles.

* TimerTable empty-skip: pop_expired / min_remaining / decrement /
  cancel_state each get inlined is_empty() early-out with the
  non-empty body marked #[cold].

* Codegen-static timer-machinery elide: when chart has no Duration
  triggers, every TimerTable call is omitted from step() and
  exit_state(). step() returns None directly. dispatch_trigger and
  the timer-fired arms remain but are unreachable; LLVM DCEs them.

* TimerEntry.remaining_ns: u128 -> u64 (~584-year ceiling). Halves
  entry size; replaces u128 saturating-sub with u64. The smaller
  machine struct improves cache density on every bench, even
  timer-free ones.

Bench coverage: four new benches added to cover paths the original
suite missed -- bubble_leaf/mid/top (handler resolution at varying
ancestor depths), timer_fire_dispatch (only bench exercising
pop_expired's non-empty branch and dispatch_trigger), terminate_event
(only bench exercising do_terminate), bubble_cross_tree (deep bubble
+ 10-state cross-tree, the realistic HSM worst case).

See hsmc/benches/baseline.txt for the per-pass delta blocks and
docs/003. latency-analysis.md sections 1, 9, and 12 for the
cost-surface analysis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:51:21 -06:00
swaitsandClaude Opus 4.7 e633f5dc55 perf: O(H) lca() depth-walk + dispatch bench scoreboard
The runtime `lca()` helper used a `heapless::Vec<u16,32>` ancestry-set
with a linear `contains()` per node — O(H²) and capped at H ≤ 32. The
codegened `__DEPTH` table already had everything needed for an O(H)
depth-walk with no allocation and no hierarchy ceiling. Switch to that.

Also adds an iai-callgrind dispatch bench harness (Linux-only dev-dep)
so future optimizations can be measured deterministically by instruction
count. Seven benches cover: cold start, lateral H=2/4/8, up, self, and a
self-emit chain. Baseline numbers checked in at `hsmc/benches/baseline.txt`.

The latency analysis (`docs/003. latency-analysis.md`) is rounded out
with event-entry-point and cooperative-driving audits — three new
sections documenting the four event injection paths, the run-loop's
cooperative shape, and three correctness invariants (sticky overflow
flag, biased-select trap, cancel-safety on durings).

Bench deltas (cargo bench --bench dispatch -- --baseline=pre_lca_fix):

  h2_cold_start        -1.9%  /  -0.3%   (priming step, no lca() — noise)
  h2_warm_lateral     -22.8%  / -22.1%
  h4_cross_tree       -25.9%  / -10.6%
  h4_up                +1.0%  /  +0.4%   (up-transition, no lca() — noise)
  h4_self             -21.5%  / -11.7%
  h8_cross_tree       -46.2%  / -41.0%   ← headline: deepest LCA stress
  chain_self_emit     -15.4%  / -10.4%

Improvement scales with hierarchy depth, exactly as the O(H²) → O(H)
algorithmic change would predict.

All tests pass under --no-default-features, --features tokio, and
--features embassy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:58:53 -06:00
swaits 4ea7463089 feat(observe)!: unify journal and trace into single observation pipeline
The codegen now emits one `__chart_observe!` invocation per observable
atom. The macro fans out at compile time to whichever sinks are enabled:
the in-memory journal (`feature = "journal"`), `defmt` (`trace-defmt`),
`log` (`trace-log`), and/or `tracing` (`trace-tracing`). Trace and
journal share the same vocabulary by construction — they cannot
diverge. With no sink feature on, every site expands to `()` (zero
overhead). Multiple trace backends now compose; previously they were
mutually exclusive.

Trace lines use a logfmt-style format with a `[statechart:Name]` prefix
and `key=value` pairs, machine-parseable and greppable. Twenty verbs
cover every observable atom — entries/exits with begin/end pairs,
actions with kind, durings, transitions with the reason that triggered
them, event lifecycle (received/delivered/dropped/queued/failed),
timers, and termination.

BREAKING CHANGES (chart syntax unchanged; observability surface only):

- `TraceEvent::TransitionFired` now carries `reason: TransitionReason`
  showing whether the transition was driven by an event, a timer, or
  internal logic.
- New `TraceEvent` variants for phase pairing: `EnterBegan`, `ExitBegan`,
  `TransitionComplete`, `EventReceived`.
- Trace prefix changed from `[Name]` to `[statechart:Name]`.
- The chart-level `trace;` keyword is parsed for backwards compatibility
  but no longer gates emission.
- `__chart_trace!` / `__chart_journal!` macros removed (doc-hidden).

Verification: 159 integration tests pass, 0 missed mutants, miri 68
tests clean, Creusot 14/14 VCs discharged. Zero overhead confirmed via
`cargo expand` — every observation site expands to `{}` with no sinks
enabled.
2026-04-28 15:24:14 -06:00
24 changed files with 4126 additions and 682 deletions

No files matched your search

+173
View File
@@ -4,6 +4,179 @@ All notable changes to this workspace are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/). this project adheres to [Semantic Versioning](https://semver.org/).
## 0.4.1 — 2026-04-28
### Performance — dispatch hot path
Six codegen-side optimization passes against `step()` and friends. No
user-facing API changes; all 296 tests pass under `--no-default-features`,
`--features tokio`, `--features embassy`, and `--features tokio,journal`.
Cumulative on `h8_cross_tree` (the deepest dispatch in the bench suite):
**−82% instructions / −72% cycles** vs the pre-`pre_lca_fix` baseline.
See `hsmc/benches/baseline.txt` for per-pass delta blocks and
`docs/003. latency-analysis.md` §1/§9/§12 for the cost-surface analysis.
- **Precomputed transition paths.** For every `(from, to)` state pair,
codegen materializes the exit/enter sequence into a single flat
`__PATH_DATA` blob with a `__PATH_RANGE: [(u16, u16, u16); N²]`
index. `transition()` becomes one indexed range lookup + two slice
loops; the `lca()` runtime helper, `is_up_transition()`, and the
per-call `heapless::Vec<u16, 16>` ancestry build are gone. Up /
self / lateral semantics are baked into the table (empty enter
slice ⇒ up-transition, target excluded; non-empty enter slice
includes the default-descent tail). Hierarchy depth is no longer
bounded by the old 32-state Vec capacity.
- **Precomputed event-dispatch table.** For every `(state, event)`
pair, codegen resolves the handler the runtime `__PARENT` bubble
walk would have found. `__DISPATCH: [(u16, &'static [u16],
Option<u16>); N×E]` stores `(handler_state, action_ids,
transition_target)` per pair; `u16::MAX` in the handler-state slot
is the no-handler sentinel. The runtime walk + per-hop
`__handler_lookup` call in `step()` collapses to one indexed lookup.
Handler resolution is now O(1) regardless of bubble depth — the
`bubble_top` bench (4-hop walk) is now within 3 instructions of
`bubble_leaf` (handler on current state). `__handler_lookup` is
retained for timer dispatch (the `dispatch_trigger` path doesn't
bubble).
- **Precomputed terminate exit path.** Per-state `__TERMINATE_DATA`
table holds the full exit chain up to and including `__Root` (which
may carry root-level `entry:`/`exit:` actions). `do_terminate()`
indexes the table once and iterates the slice, replacing the
runtime `__PARENT` walk + per-iteration match. Queue drain switched
from `while pop_front().is_some()` to `Deque::clear()` (heapless
0.8 implements this as O(1) — resets indices instead of popping
each entry). `terminate_event` bench: −22.5% instr / −23.4% cycles.
- **TimerTable empty-skip.** `pop_expired`, `min_remaining`,
`decrement`, and `cancel_state` each get `#[inline]` + an
`is_empty()` early-out, with the non-empty body split into a
`#[cold]` helper. Runtime branch on charts with timers; the body
stays out-of-line.
- **Codegen-static timer-machinery elide.** When a chart declares no
`Duration` triggers (the common case for event-driven HSMs), every
`TimerTable` call is omitted from `step()` and `exit_state()` at
codegen. `step()` returns `None` directly for the next-tick
deadline. `dispatch_trigger()` and the timer-fired arms remain in
the binary but are unreachable; LLVM DCEs them.
- **`TimerEntry.remaining_ns: u128 → u64`.** Internal-only field type
change. u64 nanoseconds bounds at ~584 years — comfortably past any
realistic `Duration`. Eliminates u128 saturating-sub in
`decrement()` (4–6 instructions per entry → 1–2) and the manual
secs/nanos decomposition in `min_remaining()` (replaced with
`Duration::from_nanos`). The struct shrinks 32 B → 16 B with
alignment, so the entire machine struct gets smaller — every bench's
RAM-hit count drops from tighter cache packing.
### Bench coverage
Four new benches added to cover hot paths the original suite missed:
- **`bubble_leaf` / `bubble_mid` / `bubble_top`** — handler resolution
at varying ancestor depths (0 / 3 / 4 hops) on a single chart.
Quantifies the bubble-walk cost as a function of distance, and
confirms the `__DISPATCH` table makes resolution depth-independent.
- **`timer_fire_dispatch`** — only bench that exercises
`pop_expired`'s non-empty branch and `dispatch_trigger`. The most
expensive single-step bench in the suite (191 instr / 706 cycles).
- **`terminate_event`** — only bench exercising `__check_terminate`'s
true arm and `do_terminate`. 69 instr / 311 cycles.
- **`bubble_cross_tree`** — handler on L1 (4 bubble hops up from
current=L5) targets R5 in a sibling subtree (10-state cross-tree
transition). Realistic HSM worst case; 94 instr / 444 cycles —
almost the same as a plain 7-deep cross-tree because both bubble
resolution and transition path are now O(1) lookups.
## 0.4.0 — 2026-04-28
### Changed — observability (breaking)
- **Unified observation pipeline.** The codegen now emits a single
`__chart_observe!` invocation per observable atom, dispatched at
compile time to whichever sinks are enabled: the in-memory journal
(`feature = "journal"`), `defmt` (`trace-defmt`), `log`
(`trace-log`), and/or `tracing` (`trace-tracing`). All sinks share
the same vocabulary by construction — the journal IS the observation
stream; trace backends are textual renderings of it. With NO sink
feature on, every call site expands to `()` (zero overhead). Multiple
trace sinks may now be enabled simultaneously (previously they were
mutually exclusive).
- **Trace prefix changed.** `[ChartName] verb …` →
`[statechart:ChartName] verb …`. The new form is greppable and
unambiguous in mixed log streams.
- **Trace format is now logfmt-style.** Every line is
`[statechart:Name] <verb> key=value key=value …` (or native structured
fields under `tracing`). Twenty verbs cover every TraceEvent variant
— entries, exits (with begin/end pairs), actions (with kind),
durings, transitions (with **reason** showing what triggered them),
events (received/delivered/dropped/queued/failed), timers, terminate.
- **`trace;` keyword deprecated.** Still parsed for backwards
compatibility, but no longer gates emission. Trace output is now
controlled purely by which `trace-*` cargo feature you enable.
### Changed — journal (breaking)
- **`TraceEvent::TransitionFired` carries `reason: TransitionReason`.**
Replay consumers now see whether a transition was driven by an event
(`Event { event: u16 }`), a timer (`Timer { state, timer }`), or
internal logic (`Internal`).
- **New TraceEvent variants for phase begin/end pairing.**
- `EnterBegan { state }` fires before a state's entry actions /
timers / durings; `Entered` keeps its place as the end marker.
- `ExitBegan { state }` fires before a state's during cancellations
/ timer cancellations / exit actions; `Exited` keeps its place as
the end marker.
- `TransitionComplete { from, to }` fires after a transition's full
exit-then-enter sequence finishes; pairs with `TransitionFired`.
- `EventReceived { event }` fires when an event is popped from the
queue, before handler search; pairs with `EventDelivered` /
`EventDropped` / `TerminateRequested`.
### Added
- **`hsmc/tests/trace_format.rs`** — 11 tests pinning every verb's
exact textual rendering, including a journal/trace atom-count
equivalence test.
- **`TransitionReason`** enum — re-exported from the crate root when
`feature = "journal"` is on. Carried on `TraceEvent::TransitionFired`.
- **Multiple trace backends compose.** Previously enabling more than
one of `trace-defmt` / `trace-log` / `trace-tracing` was a compile
error. Now they fan out independently.
### Migration
Chart syntax is unchanged: every v0.1 / v0.2 / v0.3 statechart compiles
under v0.4 without modification. Breakage is confined to the
observability surface area:
1. **Trace line prefix.** Update grep patterns and log filters from
`[ChartName]` to `[statechart:ChartName]`. The verb after the
prefix is now a single hyphenated token followed by `key=value`
pairs.
2. **`TraceEvent::TransitionFired`** has a new `reason` field. Update
pattern matches:
```rust
// before:
TraceEvent::TransitionFired { from, to } => …
// after:
TraceEvent::TransitionFired { from, to, reason } => …
// or, if you don't care:
TraceEvent::TransitionFired { from, to, .. } => …
```
3. **New `TraceEvent` variants.** If you `match` exhaustively, add
arms for `EnterBegan`, `ExitBegan`, `TransitionComplete`,
`EventReceived`. Otherwise nothing changes.
4. **`trace;` keyword is now a no-op.** Charts that declared it still
compile. Trace output is now controlled purely by which `trace-*`
cargo feature you enable.
5. **`__chart_trace!` and `__chart_journal!` macros are removed.**
These were doc-hidden and not part of the public API; mentioned
here for codebases that grepped through the macro emission for
patches/forks.
## 0.3.0 — 2026-04-27 ## 0.3.0 — 2026-04-27
### Added ### Added
Generated
+86 -3
View File
@@ -14,6 +14,15 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -195,6 +204,27 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]] [[package]]
name = "document-features" name = "document-features"
version = "0.2.12" version = "0.2.12"
@@ -465,7 +495,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hsmc" name = "hsmc"
version = "0.3.0" version = "0.4.1"
dependencies = [ dependencies = [
"crossterm", "crossterm",
"defmt 0.3.100", "defmt 0.3.100",
@@ -475,6 +505,7 @@ dependencies = [
"futures", "futures",
"heapless 0.8.0", "heapless 0.8.0",
"hsmc-macros", "hsmc-macros",
"iai-callgrind",
"log", "log",
"ratatui", "ratatui",
"tokio", "tokio",
@@ -484,7 +515,7 @@ dependencies = [
[[package]] [[package]]
name = "hsmc-macros" name = "hsmc-macros"
version = "0.3.0" version = "0.4.1"
dependencies = [ dependencies = [
"hsmc", "hsmc",
"proc-macro2", "proc-macro2",
@@ -495,12 +526,48 @@ dependencies = [
[[package]] [[package]]
name = "hsmc-verification" name = "hsmc-verification"
version = "0.3.0" version = "0.4.1"
dependencies = [ dependencies = [
"creusot-std", "creusot-std",
"hsmc", "hsmc",
] ]
[[package]]
name = "iai-callgrind"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e4910d3a9137442723dfb772c32dc10674c4181ca078d2fd227cd5dce9db0"
dependencies = [
"bincode",
"derive_more",
"iai-callgrind-macros",
"iai-callgrind-runner",
]
[[package]]
name = "iai-callgrind-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d03775318d3f9f01b39ac6612b01464006dc397a654a89dd57df2fd34fb68c3"
dependencies = [
"derive_more",
"proc-macro-error2",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn",
]
[[package]]
name = "iai-callgrind-runner"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b74c9743c00c3bca4aaffc69c87cae56837796cd362438daf354a3f785788c68"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
@@ -773,6 +840,15 @@ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
] ]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "0.38.44" version = "0.38.44"
@@ -804,6 +880,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -811,6 +893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive",
] ]
[[package]] [[package]]
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["hsmc", "hsmc-macros", "verification"] members = ["hsmc", "hsmc-macros", "verification"]
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.4.1"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
+195 -56
View File
@@ -40,14 +40,22 @@ statechart! {
- **`m.run().await` is the whole task body.** Races active durings, the - **`m.run().await` is the whole task body.** Races active durings, the
external event channel, and timer deadlines. The MCU sleeps between external event channel, and timer deadlines. The MCU sleeps between
events (embassy's cooperative executor parks all futures). events (embassy's cooperative executor parks all futures).
- **Unified observability.** One observation per atom of execution
fans out at compile time to a journal (`Vec<TraceEvent>`) and/or
textual trace backends (`defmt`, `log`, `tracing`) — they share a
single vocabulary, so they cannot diverge. With no sink feature on,
every site expands to `()` (zero overhead). See
[Observability](#observability--one-journal-multiple-outputs).
- **Deterministic + mechanically verified.** The `journal` feature - **Deterministic + mechanically verified.** The `journal` feature
records every action, transition, timer, and event in order — same records every action, transition, timer, and event in order — same
inputs, byte-identical journal. The runtime queue and timer table inputs, byte-identical journal. The runtime queue and timer table
carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge
mechanically (`just verify`). 113 byte-equal expected-vs-actual mechanically (`just verify`). 159 tests pin every spec rule
journal tests anchor every spec rule. (journal sequences + trace-format regression + integration).
- **Backward-compatible.** Every v0.1 / v0.2 statechart compiles under - **Backward-compatible chart syntax.** Every v0.1 / v0.2 / v0.3
v0.3 unchanged. statechart compiles under v0.4 unchanged. The 0.4 breaks are in
observability surface area only — see
[Migration](#migration-from-v03).
## How a chart behaves — the building blocks ## How a chart behaves — the building blocks
@@ -86,6 +94,7 @@ walk up to the parent and check there. Repeat. If you reach the root
with no handler, the event is silently discarded. with no handler, the event is silently discarded.
Three corollaries: Three corollaries:
- Leaf shadows ancestor for the same trigger. - Leaf shadows ancestor for the same trigger.
- Multiple handlers in one state on the same trigger fire in - Multiple handlers in one state on the same trigger fire in
declaration order; the transition (if any) fires last. declaration order; the transition (if any) fires last.
@@ -143,7 +152,7 @@ in.
A timer (`on(after D) => …` or `on(every D) => …`) is **armed** when A timer (`on(after D) => …` or `on(every D) => …`) is **armed** when
its declaring state is entered, **cancelled** when that state is its declaring state is entered, **cancelled** when that state is
exited. Descending into a child is *not* an exit, so the parent's 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 timers keep running while you're in a child. Re-entering a state
restarts its timers from zero. restarts its timers from zero.
@@ -174,16 +183,16 @@ overwhelming majority of charts.
The bet is on **simple, consistent semantics**. These features are The bet is on **simple, consistent semantics**. These features are
omitted on purpose; for each, what to do instead: omitted on purpose; for each, what to do instead:
| Missing feature | Use 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 | | 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 | | 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 | | 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 | | 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 | | Internal transitions | `action(Trig) => fn;` (no transition) — exactly that |
| State-local context | All state in the root context; durings borrow disjoint fields | | 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 | | 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 | | Runtime statechart modification | Design every variation up front as states |
## Quickstart ## Quickstart
@@ -191,7 +200,7 @@ Add to `Cargo.toml`:
```toml ```toml
[dependencies] [dependencies]
hsmc = { version = "0.3", features = ["embassy"] } # or ["tokio"] hsmc = { version = "0.4", features = ["embassy"] } # or ["tokio"]
``` ```
Minimal timer-only machine: Minimal timer-only machine:
@@ -327,45 +336,100 @@ into a single during that uses `select` internally.
For `statechart! Foo { ... }` the macro emits: For `statechart! Foo { ... }` the macro emits:
| Item | What it is | | Item | What it is |
|---------------------------------|----------------------------------------------------------------------| | -------------------------- | --------------------------------------------------------------------- |
| `FooState` | Enum of every user-declared state. | | `FooState` | Enum of every user-declared state. |
| `FooActions` | Trait with one async fn per unique action name. User implements. | | `FooActions` | Trait with one async fn per unique action name. User implements. |
| `FooActionContext<'a>` | Wrapper passed to actions. `Deref`s to the root context. `emit()`. | | `FooActionContext<'a>` | Wrapper passed to actions. `Deref`s to the root context. `emit()`. |
| `FooCtx<'a>` | Type alias for `FooActionContext<'a>`. | | `FooCtx<'a>` | Type alias for `FooActionContext<'a>`. |
| `Foo<const QN: usize = 8>` | The machine itself. | | `Foo<const QN: usize = 8>` | The machine itself. |
| `FooSender` | Cloneable handle for cross-task / ISR event injection. | | `FooSender` | Cloneable handle for cross-task / ISR event injection. |
| `Foo::<8>::STATE_CHART` | `&'static str` ASCII tree of the hierarchy (useful for `defmt`/docs).| | `Foo::<8>::STATE_CHART` | `&'static str` ASCII tree of the hierarchy (useful for `defmt`/docs). |
### Machine methods ### Machine methods
| Method | Notes | | Method | Notes |
|-------------------------------------|------------------------------------------------------------------| | -------------------------------------- | ------------------------------------------------------ |
| `new(ctx)` / `new(ctx, channel)` | Construct. Under embassy, pass an `&'static Channel`. | | `new(ctx)` / `new(ctx, channel)` | Construct. Under embassy, pass an `&'static Channel`. |
| `with_queue_capacity::<N>(ctx,...)` | Custom event-queue capacity. | | `with_queue_capacity::<N>(ctx,...)` | Custom event-queue capacity. |
| `async fn run()` | Drive the machine to completion (async features). | | `async fn run()` | Drive the machine to completion (async features). |
| `async fn dispatch(ev)` | Push event + drain to quiescence. Same task injection. | | `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 step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). |
| `fn send(ev)` | Raw push, non-draining. For tests. | | `fn send(ev)` | Raw push, non-draining. For tests. |
| `sender() -> Sender` | Clone handle for other tasks / ISRs. | | `sender() -> Sender` | Clone handle for other tasks / ISRs. |
| `current_state()` | The active leaf state. | | `current_state()` | The active leaf state. |
| `is_terminated()` | True after `terminate` event. | | `is_terminated()` | True after `terminate` event. |
| `has_pending_events()` | Queue non-empty. | | `has_pending_events()` | Queue non-empty. |
| `context()` / `context_mut()` | Borrow the root context. | | `context()` / `context_mut()` | Borrow the root context. |
| `into_context(self)` | Consume, return ctx. | | `into_context(self)` | Consume, return ctx. |
| `STATE_CHART` | Const ASCII diagram. | | `STATE_CHART` | Const ASCII diagram. |
## Feature flags ## Feature flags
| Feature | Effect | | Feature | Effect |
|-----------|-------------------------------------------------------------------------| | --------- | -------------------------------------------------------------------------- |
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. | | (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
| `tokio` | Async actions + `run()`. Targets single-thread via `LocalSet`. | | `tokio` | Async actions + `run()`. Targets single-thread via `LocalSet`. |
| `embassy` | Async actions + `run()`. Targets embassy's cooperative executor. `no_std`.| | `embassy` | Async actions + `run()`. Targets embassy's cooperative executor. `no_std`. |
`embassy` and `tokio` are mutually exclusive — enabling both is a compile `embassy` and `tokio` are mutually exclusive — enabling both is a compile
error. error.
## Migration from v0.3
The 0.4 breaks are in the observability surface area only; chart
syntax and runtime API are unchanged.
### 1. Trace prefix changed
Trace lines (when `trace-defmt` / `trace-log` / `trace-tracing` is
enabled) now use `[statechart:Name]` instead of `[Name]`. Update any
log filters or grep patterns. The verb after the prefix is now a
single hyphenated token followed by `key=value` pairs (logfmt).
### 2. `TraceEvent::TransitionFired` carries `reason`
Add `reason` to any pattern matches:
```rust
// before:
TraceEvent::TransitionFired { from, to } => …
// after:
TraceEvent::TransitionFired { from, to, reason } => …
// or, if you don't need it:
TraceEvent::TransitionFired { from, to, .. } => …
```
The new field carries `TransitionReason::Event { event }`,
`Timer { state, timer }`, or `Internal` so consumers can see what
triggered the transition.
### 3. New `TraceEvent` variants
If you `match` exhaustively on `TraceEvent`, add arms for the new
variants. Otherwise nothing changes:
- `EnterBegan { state }` — phase begin marker pairs with `Entered`.
- `ExitBegan { state }` — phase begin marker pairs with `Exited`.
- `TransitionComplete { from, to }` — pairs with `TransitionFired`.
- `EventReceived { event }` — fires when an event is popped from the
queue, before handler search.
These are pure observation — no runtime semantic change.
### 4. `trace;` keyword is a no-op
Charts that declared `trace;` continue to compile. The keyword no
longer gates emission — observation is now controlled purely by
which `trace-*` cargo feature you enable. You can leave `trace;` in
or remove it; both work.
### 5. Trace backends are no longer mutually exclusive
Previously, enabling more than one of `trace-defmt` / `trace-log` /
`trace-tracing` was a compile error. Now they fan out independently:
turn on whichever you want.
## Migration from v0.1 ## Migration from v0.1
v0.1 statecharts continue to compile. New features are purely additive: v0.1 statecharts continue to compile. New features are purely additive:
@@ -442,12 +506,12 @@ async fn radio_task(lora: LoRaDriver, ch: &'static RadioChan) {
## Examples ## Examples
| Example | Purpose | | Example | Purpose |
|--------------------|---------------------------------------------------------------------| | --------------- | --------------------------------------------------------------------- |
| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate.| | `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate. |
| `microwave_tui` | TUI variant of microwave. | | `microwave_tui` | TUI variant of microwave. |
| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. | | `during_radio` | `during:` activities simulating a radio task with RX + TX loops. |
| `embassy_full` | Embassy feature demonstration. | | `embassy_full` | Embassy feature demonstration. |
Run `cargo run --example during_radio --features tokio` to see the Run `cargo run --example during_radio --features tokio` to see the
`during:` pattern end-to-end. `during:` pattern end-to-end.
@@ -469,12 +533,81 @@ gets to a green proof in one command. See
[`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the [`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the
spec-rule → proof mapping. spec-rule → proof mapping.
The `journal` Cargo feature gives you a deterministic execution Beyond Creusot, the suite includes:
trace of every action, transition, timer arm/cancel/fire, queued
emit, event delivery, and termination. Same chart + same events = - **159 integration tests** — full chart behavior, byte-equal expected-
byte-identical journal. 113 byte-equal expected-vs-actual journal vs-actual journals for ~113 deterministic-flow tests, plus 11 trace-
tests anchor every spec rule. `cargo mutants` runs cleanly with format regression tests pinning every observation verb's exact
0 missed mutants under `--features tokio,journal`. textual rendering.
- **0 missed mutants** under `cargo mutants --features
tokio,journal,trace-log`.
- **Miri**: 68 tests, no UB.
## Observability — one journal, multiple outputs
Every chart emits a single observation per atom of execution. That
observation fans out to whichever sinks you compile in:
| Feature | Sink |
| --------------- | -------------------------------------------------------- |
| `journal` | In-memory `Vec<TraceEvent>` (replay, byte-deterministic) |
| `trace-defmt` | `defmt::*` log lines |
| `trace-log` | `log::*` log lines |
| `trace-tracing` | `tracing::*` events with structured fields |
With **no** sink feature on, observation is fully elided at compile
time — zero overhead. Multiple sinks can be enabled simultaneously;
the journal IS the observation stream and trace backends are textual
renderings of it, so they cannot diverge.
The textual format is logfmt-style — greppable and regex-parseable:
```
[statechart:Microwave] started chart_hash=0xa1b2c3d4
[statechart:Microwave] entering state=Idle
[statechart:Microwave] action state=Idle action=clear_display kind=entry
[statechart:Microwave] timer-armed state=Idle timer=t0 duration_ns=5000000000
[statechart:Microwave] during-started state=Idle during=poll_sensor
[statechart:Microwave] entered state=Idle
[statechart:Microwave] event-received name=StartButton
[statechart:Microwave] event-delivered name=StartButton handler=Idle
[statechart:Microwave] transition-begin from=Idle to=Heating reason=event:StartButton
[statechart:Microwave] exiting state=Idle
[statechart:Microwave] timer-cancelled state=Idle timer=t0
[statechart:Microwave] during-cancelled state=Idle during=poll_sensor
[statechart:Microwave] action state=Idle action=stop_motor kind=exit
[statechart:Microwave] exited state=Idle
[statechart:Microwave] entering state=Heating
[statechart:Microwave] entered state=Heating
[statechart:Microwave] transition-complete from=Idle to=Heating
```
### The verb vocabulary
Twenty verbs cover every `TraceEvent` variant. Begin/end markers
bracket entry, exit, and transition phases so the actions, timers,
and durings running inside each phase are visible.
| Verb | Fires when |
|---|---|
| `started` | First step of a chart's life — carries `chart_hash` |
| `entering` / `entered` | Begin / end of a state's entry phase |
| `exiting` / `exited` | Begin / end of a state's exit phase |
| `action` | An action fn was called — `kind=entry\|exit\|handler` |
| `during-started` / `during-cancelled` | A `during:` activity started / was dropped |
| `transition-begin` / `transition-complete` | Begin / end of a transition's exit-then-enter sequence |
| `event-received` | An event was popped from the queue |
| `event-delivered` | A handler ran for the event |
| `event-dropped` | The event reached root with no handler |
| `emit-queued` / `emit-failed` | `emit()` from inside an action — queued or rejected |
| `timer-armed` / `timer-cancelled` / `timer-fired` | Timer lifecycle |
| `terminate-requested` | `terminate(...)` event matched, exit chain about to run |
| `terminated` | Chart finished (always last) |
The `reason=` field on `transition-begin` records what triggered the
transition: an event (`event:VariantName`), a timer
(`timer:State/timer-id`), or `internal`. Real-life captures can be
diffed against expected behavior the same way the journal can.
## Status ## Status
@@ -484,6 +617,12 @@ tests anchor every spec rule. `cargo mutants` runs cleanly with
- v0.3: `journal` feature + deterministic trace + replay; root - v0.3: `journal` feature + deterministic trace + replay; root
state targetable by chart name; Creusot verification of the state targetable by chart name; Creusot verification of the
runtime data structures; 113-test deterministic-flow suite. runtime data structures; 113-test deterministic-flow suite.
- v0.4: unified observation pipeline (journal + trace share one
vocabulary, fan out to multiple sinks at compile time); logfmt-
style trace format with `[statechart:Name]` prefix; transition
reasons; entry/exit/transition begin/end markers; multiple trace
backends now compose; chart-level `trace;` keyword deprecated.
Breaking on the `TraceEvent` enum shape; chart syntax unchanged.
- Out of scope (deliberate): guard conditions, orthogonal/parallel - Out of scope (deliberate): guard conditions, orthogonal/parallel
regions, history states, deferred events, internal transitions, regions, history states, deferred events, internal transitions,
state-local context, event priorities beyond FIFO, runtime state-local context, event priorities beyond FIFO, runtime
+10
View File
@@ -217,6 +217,16 @@ So a full transition reads top-to-bottom as: cancel durings on the way
out → run exits on the way out → run entries on the way in → start out → run exits on the way out → run entries on the way in → start
durings on the way in. durings on the way in.
Observers (the journal and any active trace backend) see this as
phase **begin** and **end** markers bracketing the work: an
`EnterBegan { state }` fires before any of that state's entry actions /
timer arms / during starts, and an `Entered { state }` fires after
all of them finish. The same for `ExitBegan` / `Exited`, and
`TransitionFired` / `TransitionComplete` bracket the whole exit-then-
enter sequence of a transition. This is purely a visibility change —
the operational rules above are unchanged — but it lets you tell from
a captured trace which atoms ran inside which phase.
--- ---
## `emit()` — sending events from inside an action ## `emit()` — sending events from inside an action
+500
View File
@@ -0,0 +1,500 @@
# 003. Latency analysis: hsmc hot-path cost surface
This is an audit of where wall-clock and code-size cost lives in `hsmc`'s
event-dispatch path. It is intended to inform mitigation work for embedded
and embassy-rs use cases where every microsecond of dispatch overhead
couples directly into hardware-control loop tightness and jitter.
The audit is **analytical**, with three categories of evidence:
1. **Source reading** of `hsmc/src/lib.rs`, `hsmc/src/journal.rs`, and
`hsmc-macros/src/codegen.rs`.
2. **`cargo expand --release`** of two test programs (one with no
observation features, one with `tokio + journal`) to confirm what the
macro really emits.
3. **`cargo rustc --release --emit=asm`** spot-checks of the compiled
output to confirm LLVM-level claims (jump-table lowering, dead-code
elimination of unused name tables, async-closure code size).
The audit deliberately stops short of recommending mitigations. A ranked
menu of candidates is included at the end so a follow-up planning round
can pick from it; nothing here is a commitment.
---
## 1. Cost surface, ranked by per-event impact
| # | Source | Class | Per-event cost (estimate) | Location |
|----|-----------------------------------------------|-----------------------------|------------------------------------------------------------------------------------------------------------|---------------------------------------|
| # | Source | Class | Per-event cost (estimate) | Status |
|----|-----------------------------------------------|-----------------------------|------------------------------------------------------------------------------------------------------------|---------------------------------------|
| 1 | `dispatch().await` loop | Discretionary, async-only | 2 awaits cold-start / 1 await steady-state per dispatched event. ~free if action body never `Pending`-yields. | open |
| 2 | LCA + transition path build | Discretionary | ~~O(H²) `heapless::Vec<u16,32>::contains` scan~~ → ~~O(H) depth-walk~~ → **O(1) `__PATH_RANGE` lookup**. Up/self/lateral folded into the precomputed table; `lca()`, `is_up_transition()`, and the per-call `heapless::Vec<u16,16>` ancestry build are all gone. | resolved |
| 3 | Parent walk for handler search | Discretionary | ~~O(H) `__PARENT` walk + per-hop `__handler_lookup` call~~ → **O(1) `__DISPATCH[(state, event)]` lookup**. Every (state, event) pair statically resolved at codegen. `__handler_lookup` retained for timer dispatch only. | resolved |
| 4 | `__handler_lookup` nested match (timer path) | Intrinsic | Still used for timer dispatch. LLVM lowers to jump tables (verified `LJTI*`). ~1 indirect jump per call. | open (already at floor) |
| 5 | `run_action` dispatch | Intrinsic | One match arm + monomorphized trait call + 1 vtable hop on `&mut dyn QueuePush` (verified devirtualized when callsite type is known). | open (already at floor) |
| 6 | Event queue push/pop | Intrinsic | `heapless::Deque::push_back` / `pop_front`, both O(1). | open (already at floor) |
| 7 | `__chart_observe!` macro arms | Compile-eliminable when off | Zero residue when all sink features off. Each call site collapses to `{};`. | verified zero-cost |
| 8 | Journal `Vec::push` per atom | Conditional on `journal` | Amortized O(1); ~16 B per `TraceEvent`. ~11 push sites fire per transition. | opt-in |
| 9 | Async-fn future size (binary cost) | Discretionary, async-only | ~3000 asm lines of chart machinery for a 5-state chart; dominated by async-fn state machines. | open (mitigation H3) |
| 10 | `tokio::sync::mpsc::unbounded_channel` | Tokio-only, std-bound | Heap-backed channel for cross-task event injection. Embassy uses bounded `embassy_sync::channel`. | feature-specific |
| 11 | TimerTable per-step / per-exit fixed cost | Discretionary | ~~Unconditional `pop_expired` + `cancel_state` calls every step / exit~~ → **inlined `is_empty()` + `#[cold]` non-empty body** (runtime branch on charts that use timers; compile-time elided on timer-free charts). | resolved |
| 12 | TimerEntry size + per-decrement work | Discretionary | ~~`remaining_ns: u128`, 32-byte entry, u128 saturating-sub per decrement~~ → **`u64`, 16-byte entry, u64 saturating-sub** (~584-year ceiling; Duration::from_nanos round-trips exactly). | resolved |
| 13 | `do_terminate()` exit walk | Discretionary | ~~Runtime `__PARENT` walk + `pop_front`-loop queue drain~~ → **precomputed `__TERMINATE_DATA` slice + `Deque::clear()`** (O(1)). | resolved |
Items 2, 3, 11, 12, 13 are fully resolved by the precompute / inline-
empty-check passes in this round of work — see `hsmc/benches/baseline.txt`
for the per-pass delta blocks. Items 4–6 are at or near the algorithmic
floor for hierarchical state machines. Item 7 is verified zero-cost.
Items 1 (async overhead) and 9 (async machinery binary size) are the
only meaningful open items; both are addressable via a `dispatch_sync()`
fast path (mitigation H3 in §9), which would be a user-facing API
change and is therefore out-of-scope for this round.
---
## 2. Per-event cost model (analytical)
For a chart with hierarchy depth `H=3`, one entry action + one exit action,
a simple lateral transition, no observation features, `cargo build
--release`, on a Cortex-M4-class core (~168 MHz):
| Stage | Cycles (rough) | Notes |
|----------------------------------------|--------------------|------------------------------------------------|
| `queue.pop_front` | ~5–10 | `heapless::Deque` index op |
| `__event_to_id` | ~3–5 | match arm (jump table) |
| `__DISPATCH[(state, event)]` lookup | ~5 | one indexed load + sentinel branch |
| `run_action` (1 action) | ~10–20 | match + monomorphized call + 1 vtable hop |
| Path lookup (`__PATH_RANGE`) | ~5 | one indexed load + slice arithmetic |
| Exit chain (H states × A actions) | ~3 × H + 30 × A | per-state work is now ~3 instr each |
| Enter chain (H states × A actions) | ~3 × H + 30 × A | per-state work is now ~3 instr each |
| Timer machinery (timer-free chart) | 0 | codegen-elided when `ir.duration_triggers.is_empty()` |
| Timer machinery (timer chart, no fire) | ~3 | inlined `is_empty()` branches × 3 |
| Timer machinery (timer firing) | ~30–40 | decrement + pop_expired + dispatch_trigger |
| **Total `.step()` steady-state** | ~0.4 – 0.8 µs | for H=3, A=1; user action body excluded |
| Async overhead (`.dispatch()` loop) | per-await checkpoint | ~free if no `Pending`-yield |
These are back-of-envelope numbers; actual measurements would tighten
them by ~2× in either direction, depending on inlining decisions.
For empirical numbers (deterministic, instruction-counted), see
`hsmc/benches/baseline.txt`. Headline numbers (current):
h2_warm_lateral 84 instr / 397 cycles; h4_cross_tree 101 instr / 456
cycles; h8_cross_tree 93 instr / 443 cycles; bubble_top 99 instr /
453 cycles (4-hop handler resolution); bubble_cross_tree 94 instr /
444 cycles (4-hop bubble + 10-state cross-tree); terminate_event 69
instr / 311 cycles; timer_fire_dispatch 191 instr / 706 cycles;
chain_self_emit 419 instr across 4 events (≈105 instr/event).
Cumulative on h8_cross_tree: **-82% instructions / -72% cycles** vs
the original O(H²) LCA scan.
---
## 3. Spot-check evidence
### 3.1 `__chart_observe!` is zero-cost when off (cargo expand)
Test: `tests/state_chart_const.rs`, with no observation features enabled.
In the expanded source (`/tmp/hsmc_expand_no_features.rs`, 911 lines), every
observation site collapses to an empty block:
```rust
// in `step()`:
if self.current.is_none() {
{}; // would be observe_started
self.enter_path(0);
return self.timers.min_remaining();
}
// ...
if let Some((state, trig)) = self.timers.pop_expired(&__DEPTH) {
let __t_state = state;
let __t_trigger = trig;
{}; // would be observe_timer_fired
// ...
}
```
A grep for any of `TraceEvent::`, `::log::`, `::tracing::`, `::defmt::`,
`JournalSink::push` in the expanded code returns **zero matches**. The
macro arms entirely vanish.
`JournalSink` itself becomes a unit struct when the feature is off:
```rust
#[cfg(not(feature = "journal"))]
pub struct JournalSink; // ZST — 0 bytes in the chart struct
```
so the `__journal` field on the chart costs zero bytes at runtime.
### 3.2 Name lookups are dead-code-eliminated (cargo rustc --emit=asm)
Test: `tests/det_lateral.rs`, built with `--features tokio,journal`.
Even with `journal` enabled, a grep across the 22,477 lines of generated
asm for `state_name`, `action_name`, `event_name`, or `timer_name` returns
**zero matches**. The journal stores `u16` indices, not strings, and no
trace-* feature is on, so the name-lookup helper functions are uncalled
and DCE'd from the final binary.
This means even if a user enables `journal` for replay/verification, they
pay zero binary cost for the human-readable name tables until they also
enable a `trace-*` sink.
### 3.3 LLVM lowers handler matches to jump tables
Same asm file. Grep for `LJTI`:
```asm
leaq .LJTI2_0(%rip), %rcx
jmpq *%rax
.LJTI2_0:
.long .LBB2_1-.LJTI2_0
.long .LBB2_28-.LJTI2_0
.long .LBB2_29-.LJTI2_0
.long .LBB2_23-.LJTI2_0
```
Multiple jump tables are present, each sized roughly to the chart's
state/event count. This corroborates the assumption that
`__handler_lookup`'s nested `match (state_id) { match (kind, trigger_id) }`
lowers to a single indirect jump on platforms LLVM can target with one,
not a chain of compares.
### 3.4 Async machinery code size
Symbol-by-symbol asm sizes from `det_lateral`:
| Symbol (async closure / future) | Asm lines | Notes |
|---------------------------------|-----------|----------------------------------------------|
| `Lat::exit_state` | ~275 | one `.await` per exit action |
| `Lat::run_action` | ~349 | match arm + N monomorphized async branches |
| `Lat::run_handlers` | ~826 | loop over actions, each `.await` |
| `Lat::enter_state_no_descent` | ~217 | |
| `Lat::step` | ~970 | inlines `lca`, `is_up_transition`, etc. |
| `Lat::dispatch` | ~137 | thin loop wrapper |
| **Lat-chart total** | **~2,800**| async machinery for one 5-state chart |
For comparison, `Lat::new` is 141 lines. The async coloring
roughly doubles to triples the binary footprint of a chart relative to a
pure sync codegen. On Cortex-M0/M0+ where flash is small, this matters.
### 3.5 Journal expansion adds N pushes, nothing else (cargo expand)
Same test, expanded with `--features tokio,journal`
(`/tmp/hsmc_expand_with_journal.rs`, 2,426 lines vs. 911 lines without).
Counts:
- 104 total `TraceEvent::*` constructions.
- 20 distinct variant names (the full vocabulary).
- 8 `Vec::push` call sites inside `step()` — but only one fires per
invocation (timer-fired path, event-received path, etc., are mutually
exclusive).
The diff against the no-feature expansion is exactly: every `{};`
placeholder becomes a `(&mut self.__journal).push(TraceEvent::...)`. No
other infrastructure is added. No locking, no allocation per push beyond
the amortized `Vec` growth.
---
## 4. Observability cost matrix
Distilled from `lib.rs:158-712` and the spot-checks above:
| Feature(s) | Per-call CPU | Per-call alloc | Off → fully elided? |
|-------------------|-------------------------------------------|--------------------------|-------------------------------|
| `(none)` | 0 | 0 | Yes (verified) |
| `journal` | `Vec::push` ~10 ns amortized | ~16-24 B amortized | n/a |
| `trace-defmt` | format-hash u32 + field write | 0 | Yes |
| `trace-log` | `log_enabled!()` check + format if passes | 0 unless format | Yes (with small residual when on but level filtered) |
| `trace-tracing` | unconditional field collection + span | 0 | Yes (with ~30-cycle residual when on but no subscriber) |
The codegen emits **~11 `__chart_observe!` calls per typical transition**
in deterministic order: `EventReceived` → `EventDelivered` →
`TransitionFired` → `ExitBegan` → `ActionInvoked{Exit}` → `Exited` →
`EnterBegan` → `ActionInvoked{Entry}` → `Entered` → `TransitionComplete`,
plus action-handler markers. That's the multiplier on every per-call
number above.
---
## 5. What is *not* a problem (audited and dismissed)
- **Handler-lookup growth with chart size** — confirmed jump-table
lowering (§3.3). O(1) per event regardless of state or event count
(modulo cache effects from larger jump tables).
- **Trait-object cost on action dispatch** — only the `&mut dyn QueuePush`
proxy is `dyn`. User actions are monomorphized trait methods (one match
arm per action ID, direct calls). One vtable hop per emit, *not* per
action.
- **Observation overhead with features off** — confirmed zero. Verified
three ways: macro arm structure (every body `#[cfg]`-gated), expanded
source (every site is `{};`), and asm (no name-lookup symbols, no trace
symbols).
- **Heap allocation in default build** — `extern crate alloc` is confined
to `journal.rs:26`. With `journal` off, the runtime is fully stack-only.
Verified: no `Vec`, `Box`, or `String` in expanded sync code.
- **`Pin<Box<...>>` futures on the hot path** — none. Chart futures are
inline state machines, not boxed.
- **Tokio `unbounded_channel` for embedded** — irrelevant: tokio is `std`
anyway. Embassy uses bounded `embassy_sync::channel::Channel` (no
alloc).
---
## 6. Event entry points
Events reach the chart's internal `heapless::Deque` via four distinct
paths. Each has its own latency, wakeup, ordering, and backpressure
profile, summarized below.
| Path | API | Backing | Wakeup | Ordering | Backpressure |
|------|-------------------------------------------|--------------------------------------------|-------------------------|----------------------------------------|------------------------------------|
| 1 | `chart.send(event)` (sync, single-task) | `heapless::Deque::push_back` | none — caller drives | FIFO within queue | `Err(QueueFull)` immediate |
| 2a | `chart.sender().send(ev)` (tokio) | `tokio::sync::mpsc::unbounded_channel` | tokio task wake | FIFO; biased select deprioritizes vs. durings | **unbounded memory** |
| 2b | `chart.sender().send(ev)` (embassy) | `embassy_sync::channel::Channel` (bounded) | embassy task wake | FIFO; same biased-select | `Err(Full)` (try_send) or backpressure-await |
| 3 | `actx.emit(ev)` from inside an action | same internal `Deque` as path 1 | none — drained inline | drained synchronously after current handler completes; *before* `step()` returns | sticky overflow flag |
| 4 | return from a `during:` future | `select!` arm in run loop | driver-specific | biased priority over channel events | silent drop on `Deque` full |
**Path 1 (sync send)** — `codegen.rs:2316-2319`. Bare
`Deque::push_back`; owning task only, no synchronization. ~5–10 cycles.
Caller is responsible for invoking `step()` (or `dispatch()` under an
async feature) to actually process the event.
**Path 2 (cross-task `sender()`)** — `codegen.rs:1492` (tokio sender),
`1638` (embassy sender), `1308` / `1405` (run-loop receive sites). The
canonical cross-thread / ISR path per the spec. Tokio uses an unbounded
MPSC (heap-backed; no backpressure signal); embassy uses a bounded
`Channel<M, E, N>` with `try_send` for ISR-safe non-blocking emits and
an `async send` that backpressures the producer. The receiver task
wakes on send; one iteration of the run-loop's `select!` consumes the
event and pushes it into the internal `Deque`.
**Path 3 (self-emit from action)** — `actx.emit(...)` goes through
`EmitProxy::push` (`hsmc/src/lib.rs`) which writes into the same
internal `Deque` as Path 1. Self-emitted events are drained in the
*same* `step()` call that emitted them — `dispatch()` runs `while
!queue.is_empty() { step().await }` until quiescence. Nothing crosses a
task boundary.
**Path 4 (`during:` return)** — `codegen.rs:1306–1313` (tokio with
durings), `1392+` (embassy). Durings are racing futures spawned in the
run-loop's `select!`. When one resolves to an event, the `select!`
unblocks and the event is pushed into the internal `Deque`. The
`select!` is `biased;` so durings have priority over the channel —
relevant if a during is a poll-based driver that's almost always
ready (see §8).
**One ordering caveat**: there is no global FIFO across paths 1–4.
Within each path, events are FIFO; across paths, the order depends on
when each path's wakeup arrives at the run loop. For most charts this
is a non-issue, but a chart that mixes high-frequency cross-task sends
with rapid self-emits could observe self-emits processed before
external events that arrived "first" in wall-clock terms.
---
## 7. Cooperative-driving audit
The library targets embassy-rs's single-priority cooperative scheduler,
where any tight loop without an `.await` can starve other tasks. An
audit of every loop and await pattern in the runtime and codegen:
| Loop / await | Location | Bounded? | Awaits per iter? | Verdict |
|---------------------------------------|---------------------------|-------------------------|--------------------------|---------------------|
| Main `run()` outer | codegen.rs:1290+, 1379+ | by `terminated` flag | yes — always | cooperative |
| `dispatch()` drain loop | codegen.rs:2339 | by queue size | only via `step().await` | suspect — fairness |
| Transition exit/enter slice loops | codegen.rs (transition) | by precomputed slice len| yes (async variant) | OK |
| Termination drain | codegen.rs:2750 | by hierarchy + queue | yes (async variant) | OK |
| Timer decrement / pop | runtime / lib.rs | by timer-table size | sync, off the event-loop | OK |
| Send/recv backends | codegen.rs:1308, 1405 | n/a | always `.await` | cooperative |
No `try_recv` patterns anywhere. No fixed-period polling. The library
is fundamentally event-driven and embassy-friendly.
**One latent fairness characteristic**: the `dispatch()` drain loop —
`while !self.queue.is_empty() { step(Duration::ZERO).await }` — never
yields to the executor between iterations. If user actions are all
synchronous and they emit a chain of follow-on events (action A emits
B, B emits C, …), each `step().await` completes inline, and the loop
becomes effectively synchronous for the duration of the chain. Embassy
high-priority tasks could be delayed by the chain length.
This is **not a bug** — `dispatch()` is documented to drain to
quiescence — but it's worth knowing about. A future option (not part of
this round of work) would be a feature-gated
`embassy_futures::yield_now().await` every Nth iteration, or a
chart-level config to bound chain length. Not done by default because
it would serialize the common case for no benefit.
---
## 8. Correctness invariants surfaced during the audit
Three contracts that aren't bugs, but warrant explicit documentation
because they are easy to miss when reading the code:
**Sticky overflow flag in `EmitProxy`** (`hsmc/src/lib.rs`,
`EmitProxy::push`). When a self-emit fails because the internal `Deque`
is full, an `__overflow` flag is set on the chart. *Every* subsequent
`actx.emit(...)` in the same dispatch chain returns `Err(QueueFull)`,
even after the queue has drained. The flag is cleared at the top of
each `dispatch()` call by `take_overflow()`. This is intentional — it
prevents a chart from observing a partially-applied sequence of
self-emissions where some made it into the queue and others did not.
Authors of actions that emit multiple events should treat
`Err(QueueFull)` as terminal for the current chain.
**Biased select in run loop** (tokio: `codegen.rs:1306–1313`; embassy:
`1392+`). The `select!` is `biased;`, so durings are always polled
before the cross-task channel. For most charts this is the right
priority — a sensor driver should pre-empt a UI event. But for a
`during:` that wraps a poll-based driver (one that returns `Ready`
nearly every poll), the channel can be starved indefinitely. Cure:
make the during yield (`embassy_futures::yield_now`) when there's
nothing real to report.
**Cancel-safety contract on durings** (`hsmc/src/lib.rs:53–60`). Every
`.await` in a `during:` future must be a clean resume point, because
the future will be dropped if the state transitions before it
completes. This is the standard async-cancel-safety contract, but it
is easy to violate by holding a mutable borrow across an await — the
borrow is "valid" when resumed but the surrounding state may have
moved on.
---
## 9. Candidate mitigations (menu, NOT a commitment)
Sized for sequencing in a follow-up plan-mode session. "Effort" is gut
feel: L = afternoon, M = couple of days, H = a week or more.
| ID | Mitigation | Targets | Impact | Effort | Cost |
|-----|-----------------------------------------------------------------------------------------------------------------------------------|-----------|-----------------------------------------|--------|---------------------------------------|
| H0 | Replace `lca()` with an O(H) depth-walk via the `__DEPTH` table. | #2 | **Landed.** | - | - |
| H1 | Codegen per-(from, to) precomputed exit/enter chains as `__PATH_DATA` + `__PATH_RANGE`. | #2 | **Landed.** Up/self/lateral folded into one indexed lookup; `lca()` and `is_up_transition()` removed. | - | ~`6·N²` B index + ~`N²·H` B path data |
| T1 | Inlined `is_empty()` early-out on `pop_expired` / `min_remaining` / `decrement` / `cancel_state` with `#[cold]` non-empty bodies. | #11 | **Landed.** | - | - |
| T2 | Codegen-static elide of all `TimerTable` calls when `ir.duration_triggers.is_empty()`. | #11 | **Landed.** Most charts are timer-free; `step()` returns `None` directly. | - | - |
| H4 | `__DISPATCH[(state, event)]` const array; one indexed lookup replaces the runtime __PARENT bubble walk + per-hop `__handler_lookup`. | #3 | **Landed.** Each (state, event) statically resolved at codegen. | - | ~24 B per (state, event) pair |
| U1 | Shrink `TimerEntry.remaining_ns: u128 → u64` (~584-year ceiling). Halves entry size; replaces u128 saturating-sub with u64. | #12 | **Landed.** | - | - |
| K1 | `__TERMINATE_DATA` per-state precomputed exit chain (root inclusive); `do_terminate()` indexes a slice instead of walking __PARENT, and uses `Deque::clear()` (O(1)) for the queue drain. | #13 | **Landed.** terminate_event -22.5% instr / -23.4% cycles. | - | ~`(H+1)·N` B path data |
| H3 | Provide a `dispatch_sync()` API that bypasses `.await` when the chart's actions are fully synchronous. | #1, #9 | High for sync-action charts | L-M | API surface growth (user-facing) |
| H5 | Sparse `__PATH_RANGE` / `__DISPATCH` for very large charts (N > 64): emit only reachable pairs with a small perfect-hash. Latency-neutral; cuts O(N²) static memory growth. | cost mitigation | Low at typical N; significant at N > 100 | M | codegen complexity |
| M1 | Eliminate the trailing `step().await` in `dispatch()` when queue empties mid-step. | #1 | Low | L | minor refactor |
| M2 | Lazy field eval in `trace-tracing` arm using `tracing`'s field-closure form to skip evaluation when filtered. | #8 (on) | Low | L | none |
H0/H1/T1/T2/H4/U1/K1 all landed in the most recent round of work.
Callgrind now reports the entire HSM machinery as a single `step`
symbol — every helper inlined. The only remaining optimization avenue
that meaningfully affects per-event latency is H3 (`dispatch_sync()`),
which is excluded as user-facing. Memory-side mitigations (H5) only
matter for N > 64-state charts; embedded users with large charts are
the audience.
---
## 10. Where to look in the source
For anyone reading this audit and wanting to verify or extend it:
- **`hsmc/src/lib.rs`**
- `:94` — the `#![cfg_attr(... no_std)]` boundary
- `:158-712` — `__chart_observe!` macro, all observation arms
- `:737-772` — `JournalSink` ZST/Vec duality
- **`hsmc/src/journal.rs`**
- `:26-32` — `extern crate alloc` and `Vec` import (only here)
- `:65-150+` — `TraceEvent` enum (the journal vocabulary)
- **`hsmc-macros/src/codegen.rs`**
- `compute_transition_paths()` (top of file, after `compute_chart_hash`) — builds `__PATH_DATA` + `__PATH_RANGE` at codegen time
- `__PARENT`, `__DEPTH`, `__DEFAULT_CHILD`, `__PATH_DATA`, `__PATH_RANGE` static-table emission (search "const __PARENT")
- `step()` (search "fn step" / "async fn step") — sync vs. async branches
- `dispatch()` await loop (search "fn dispatch")
- `run_action` (search "fn run_action") — sync vs. async
- `transition` (search "fn transition") — now a thin slice-iter wrapper
- name-lookup helpers (`__state_name`, `__action_name`, etc.) at `:631-667`
- **Test reference shape**: `hsmc/tests/state_chart_const.rs` is the
smallest sync-only chart in the tree; `hsmc/tests/det_lateral.rs` is
the reference async + journal shape used for the asm spot-check.
---
## 11. Reproducing the spot-checks
```sh
# Zero-cost observation verification (expand → no observation residue)
cargo expand --release --test state_chart_const \
> /tmp/expand_no_features.rs --manifest-path hsmc/Cargo.toml
grep -E "TraceEvent::|::log::|::tracing::|::defmt::" /tmp/expand_no_features.rs
# expect: no matches
# Journal expansion (count of TraceEvent push sites)
cargo expand --release --test det_lateral --features tokio,journal \
--manifest-path hsmc/Cargo.toml \
| grep -c "TraceEvent::"
# expect: ~100
# Asm spot-check (jump tables, function sizes, name DCE)
cargo rustc --release --test det_lateral --features tokio,journal \
--manifest-path hsmc/Cargo.toml -- --emit=asm
ASM=$(find target -name 'det_lateral*.s' | head -1)
grep -E "LJTI|jmpq?[[:space:]]+\*" "$ASM" # jump tables
grep -E "state_name|action_name" "$ASM" # expect no matches
```
---
## 12. Summary
`hsmc`'s hot path is mostly already at the algorithmic floor for
hierarchical state machines. The intrinsic per-event cost (parent walk,
handler lookup, action dispatch, queue ops) is small and well-monomorphized.
Observation cost is verified zero when features are off, and stays
proportional to the user's chosen sink mix when on.
**Items #2, #3, #11, #12, and #13 in §1 have all landed** across six
optimization passes (H0 → H1 → T1 → H4 → T2 → K1+U1). Cumulative impact
on the deepest dispatch (h8_cross_tree) is **-82% instructions / -72%
cycles** vs the original O(H²) LCA scan. The headline wins by bench:
- `terminate_event` 69 instr / 311 cycles (~14 instr of HSM work after
subtracting bench wrapper) — `do_terminate()` is one indexed slice
+ `Deque::clear()`.
- `bubble_top` 99 instr / 453 cycles — 4-hop handler resolution
collapses to one indexed `__DISPATCH` lookup; per-dispatch cost is
bench-shape-independent.
- `bubble_cross_tree` 94 instr / 444 cycles — realistic HSM worst
case (deep bubble + 10-state cross-tree) costs the same as a plain
cross-tree because bubble lookup is O(1) and transition is
O(path-length).
- `h8_cross_tree` 93 instr / 443 cycles — 7-deep cross-tree dispatch.
Callgrind reports the entire HSM machinery as a single `step` symbol —
`run_handlers`, `run_action`, `transition`, `do_terminate`,
`exit_state`, `enter_state_no_descent`, `EmitProxy::push`, all four
`TimerTable` methods, the dispatch-table lookup, and the
terminate-path lookup are all inlined.
Cost remaining, in priority order:
1. **Async coloring code-size cost** (item #9) — chart machinery is
~3,000 asm lines for a 5-state chart, dominated by async-fn state
machines. Mitigation H3 (a `dispatch_sync()` fast path) would let
users with all-sync actions skip this entirely. **User-facing API
change** — out of scope for the current optimization pass.
2. **Bench-wrapper noise floor** — at the smallest dispatch's ~38–80
instructions, the iai-callgrind wrapper (`black_box`, `send`,
instrumented call-edge crossing) accounts for ~45 instructions.
Sub-bench-wrapper improvements are below the measurement floor.
3. **Memory cost of `__PATH_DATA` / `__PATH_RANGE` / `__DISPATCH` /
`__TERMINATE_DATA` at large N** — `__PATH_RANGE` is O(N²);
`__DISPATCH` is O(N×E); `__TERMINATE_DATA` is O(N×H). For N ≤ 32 /
E ≤ 16 / H ≤ 8 the total static footprint is a few KiB. A
64-state × 32-event chart would push past ~80 KiB. Mitigation H5
sketches a sparse encoding.
All addressable purely in `hsmc-macros/src/codegen.rs` and `hsmc/src/lib.rs`
without breaking runtime semantics or feature gates. The bench harness
in `hsmc/benches/dispatch.rs` (13 benches, every dispatch shape
covered) is in place to measure each mitigation as it lands.
+971 -360
View File
File diff suppressed because it is too large. Load diff
+6 -4
View File
@@ -26,10 +26,12 @@ pub struct Ir {
/// Duration triggers referenced anywhere, in first-seen order. /// Duration triggers referenced anywhere, in first-seen order.
/// Each one gets a trigger id used by the timer table. /// Each one gets a trigger id used by the timer table.
pub duration_triggers: Vec<DurationTrigger>, pub duration_triggers: Vec<DurationTrigger>,
/// True when the chart declared `trace;` at root. Codegen routes /// Set when the chart declared `trace;` at root. Retained for
/// trace calls to `::hsmc::__chart_trace!`, which dispatches to a /// backwards-compatible parsing — the keyword no longer gates
/// backend (defmt / log / tracing / no-op) based on the user's /// observation emission. As of 0.4 every chart unconditionally
/// `hsmc/trace-*` feature selection in their `Cargo.toml`. /// emits `__chart_observe!` calls, and the actual trace fan-out is
/// chosen by the user's `hsmc/trace-*` cargo feature selection.
#[allow(dead_code)]
pub trace_enabled: bool, pub trace_enabled: bool,
} }
@@ -1,3 +1,5 @@
#![allow(unexpected_cfgs)]
use hsmc::statechart; use hsmc::statechart;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -1,68 +1,13 @@
warning: unexpected `cfg` condition value: `tokio`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: `#[warn(unexpected_cfgs)]` on by default
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
warning: unexpected `cfg` condition value: `embassy`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
warning: unexpected `cfg` condition value: `journal`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0046]: not all trait items implemented, missing: `NonExistent` error[E0046]: not all trait items implemented, missing: `NonExistent`
--> tests/ui/t9_8_unknown_target.rs:19:1 --> tests/ui/t9_8_unknown_target.rs:21:1
| |
8 | / statechart! { 10 | / statechart! {
9 | | M { 11 | | M {
10 | | context: Ctx; 12 | | context: Ctx;
11 | | events: Ev; 13 | | events: Ev;
... | ... |
17 | | } 19 | | }
| |_- `NonExistent` from trait | |_- `NonExistent` from trait
18 | 20 |
19 | impl MActions for MActionContext<'_> {} 21 | impl MActions for MActionContext<'_> {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `NonExistent` in implementation | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `NonExistent` in implementation
+23 -11
View File
@@ -17,25 +17,30 @@ exclude = ["examples/microwave_tui.rs"]
default = [] default = []
tokio = ["dep:tokio"] tokio = ["dep:tokio"]
embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"] embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"]
# Auto-tracing backends — pick at most one. When the user opts a chart # Auto-tracing sinks — every chart emits `__chart_observe!` calls
# into `trace;`, the macro emits calls to an internal dispatcher that # unconditionally; whichever of these sinks you enable is what gets
# routes to whichever backend is enabled here. With NO backend feature # textual log lines. Pick zero, one, or any combination — the
# enabled, `trace;` is a no-op (zero overhead). Enabling more than one # observation pipeline fans out to all enabled sinks and the in-memory
# backend produces a duplicate-macro compile error — that's intentional. # journal (below). With NO sink feature enabled, observation is fully
# elided at compile time (zero overhead).
#
# Format: `[statechart:<Name>] <verb> key=value …` (logfmt-style,
# greppable, regex-parseable). The `tracing` backend uses native
# structured fields instead of stringly key=value pairs.
trace-defmt = ["dep:defmt"] trace-defmt = ["dep:defmt"]
trace-log = ["dep:log"] trace-log = ["dep:log"]
trace-tracing = ["dep:tracing"] trace-tracing = ["dep:tracing"]
# Journaling backend. When enabled, the macro emits calls into an # In-memory journal sink. When enabled, every `__chart_observe!` site
# in-memory journal recording every observable atom: entries, exits, # also records a `TraceEvent` into the per-machine `JournalSink`. The
# action invocations, during start/cancel, timer arm/cancel/fire, emits, # journal is the authoritative byte-deterministic atom-by-atom record
# event delivery, transitions, and termination. Used to make charts # of chart execution; trace backends are textual renderings of the
# replay-deterministic and to catch semantic regressions in CI. # same vocabulary.
# Requires `alloc` (the journal grows dynamically). Off by default — # Requires `alloc` (the journal grows dynamically). Off by default —
# zero overhead and no `alloc` requirement when not enabled. # zero overhead and no `alloc` requirement when not enabled.
journal = [] journal = []
[dependencies] [dependencies]
hsmc-macros = { path = "../hsmc-macros", version = "0.3.0" } hsmc-macros = { path = "../hsmc-macros", version = "0.4.1" }
heapless = "0.8" heapless = "0.8"
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true } tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
embassy-sync = { version = "0.8", optional = true } embassy-sync = { version = "0.8", optional = true }
@@ -52,6 +57,9 @@ ratatui = "0.28"
crossterm = { version = "0.28", features = ["event-stream"] } crossterm = { version = "0.28", features = ["event-stream"] }
futures = "0.3" futures = "0.3"
[target.'cfg(target_os = "linux")'.dev-dependencies]
iai-callgrind = "0.16"
[[example]] [[example]]
name = "microwave" name = "microwave"
required-features = ["tokio"] required-features = ["tokio"]
@@ -64,3 +72,7 @@ required-features = ["tokio"]
name = "embassy_full" name = "embassy_full"
required-features = ["embassy"] required-features = ["embassy"]
crate-type = ["lib"] crate-type = ["lib"]
[[bench]]
name = "dispatch"
harness = false
+176
View File
@@ -0,0 +1,176 @@
# hsmc dispatch baseline — instruction counts from iai-callgrind.
#
# Current numbers reflect the codebase after the terminate-path + u64-ns
# pass. `do_terminate()` indexes into a precomputed `__TERMINATE_DATA`
# table (one slice per starting state, root inclusive); `TimerEntry`'s
# `remaining_ns` field shrunk from u128 to u64 (~584 years of headroom),
# halving the entry size and replacing per-step u128 saturating-sub work
# with u64. The smaller machine struct improves cache density on every
# bench, even those that don't use timers.
#
# Re-run with:
#
# cargo bench --bench dispatch
#
# Named baselines under target/iai/ for diffing past changes:
# pre_lca_fix — original O(H²) LCA contains() scan
# before_path_precompute — post-LCA-fix, pre-precompute
# before_timer_skip — post-precompute, pre-timer-skip (runtime is_empty)
# before_dispatch_precompute — post-timer-skip, pre-dispatch-table
# before_timer_elide — post-dispatch-table, pre-codegen-elide
# before_terminate_and_timer_opts — post-codegen-elide, pre-terminate-path / u64
#
# To diff a future change against the current numbers:
#
# cargo bench --bench dispatch -- --save-baseline=before_<your_change>
# <change>
# cargo bench --bench dispatch -- --baseline=before_<your_change>
#
# Numbers below are from a 6.19.14-zen kernel x86_64 box, valgrind 3.25.1,
# iai-callgrind 0.16.1, --release.
dispatch::dispatch::h2_cold_start cold:setup_h2_cold()
Instructions: 38
Estimated Cycles: 201
dispatch::dispatch::h2_warm_lateral warm:setup_h2_warm()
Instructions: 84
Estimated Cycles: 397
dispatch::dispatch::h4_cross_tree cross:setup_h4_warm()
Instructions: 101
Estimated Cycles: 456
dispatch::dispatch::h4_up up:setup_h4_warm()
Instructions: 98
Estimated Cycles: 384
dispatch::dispatch::h4_self self_:setup_h4_warm()
Instructions: 101
Estimated Cycles: 456
dispatch::dispatch::h8_cross_tree cross:setup_h8_warm()
Instructions: 93
Estimated Cycles: 443
dispatch::dispatch::bubble_leaf leaf:setup_bubble_warm()
Instructions: 99
Estimated Cycles: 453
dispatch::dispatch::bubble_mid mid:setup_bubble_warm()
Instructions: 99
Estimated Cycles: 453
dispatch::dispatch::bubble_top top:setup_bubble_warm()
Instructions: 99
Estimated Cycles: 453
dispatch::dispatch::timer_fire_dispatch fire:setup_timer_fire_armed()
Instructions: 191
Estimated Cycles: 706
dispatch::dispatch::terminate_event quit:setup_term_warm()
Instructions: 69
Estimated Cycles: 311
dispatch::dispatch::bubble_cross_tree switch:setup_bubble_cross_warm()
Instructions: 94
Estimated Cycles: 444
dispatch::dispatch::chain_self_emit chain:setup_chain_warm()
Instructions: 419
Estimated Cycles: 1301
# ─── LCA O(H) fix delta (pre_lca_fix → before_path_precompute) ──────
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -22.8% -22.1%
# h4_cross_tree -25.9% -10.6%
# h4_self -21.5% -11.7%
# h8_cross_tree -46.2% -41.0% ← deepest LCA stress
# chain_self_emit -15.4% -10.4%
#
# ─── Precompute-paths delta (before_path_precompute → before_timer_skip) ──
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -19.5% -4.8%
# h4_cross_tree -45.0% -28.6%
# h8_cross_tree -48.6% -34.8%
# chain_self_emit -28.1% -14.6%
#
# ─── Timer empty-skip delta (before_timer_skip → before_dispatch_precompute) ──
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -30.8% -35.1%
# h4_cross_tree -38.4% -35.4%
# h8_cross_tree -54.4% -52.9%
# chain_self_emit -27.9% -29.2%
#
# ─── Dispatch-table delta (before_dispatch_precompute → before_timer_elide) ──
#
# Bench Instr Δ Cycle Δ
# bubble_mid -19.4% -4.7% 3-hop __PARENT walk → table lookup
# bubble_top -22.3% -5.8% 4-hop walk → table lookup
# h8_cross_tree +4.2% +15.8% cold-cache miss on __DISPATCH (warm-cache neutral)
#
# ─── Timer-elide delta (before_timer_elide → before_terminate_and_timer_opts) ──
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -26.9% -9.7%
# h4_cross_tree -22.2% -20.1%
# h8_cross_tree -24.2% -20.8%
# bubble_top -22.1% -23.2%
# chain_self_emit -18.0% -8.4%
#
# ─── Terminate-path + u64-ns delta (before_terminate_and_timer_opts → current) ──
#
# Bench Instr Δ Cycle Δ Notes
# h2_cold_start -2.6% -14.1% smaller machine struct, tighter cache
# h2_warm_lateral -3.4% -21.2% (RAM hits 11→8)
# h4_cross_tree -3.8% -1.7%
# h4_up -3.9% -22.3% (RAM hits 10→7)
# h4_self -3.8% -8.4%
# h8_cross_tree -1.1% -0.7%
# bubble_leaf -6.6% -9.6%
# bubble_mid -6.6% -3.0%
# bubble_top -6.6% -9.6%
# timer_fire_dispatch +2.7% -12.6% smaller TimerEntry → -3 RAM hits
# terminate_event -22.5% -23.4% ← headline: precomputed exit path + queue.clear()
# bubble_cross_tree -2.1% -0.9%
# chain_self_emit -0.9% -3.1%
#
# Two changes in this pass:
#
# (1) `__TERMINATE_DATA` / `__TERMINATE_RANGE` — per-state precomputed
# exit chain up to and including __Root. `do_terminate()` indexes the
# table once and iterates the slice, replacing the runtime `__PARENT`
# walk + per-iteration match. Also swapped the queue-drain loop
# (`while self.queue.pop_front().is_some() {}`) for `self.queue.clear()`,
# which heapless 0.8 implements as O(1) (resets indices, no per-element
# pop). Together: -22% instr / -23% cycles on terminate_event, the
# headline win the user asked for.
#
# (2) `TimerEntry.remaining_ns: u128 → u64`. Internal-only field type
# change. u64 nanoseconds bounds at ~584 years — past any realistic
# Duration. Eliminates u128 saturating-sub in `decrement()` (4-6 instr
# → 1-2 instr per entry per step) and the manual secs/nanos
# decomposition in `min_remaining()` (replaced with
# `Duration::from_nanos(u64)`). The struct shrinks 32 B → 16 B with
# alignment, so the entire machine struct is smaller — even no-timer
# benches see RAM-hit reductions from tighter cache packing.
#
# Net across the six optimization passes (pre_lca_fix → current):
# h8_cross_tree dispatch is **-82% instructions / -72% cycles** vs the
# original O(H²) LCA scan. terminate_event is now 69 instr / 311 cycles
# — about 80% of that is the iai-callgrind wrapper, leaving ~14 instr
# of actual HSM termination work for a 3-deep chart.
#
# ─── Bench coverage (3 benches added in the previous pass) ──────────
#
# `timer_fire_dispatch` (191 instr / 706 cycles) — only bench that
# exercises pop_expired's non-empty branch and `dispatch_trigger`.
# `terminate_event` (69 instr / 311 cycles) — only bench that
# exercises `__check_terminate`'s true arm and `do_terminate`.
# `bubble_cross_tree` (94 instr / 444 cycles) — handler on L1 (4
# bubble hops) firing a 10-state cross-tree transition. Realistic
# HSM worst case.
+633
View File
@@ -0,0 +1,633 @@
//! Deterministic instruction-count benches for the dispatch hot path.
//!
//! Run with `cargo bench --bench dispatch` (Linux only — uses
//! iai-callgrind + valgrind).
//!
//! Each bench measures one dispatch kind in isolation:
//! - `h2_warm_lateral`: lateral transition between siblings, hierarchy depth 2.
//! - `h4_cross_tree`: lateral transition crossing two parallel subtrees, depth 4 from root.
//! - `h8_cross_tree`: same shape but depth 8 — exercises LCA worst-case.
//! - `h4_up`: up-transition (target is an ancestor of current).
//! - `h4_self`: self-transition (target == current).
//! - `h4_drop`: send an event with no matching handler (drop path).
//! - `h4_self_emit_chain`: action emits next event 3×; tests the dispatch drain loop.
//! - `h2_cold_start`: first dispatch into a freshly-constructed chart (priming step).
//!
//! Each bench's setup function primes the chart to a known state outside the
//! measured region. The body sends one event and runs `step()` once.
#[cfg(not(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy")))))]
fn main() {
// The benches use sync action impls; under `--features tokio` /
// `--features embassy` the codegen emits async fns and the impls
// would need an `async` keyword. Rather than maintain two parallel
// bench files, we gate the whole suite to the default-features /
// Linux configuration, which is what we measure latency in anyway.
eprintln!(
"hsmc dispatch benches require Linux + default features \
(iai-callgrind + valgrind, sync action impls)."
);
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
use hsmc::{statechart, Duration};
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
use iai_callgrind::{library_benchmark, library_benchmark_group, main};
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
use std::hint::black_box;
// ──────────────────────────────────────────────────────────────────────
// Chart 1: H=2 sibling. LCA(A, B) = P. Two states deep.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod h2 {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Go,
}
statechart! {
H2 {
context: Ctx;
events: Ev;
default(P);
state P {
default(A);
state A { on(Go) => B; }
state B { entry: noop; }
}
}
}
impl H2Actions for H2ActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 2: H=4 cross-tree. LCA(L3, R3) = root. Stresses LCA over 8 ancestors.
// Also has up- and self-transition events for additional benches.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod h4 {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Cross,
Up,
Self_,
}
statechart! {
H4 {
context: Ctx;
events: Ev;
default(L1);
state L1 {
default(L2);
state L2 {
default(L3);
state L3 {
on(Cross) => R3;
on(Up) => L1;
on(Self_) => L3;
}
}
}
state R1 {
default(R2);
state R2 {
default(R3);
state R3 { entry: noop; }
}
}
}
}
impl H4Actions for H4ActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 3: H=8 cross-tree. LCA(L7, R7) = root. Worst-case for the
// pre-fix O(H²) LCA — old code does ~64 compares; new code does ~16.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod h8 {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Cross,
}
statechart! {
H8 {
context: Ctx;
events: Ev;
default(L1);
state L1 { default(L2);
state L2 { default(L3);
state L3 { default(L4);
state L4 { default(L5);
state L5 { default(L6);
state L6 { default(L7);
state L7 { on(Cross) => R7; }
}
}
}
}
}
}
state R1 { default(R2);
state R2 { default(R3);
state R3 { default(R4);
state R4 { default(R5);
state R5 { default(R6);
state R6 { default(R7);
state R7 { entry: noop; }
}
}
}
}
}
}
}
}
impl H8Actions for H8ActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 4: emit-chain. S0 →(Go)→ S1 →(emits Next)→ S2 →(emits Next)→ S3 →(emits Next)→ S4.
// One Send + one Step ⇒ chart drains 4 events through 3 self-emits.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod chain {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Next,
}
statechart! {
Chain {
context: Ctx;
events: Ev;
default(S0);
state S0 { on(Go) => S1; }
state S1 { entry: emit_next; on(Next) => S2; }
state S2 { entry: emit_next; on(Next) => S3; }
state S3 { entry: emit_next; on(Next) => S4; }
state S4 { entry: settle; }
}
}
impl ChainActions for ChainActionContext<'_> {
fn emit_next(&mut self) {
let _ = self.emit(Ev::Next);
}
fn settle(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 5: handler bubbling. Active state is L4 (depth 5). The three
// events resolve to handlers at three different ancestor depths so the
// __PARENT walk in step() is exercised at increasing distance:
//
// Leaf — handler on L4 itself → bubble depth 0
// Mid — handler on L1 (3 ancestors) → bubble depth 3
// Top — handler on Outer (4 anc.) → bubble depth 4
//
// `Sibling` is the transition target so each event causes the same
// dispatch shape; the only thing that varies between benches is the
// bubble distance to the handler.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod bubble {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Leaf,
Mid,
Top,
}
statechart! {
Bubble {
context: Ctx;
events: Ev;
default(Outer);
state Outer {
default(L1);
on(Top) => Sibling;
state L1 {
default(L2);
on(Mid) => Sibling;
state L2 {
default(L3);
state L3 {
default(L4);
state L4 {
on(Leaf) => Sibling;
}
}
}
}
}
state Sibling { entry: noop; }
}
}
impl BubbleActions for BubbleActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 6: timer firing. A single timer armed for 1 ns; the bench body
// advances time past expiry so `pop_expired` returns Some and dispatches
// the timer-driven transition. This is the only bench that exercises
// `step()`'s pop_expired non-empty branch — every other chart in this
// suite declares no `Duration` triggers and (post timer-elide pass) has
// the timer-table machinery removed at codegen.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod timer_fire {
use super::*;
#[derive(Default)]
pub struct Ctx;
statechart! {
TimerFire {
context: Ctx;
default(Armed);
state Armed {
on(after Duration::from_nanos(1)) => Fired;
}
state Fired { entry: noop; }
}
}
impl TimerFireActions for TimerFireActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 7: terminate event. `Quit` is declared as the terminate event,
// so dispatching it runs `do_terminate()` — exits every active state up
// to the root, sets `terminated`, and short-circuits future `step()`s.
// Only bench that exercises `__check_terminate`'s true arm and the
// terminate-on-event path.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod term {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Quit,
}
statechart! {
Term {
context: Ctx;
events: Ev;
default(L1);
terminate(Quit);
state L1 {
default(L2);
state L2 {
default(L3);
state L3 { entry: noop; }
}
}
}
}
impl TermActions for TermActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 8: bubble + cross-tree. Handler declared on L1 (4 ancestors up
// from current=L5) targets a deep leaf in a sibling subtree (R5). Two
// dispatch costs at once: the (now-O(1)) handler resolution and a
// 10-state-op cross-tree transition (5 exits + 5 enters). The
// realistic worst case for HSM dispatch — a parent's "back to default"
// handler that descends deep on the other side.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod bubble_cross {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Switch,
}
statechart! {
BubbleCross {
context: Ctx;
events: Ev;
default(L1);
state L1 {
default(L2);
on(Switch) => R5;
state L2 {
default(L3);
state L3 {
default(L4);
state L4 {
default(L5);
state L5 { entry: noop; }
}
}
}
}
state R1 {
default(R2);
state R2 {
default(R3);
state R3 {
default(R4);
state R4 {
default(R5);
state R5 { entry: noop; }
}
}
}
}
}
}
impl BubbleCrossActions for BubbleCrossActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Setup helpers. Prime each chart to its measurement starting state
// OUTSIDE the measured region — iai-callgrind only counts work inside
// the bench fn body.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h2_warm() -> h2::H2<8> {
let mut m = h2::H2::new(h2::Ctx);
let _ = m.step(Duration::ZERO); // priming step → enters P → A
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h2_cold() -> h2::H2<8> {
h2::H2::new(h2::Ctx)
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h4_warm() -> h4::H4<8> {
let mut m = h4::H4::new(h4::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1 → L2 → L3
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h8_warm() -> h8::H8<8> {
let mut m = h8::H8::new(h8::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1..L7
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_chain_warm() -> chain::Chain<8> {
let mut m = chain::Chain::new(chain::Ctx);
let _ = m.step(Duration::ZERO); // priming step → S0
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_bubble_warm() -> bubble::Bubble<8> {
let mut m = bubble::Bubble::new(bubble::Ctx);
let _ = m.step(Duration::ZERO); // priming step → Outer → L1 → L2 → L3 → L4
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_timer_fire_armed() -> timer_fire::TimerFire<8> {
let mut m = timer_fire::TimerFire::new(timer_fire::Ctx);
let _ = m.step(Duration::ZERO); // priming step → enters Armed, arms 1ns timer
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_term_warm() -> term::Term<8> {
let mut m = term::Term::new(term::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1 → L2 → L3
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_bubble_cross_warm() -> bubble_cross::BubbleCross<8> {
let mut m = bubble_cross::BubbleCross::new(bubble_cross::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1 → L2 → L3 → L4 → L5
m
}
// ──────────────────────────────────────────────────────────────────────
// Benches. One library_benchmark per scenario. Each takes its primed
// chart by value, sends one event, and runs one step.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::cold(setup = setup_h2_cold)]
fn h2_cold_start(mut m: h2::H2<8>) -> h2::H2<8> {
// first .step() primes the chart by entering the default state path
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::warm(setup = setup_h2_warm)]
fn h2_warm_lateral(mut m: h2::H2<8>) -> h2::H2<8> {
let _ = black_box(m.send(black_box(h2::Ev::Go)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::cross(setup = setup_h4_warm)]
fn h4_cross_tree(mut m: h4::H4<8>) -> h4::H4<8> {
let _ = black_box(m.send(black_box(h4::Ev::Cross)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::up(setup = setup_h4_warm)]
fn h4_up(mut m: h4::H4<8>) -> h4::H4<8> {
let _ = black_box(m.send(black_box(h4::Ev::Up)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::self_(setup = setup_h4_warm)]
fn h4_self(mut m: h4::H4<8>) -> h4::H4<8> {
let _ = black_box(m.send(black_box(h4::Ev::Self_)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::cross(setup = setup_h8_warm)]
fn h8_cross_tree(mut m: h8::H8<8>) -> h8::H8<8> {
let _ = black_box(m.send(black_box(h8::Ev::Cross)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::leaf(setup = setup_bubble_warm)]
fn bubble_leaf(mut m: bubble::Bubble<8>) -> bubble::Bubble<8> {
// Handler on L4 (current state). Bubble distance = 0.
let _ = black_box(m.send(black_box(bubble::Ev::Leaf)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::mid(setup = setup_bubble_warm)]
fn bubble_mid(mut m: bubble::Bubble<8>) -> bubble::Bubble<8> {
// Handler on L1. Bubble walks L4 → L3 → L2 → L1 (3 __PARENT hops).
let _ = black_box(m.send(black_box(bubble::Ev::Mid)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::top(setup = setup_bubble_warm)]
fn bubble_top(mut m: bubble::Bubble<8>) -> bubble::Bubble<8> {
// Handler on Outer. Bubble walks L4 → L3 → L2 → L1 → Outer (4 hops).
let _ = black_box(m.send(black_box(bubble::Ev::Top)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::fire(setup = setup_timer_fire_armed)]
fn timer_fire_dispatch(mut m: timer_fire::TimerFire<8>) -> timer_fire::TimerFire<8> {
// Advance time by 2ns — past the 1ns arm — so pop_expired returns
// Some and dispatches the timer-driven Armed → Fired transition.
let _ = black_box(m.step(black_box(Duration::from_nanos(2))));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::quit(setup = setup_term_warm)]
fn terminate_event(mut m: term::Term<8>) -> term::Term<8> {
// Send Quit (declared as terminate event); step runs check_terminate's
// true arm and do_terminate, which exits L3 → L2 → L1 → root.
let _ = black_box(m.send(black_box(term::Ev::Quit)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::switch(setup = setup_bubble_cross_warm)]
fn bubble_cross_tree(mut m: bubble_cross::BubbleCross<8>) -> bubble_cross::BubbleCross<8> {
// Switch handler is on L1 (4 bubble hops from current=L5). Target is
// R5 in a sibling subtree, so the transition is exit L5..L1 + enter
// R1..R5 = 10 state operations.
let _ = black_box(m.send(black_box(bubble_cross::Ev::Switch)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::chain(setup = setup_chain_warm)]
fn chain_self_emit(mut m: chain::Chain<8>) -> chain::Chain<8> {
let _ = black_box(m.send(black_box(chain::Ev::Go)));
// Drain the chain: 1 step per event. Go → S1 (entry emits Next) → S2
// (entry emits Next) → S3 (entry emits Next) → S4. 4 events total.
for _ in 0..4 {
let _ = black_box(m.step(black_box(Duration::ZERO)));
}
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
library_benchmark_group!(
name = dispatch;
benchmarks =
h2_cold_start,
h2_warm_lateral,
h4_cross_tree,
h4_up,
h4_self,
h8_cross_tree,
bubble_leaf,
bubble_mid,
bubble_top,
timer_fire_dispatch,
terminate_event,
bubble_cross_tree,
chain_self_emit,
);
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
main!(library_benchmark_groups = dispatch);
+55 -14
View File
@@ -45,6 +45,22 @@ pub enum ActionKind {
Handler, Handler,
} }
/// Why a transition fired. Carried on `TransitionFired` so observers can
/// see *what triggered* the state change, not just that one happened.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TransitionReason {
/// External or queued event with the given index dispatched into a
/// handler that fired this transition.
Event { event: u16 },
/// Timer on the given (state, timer) slot expired and its handler
/// fired this transition.
Timer { state: u16, timer: u16 },
/// Driven from inside the runtime without an originating event or
/// timer (e.g. default-descent into a child after entering a
/// composite state). Reserved; not currently emitted.
Internal,
}
/// One observable atom of chart execution. The macro emits these at every /// One observable atom of chart execution. The macro emits these at every
/// hook point so the full sequence can be journaled and compared. /// hook point so the full sequence can be journaled and compared.
/// ///
@@ -54,14 +70,23 @@ pub enum ActionKind {
pub enum TraceEvent { pub enum TraceEvent {
/// First event in any journal. Identifies the chart definition. /// First event in any journal. Identifies the chart definition.
Started { chart_hash: u64 }, Started { chart_hash: u64 },
/// A state's entry begins. Followed by `ActionInvoked{Entry}` for each /// A state's entry began. The marker is emitted before any of the
/// of that state's entry actions, then `DuringStarted` for each during /// state's entry actions, timers, or durings have run. Followed by
/// declared on the state, then potentially the next state's `Entered` /// `ActionInvoked{Entry}`, `TimerArmed`, `DuringStarted`, then the
/// (default-descent). /// matching `Entered` end marker.
EnterBegan { state: u16 },
/// A state's entry completed. Emitted after every entry action,
/// timer arm, and during start has run for that state. Pairs with
/// the prior `EnterBegan { state }`.
Entered { state: u16 }, Entered { state: u16 },
/// A state's exit completed. Preceded by `DuringCancelled` for each /// A state's exit began. The marker is emitted before any of the
/// during on the state, followed by `ActionInvoked{Exit}` for each /// state's during cancellations, timer cancellations, or exit
/// exit action. /// actions have run. Followed by `DuringCancelled`, `TimerCancelled`,
/// `ActionInvoked{Exit}`, then the matching `Exited` end marker.
ExitBegan { state: u16 },
/// A state's exit completed. Emitted after every during cancel,
/// timer cancel, and exit action has run for that state. Pairs with
/// the prior `ExitBegan { state }`.
Exited { state: u16 }, Exited { state: u16 },
/// An action function was called. /// An action function was called.
ActionInvoked { ActionInvoked {
@@ -75,14 +100,30 @@ pub enum TraceEvent {
/// A `during:` activity was cancelled because its owning state is /// A `during:` activity was cancelled because its owning state is
/// being exited. /// being exited.
DuringCancelled { state: u16, during: u16 }, DuringCancelled { state: u16, during: u16 },
/// A transition is about to fire. `from` is the innermost active state /// A transition is about to fire. `from` is the innermost active
/// at the moment the transition was triggered; `to` is the target. /// state at the moment the transition was triggered; `to` is the
/// Followed by the actual `Exited` / `Entered` / etc. events that /// target; `reason` records what caused the transition (event,
/// implement the transition. /// timer, or internal). Followed by the actual `ExitBegan`/`Exited`/
TransitionFired { from: Option<u16>, to: u16 }, /// `EnterBegan`/`Entered` events that implement the transition,
/// then the matching `TransitionComplete` end marker.
TransitionFired {
from: Option<u16>,
to: u16,
reason: TransitionReason,
},
/// A transition completed. Emitted after the transition's exit and
/// entry sequences are fully done. Pairs with the prior
/// `TransitionFired { from, to, .. }`.
TransitionComplete { from: Option<u16>, to: u16 },
/// An event was popped from the internal queue and is about to be
/// dispatched. Emitted before the handler search; followed by either
/// `EventDelivered` (handler found and ran) or `EventDropped`
/// (no handler on the active path), or `TerminateRequested` if the
/// event matches the chart's terminate declaration.
EventReceived { event: u16 },
/// An event reached a state's handler. `handler_state` is the state /// An event reached a state's handler. `handler_state` is the state
/// whose handler ran (which may be an ancestor of the innermost active /// whose handler ran (which may be an ancestor of the innermost
/// state if the event bubbled). /// active state if the event bubbled).
EventDelivered { handler_state: u16, event: u16 }, EventDelivered { handler_state: u16, event: u16 },
/// An event was dispatched but no state on the active path had a /// An event was dispatched but no state on the active path had a
/// handler. Silently dropped per spec. /// handler. Silently dropped per spec.
+652 -125
View File
@@ -102,140 +102,614 @@ pub use hsmc_macros::statechart;
mod error; mod error;
pub use error::HsmcError; pub use error::HsmcError;
// ── Journal (deterministic execution trace) ───────────────────────── // ── Unified observation pipeline ────────────────────────────────────
// //
// When `feature = "journal"` is enabled, the macro emits one // The `statechart!` macro emits ONE `__chart_observe!` call per
// [`TraceEvent`] per observable atom: entries, exits, action calls, // observable atom of execution — entry/exit phase begin/end markers,
// during start/cancel, timer arm/cancel/fire, queued emits, event // action invocations, during start/cancel, timer arm/cancel/fire,
// dispatch, transitions, termination. The events are appended to the // emit queue push/fail, event receive/deliver/drop, transition
// machine's internal `JournalSink` so callers can compare against an // begin/complete, terminate-requested, and terminated.
// expected sequence (see `tests/replay.rs`).
// //
// When the feature is OFF, the codegen still emits `__chart_journal!` // `__chart_observe!` is the single dispatcher: each arm fans the
// invocations at every hook site, but the macro expands to nothing // observation out to whichever sinks are enabled at compile time:
// (zero overhead) and `JournalSink` is a ZST so the field on the //
// machine takes 0 bytes. // - `feature = "journal"` → push a `TraceEvent` into the
// per-machine `JournalSink`.
// - `feature = "trace-defmt"` → emit a `defmt::*` log line.
// - `feature = "trace-log"` → emit a `log::*` log line.
// - `feature = "trace-tracing"` → emit a `tracing::*` event with
// structured fields.
//
// With NO sink feature enabled, every arm body is empty and the
// macro's args are never expanded — zero runtime cost, zero binary
// growth. There is intentionally no catch-all arm: an unrecognized
// invocation produces a compile error, so codegen mismatches are
// caught loudly rather than silently dropped.
//
// Trace and journal share the same vocabulary by construction. The
// trace's textual rendering is machine-parseable (logfmt-style:
// `[statechart:Name] verb key=value …`), so a captured log can be
// diff'd against expected behavior the same way the journal can.
#[cfg(feature = "journal")] #[cfg(feature = "journal")]
mod journal; mod journal;
#[cfg(feature = "journal")] #[cfg(feature = "journal")]
pub use journal::{ActionKind, Journal, TraceEvent}; pub use journal::{ActionKind, Journal, TraceEvent, TransitionReason};
/// Journal call dispatcher invoked by `statechart!`-generated code. /// Internal observation dispatcher invoked by `statechart!`-generated
/// Not part of the public API. /// code. Not part of the public API.
/// ///
/// Call form: `__chart_journal!(<sink-expr>, <TraceEvent expr>)`. /// Call form (one arm per `TraceEvent` variant):
/// The first argument is a place expression for the sink (typically /// `__chart_observe!(<Variant>, <sink-expr>, <chart-literal>, <variant args…>)`
/// `self.__journal`); the second is the full `TraceEvent` value to
/// push.
/// ///
/// When the `journal` feature is off, the macro discards both arguments /// `sink-expr` is the place expression for the journal sink (typically
/// and expands to nothing — the codegen can refer to `TraceEvent::*` /// `&mut self.__journal`); `chart-literal` is the chart name as a
/// without the type having to exist. /// string literal. Variant args are passed positionally — see each arm
#[cfg(feature = "journal")] /// for the order. Names (state, action, event, timer, during) are
/// passed as `&'static str` literals at each call site so the trace
/// formatters never do a runtime lookup.
///
/// All sink-feature gates apply *inside* each arm. With every sink
/// feature off, the arm body is empty; the macro's positional args are
/// never expanded; the call site contributes zero code.
#[doc(hidden)] #[doc(hidden)]
#[macro_export] #[macro_export]
macro_rules! __chart_journal { macro_rules! __chart_observe {
($sink:expr, $event:expr) => { // ── Started ────────────────────────────────────────────────────
$sink.push($event); (Started, $sink:expr, $chart:literal, $hash:expr) => {{
}; #[cfg(feature = "journal")]
} {
$sink.push($crate::TraceEvent::Started { chart_hash: $hash });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] started chart_hash={=u64:#x}",
$chart,
$hash
);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] started chart_hash={:#x}", $chart, $hash);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, chart_hash = $hash, "started");
}};
/// No-op fallback for when `feature = "journal"` is disabled. // ── EnterBegan / Entered ───────────────────────────────────────
#[cfg(not(feature = "journal"))] (EnterBegan, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
#[doc(hidden)] #[cfg(feature = "journal")]
#[macro_export] {
macro_rules! __chart_journal { $sink.push($crate::TraceEvent::EnterBegan { state: $sid });
($($_:tt)*) => {}; }
} #[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] entering state={}", $chart, $sname);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] entering state={}", $chart, $sname);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "entering");
}};
(Entered, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::Entered { state: $sid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] entered state={}", $chart, $sname);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] entered state={}", $chart, $sname);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "entered");
}};
// ── Auto-tracing dispatcher ────────────────────────────────────────── // ── ExitBegan / Exited ─────────────────────────────────────────
// (ExitBegan, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
// When a chart opts in via `trace;`, the proc-macro emits calls to #[cfg(feature = "journal")]
// `::hsmc::__chart_trace!(<kind> <chart-name>, <state-or-action>);` at {
// every state entry, exit, action invocation, and transition. This $sink.push($crate::TraceEvent::ExitBegan { state: $sid });
// crate-level macro picks the backend based on which `trace-*` cargo }
// feature is enabled — exactly one of `trace-defmt` / `trace-log` / #[cfg(feature = "trace-defmt")]
// `trace-tracing`. With none enabled it expands to nothing. ::defmt::info!("[statechart:{}] exiting state={}", $chart, $sname);
// #[cfg(feature = "trace-log")]
// Multiple `trace-*` features enabled simultaneously produce a ::log::info!("[statechart:{}] exiting state={}", $chart, $sname);
// duplicate-definition compile error from rustc. Intentional. #[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "exiting");
}};
(Exited, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::Exited { state: $sid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] exited state={}", $chart, $sname);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] exited state={}", $chart, $sname);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "exited");
}};
/// Internal trace dispatcher invoked by `statechart!`-generated code. // ── ActionInvoked (Entry / Exit / Handler) ─────────────────────
/// Not part of the public API. (ActionInvoked, $sink:expr, $chart:literal,
#[cfg(feature = "trace-defmt")] $sid:expr, $sname:expr,
#[doc(hidden)] $aid:expr, $aname:expr,
#[macro_export] Entry) => {{
macro_rules! __chart_trace { #[cfg(feature = "journal")]
(enter $chart:literal, $state:expr) => { {
::defmt::info!("[{}] enter {}", $chart, $state); $sink.push($crate::TraceEvent::ActionInvoked {
}; state: $sid,
(exit $chart:literal, $state:expr) => { action: $aid,
::defmt::info!("[{}] exit {}", $chart, $state); kind: $crate::ActionKind::Entry,
}; });
(action $chart:literal, $action:expr) => { }
::defmt::trace!("[{}] action {}", $chart, $action); #[cfg(feature = "trace-defmt")]
}; ::defmt::trace!(
(transition $chart:literal, $from:expr, $to:expr) => { "[statechart:{}] action state={} action={} kind=entry",
::defmt::info!("[{}] {} -> {}", $chart, $from, $to); $chart,
}; $sname,
($($_:tt)*) => {}; $aname
} );
#[cfg(feature = "trace-log")]
::log::trace!(
"[statechart:{}] action state={} action={} kind=entry",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-tracing")]
::tracing::trace!(
chart = $chart,
state = $sname,
action = $aname,
kind = "entry",
"action"
);
}};
(ActionInvoked, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$aid:expr, $aname:expr,
Exit) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::ActionInvoked {
state: $sid,
action: $aid,
kind: $crate::ActionKind::Exit,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::trace!(
"[statechart:{}] action state={} action={} kind=exit",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-log")]
::log::trace!(
"[statechart:{}] action state={} action={} kind=exit",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-tracing")]
::tracing::trace!(
chart = $chart,
state = $sname,
action = $aname,
kind = "exit",
"action"
);
}};
(ActionInvoked, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$aid:expr, $aname:expr,
Handler) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::ActionInvoked {
state: $sid,
action: $aid,
kind: $crate::ActionKind::Handler,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::trace!(
"[statechart:{}] action state={} action={} kind=handler",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-log")]
::log::trace!(
"[statechart:{}] action state={} action={} kind=handler",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-tracing")]
::tracing::trace!(
chart = $chart,
state = $sname,
action = $aname,
kind = "handler",
"action"
);
}};
/// Internal trace dispatcher invoked by `statechart!`-generated code. // ── DuringStarted / DuringCancelled ────────────────────────────
/// Not part of the public API. (DuringStarted, $sink:expr, $chart:literal,
#[cfg(feature = "trace-log")] $sid:expr, $sname:expr,
#[doc(hidden)] $did:expr, $dname:expr) => {{
#[macro_export] #[cfg(feature = "journal")]
macro_rules! __chart_trace { {
(enter $chart:literal, $state:expr) => { $sink.push($crate::TraceEvent::DuringStarted {
::log::info!("[{}] enter {}", $chart, $state); state: $sid,
}; during: $did,
(exit $chart:literal, $state:expr) => { });
::log::info!("[{}] exit {}", $chart, $state); }
}; #[cfg(feature = "trace-defmt")]
(action $chart:literal, $action:expr) => { ::defmt::debug!(
::log::trace!("[{}] action {}", $chart, $action); "[statechart:{}] during-started state={} during={}",
}; $chart,
(transition $chart:literal, $from:expr, $to:expr) => { $sname,
::log::info!("[{}] {} -> {}", $chart, $from, $to); $dname
}; );
($($_:tt)*) => {}; #[cfg(feature = "trace-log")]
} ::log::debug!(
"[statechart:{}] during-started state={} during={}",
$chart,
$sname,
$dname
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
during = $dname,
"during-started"
);
}};
(DuringCancelled, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$did:expr, $dname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::DuringCancelled {
state: $sid,
during: $did,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!(
"[statechart:{}] during-cancelled state={} during={}",
$chart,
$sname,
$dname
);
#[cfg(feature = "trace-log")]
::log::debug!(
"[statechart:{}] during-cancelled state={} during={}",
$chart,
$sname,
$dname
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
during = $dname,
"during-cancelled"
);
}};
/// Internal trace dispatcher invoked by `statechart!`-generated code. // ── TransitionFired (begin marker) ─────────────────────────────
/// Not part of the public API. //
#[cfg(feature = "trace-tracing")] // The reason is rendered three ways (event:Name, timer:State/Timer,
#[doc(hidden)] // internal). The journal stores the structured reason and the trace
#[macro_export] // gets a pre-rendered `&'static str` (`$rname`) so log lines stay
macro_rules! __chart_trace { // simple to parse.
(enter $chart:literal, $state:expr) => { (TransitionFired, $sink:expr, $chart:literal,
::tracing::info!(chart = $chart, state = $state, "enter"); $from:expr, $fname:expr,
}; $to:expr, $tname:expr,
(exit $chart:literal, $state:expr) => { $reason:expr, $rname:expr) => {{
::tracing::info!(chart = $chart, state = $state, "exit"); #[cfg(feature = "journal")]
}; {
(action $chart:literal, $action:expr) => { $sink.push($crate::TraceEvent::TransitionFired {
::tracing::trace!(chart = $chart, action = $action, "action"); from: $from,
}; to: $to,
(transition $chart:literal, $from:expr, $to:expr) => { reason: $reason,
::tracing::info!(chart = $chart, from = $from, to = $to, "transition"); });
}; }
($($_:tt)*) => {}; #[cfg(feature = "trace-defmt")]
} ::defmt::info!(
"[statechart:{}] transition-begin from={} to={} reason={}",
$chart,
$fname,
$tname,
$rname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] transition-begin from={} to={} reason={}",
$chart,
$fname,
$tname,
$rname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
from = $fname,
to = $tname,
reason = $rname,
"transition-begin"
);
}};
/// No-op fallback when no `trace-*` backend feature is enabled. The // ── TransitionComplete (end marker) ────────────────────────────
/// chart's `trace;` keyword still parses; calls just expand to nothing. (TransitionComplete, $sink:expr, $chart:literal,
#[cfg(not(any( $from:expr, $fname:expr,
feature = "trace-defmt", $to:expr, $tname:expr) => {{
feature = "trace-log", #[cfg(feature = "journal")]
feature = "trace-tracing", {
)))] $sink.push($crate::TraceEvent::TransitionComplete {
#[doc(hidden)] from: $from,
#[macro_export] to: $to,
macro_rules! __chart_trace { });
($($_:tt)*) => {}; }
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] transition-complete from={} to={}",
$chart,
$fname,
$tname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] transition-complete from={} to={}",
$chart,
$fname,
$tname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
from = $fname,
to = $tname,
"transition-complete"
);
}};
// ── Event lifecycle ────────────────────────────────────────────
(EventReceived, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EventReceived { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!("[statechart:{}] event-received name={}", $chart, $ename);
#[cfg(feature = "trace-log")]
::log::debug!("[statechart:{}] event-received name={}", $chart, $ename);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(chart = $chart, name = $ename, "event-received");
}};
(EventDelivered, $sink:expr, $chart:literal,
$eid:expr, $ename:expr,
$sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EventDelivered {
handler_state: $sid,
event: $eid,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] event-delivered name={} handler={}",
$chart,
$ename,
$sname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] event-delivered name={} handler={}",
$chart,
$ename,
$sname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
name = $ename,
handler = $sname,
"event-delivered"
);
}};
(EventDropped, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EventDropped { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!("[statechart:{}] event-dropped name={}", $chart, $ename);
#[cfg(feature = "trace-log")]
::log::debug!("[statechart:{}] event-dropped name={}", $chart, $ename);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(chart = $chart, name = $ename, "event-dropped");
}};
// ── emit() outcomes ────────────────────────────────────────────
(EmitQueued, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EmitQueued { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!("[statechart:{}] emit-queued event={}", $chart, $ename);
#[cfg(feature = "trace-log")]
::log::debug!("[statechart:{}] emit-queued event={}", $chart, $ename);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(chart = $chart, event = $ename, "emit-queued");
}};
(EmitFailed, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EmitFailed { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::warn!(
"[statechart:{}] emit-failed event={} reason=queue-full",
$chart,
$ename
);
#[cfg(feature = "trace-log")]
::log::warn!(
"[statechart:{}] emit-failed event={} reason=queue-full",
$chart,
$ename
);
#[cfg(feature = "trace-tracing")]
::tracing::warn!(
chart = $chart,
event = $ename,
reason = "queue-full",
"emit-failed"
);
}};
// ── Timer lifecycle ────────────────────────────────────────────
(TimerArmed, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$tid:expr, $tname:expr,
$ns:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TimerArmed {
state: $sid,
timer: $tid,
ns: $ns,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!(
"[statechart:{}] timer-armed state={} timer={} duration_ns={=u64}",
$chart,
$sname,
$tname,
$ns
);
#[cfg(feature = "trace-log")]
::log::debug!(
"[statechart:{}] timer-armed state={} timer={} duration_ns={}",
$chart,
$sname,
$tname,
$ns
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
timer = $tname,
duration_ns = $ns,
"timer-armed"
);
}};
(TimerCancelled, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$tid:expr, $tname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TimerCancelled {
state: $sid,
timer: $tid,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!(
"[statechart:{}] timer-cancelled state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-log")]
::log::debug!(
"[statechart:{}] timer-cancelled state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
timer = $tname,
"timer-cancelled"
);
}};
(TimerFired, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$tid:expr, $tname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TimerFired {
state: $sid,
timer: $tid,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] timer-fired state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] timer-fired state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
state = $sname,
timer = $tname,
"timer-fired"
);
}};
// ── Termination ────────────────────────────────────────────────
(TerminateRequested, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TerminateRequested { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] terminate-requested event={}",
$chart,
$ename
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] terminate-requested event={}",
$chart,
$ename
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, event = $ename, "terminate-requested");
}};
(Terminated, $sink:expr, $chart:literal) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::Terminated);
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] terminated", $chart);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] terminated", $chart);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, "terminated");
}};
} }
#[doc(hidden)] #[doc(hidden)]
@@ -338,7 +812,12 @@ pub mod __private {
pub struct TimerEntry { pub struct TimerEntry {
pub state: u16, pub state: u16,
pub trigger: u16, pub trigger: u16,
pub remaining_ns: u128, // u64 nanoseconds bounds at ~584 years of countdown — comfortably
// beyond any practical Duration. Storing the original u128 from
// `Duration::as_nanos()` would force u128 saturating_sub on every
// `decrement()` call; on 64-bit it's 4-6 instructions per entry vs
// 1-2 for u64. The ceiling is enforced by saturating-cast at insert.
pub remaining_ns: u64,
} }
impl<const N: usize> Default for TimerTable<N> { impl<const N: usize> Default for TimerTable<N> {
@@ -354,7 +833,12 @@ pub mod __private {
} }
} }
pub fn start(&mut self, state: u16, trigger: u16, duration: core::time::Duration) { pub fn start(&mut self, state: u16, trigger: u16, duration: core::time::Duration) {
let ns = duration.as_nanos(); // Truncating u128→u64. u64 nanoseconds bounds at ~584 years —
// past that the cast wraps, but no realistic timer duration
// gets near it (Duration::from_secs(u64::MAX) overflows long
// before, and from_millis/from_nanos take u64 directly so
// round-trip exactly).
let ns = duration.as_nanos() as u64;
// Replace existing entry for same (state, trigger) — resets on re-entry. // Replace existing entry for same (state, trigger) — resets on re-entry.
for e in self.entries.iter_mut() { for e in self.entries.iter_mut() {
if e.state == state && e.trigger == trigger { if e.state == state && e.trigger == trigger {
@@ -368,22 +852,51 @@ pub mod __private {
remaining_ns: ns, remaining_ns: ns,
}); });
} }
/// Cancel every timer owned by `state`. Inlined empty-check so an
/// `exit_state` on a chart with no live timers compiles to a single
/// branch — the `retain` call (and its closure) stays out-of-line.
#[inline]
pub fn cancel_state(&mut self, state: u16) { pub fn cancel_state(&mut self, state: u16) {
self.entries.retain(|e| e.state != state); if !self.entries.is_empty() {
self.entries.retain(|e| e.state != state);
}
} }
pub fn cancel_one(&mut self, state: u16, trigger: u16) { pub fn cancel_one(&mut self, state: u16, trigger: u16) {
self.entries self.entries
.retain(|e| !(e.state == state && e.trigger == trigger)); .retain(|e| !(e.state == state && e.trigger == trigger));
} }
/// Subtract `elapsed` from every live timer (saturating at 0). Inlined
/// empty-check so the per-`step()` call costs one branch when no
/// timers are armed.
#[inline]
pub fn decrement(&mut self, elapsed: core::time::Duration) { pub fn decrement(&mut self, elapsed: core::time::Duration) {
let ns = elapsed.as_nanos(); if self.entries.is_empty() {
return;
}
let ns = elapsed.as_nanos() as u64;
for e in self.entries.iter_mut() { for e in self.entries.iter_mut() {
e.remaining_ns = e.remaining_ns.saturating_sub(ns); e.remaining_ns = e.remaining_ns.saturating_sub(ns);
} }
} }
/// Returns (state, trigger) of first expired timer, picking the innermost /// Returns (state, trigger) of first expired timer, picking the innermost
/// (deepest) state, ties broken by declaration order. /// (deepest) state, ties broken by declaration order.
///
/// The empty-table case is split out and `#[inline]`d so charts with no
/// live timers pay only a one-instruction `is_empty` branch per
/// `step()`, not the full function-call + iterator-setup overhead. The
/// non-empty body stays out-of-line to keep the hot path's icache
/// footprint small.
#[inline]
pub fn pop_expired(&mut self, depth: &[u8]) -> Option<(u16, u16)> { pub fn pop_expired(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
if self.entries.is_empty() {
None
} else {
self.pop_expired_nonempty(depth)
}
}
#[cold]
fn pop_expired_nonempty(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
let mut best: Option<usize> = None; let mut best: Option<usize> = None;
for (i, e) in self.entries.iter().enumerate() { for (i, e) in self.entries.iter().enumerate() {
if e.remaining_ns == 0 { if e.remaining_ns == 0 {
@@ -406,12 +919,26 @@ pub mod __private {
None None
} }
} }
/// Smallest `remaining_ns` across live timers, or `None` if there are
/// none. Same inline-the-empty-case treatment as `pop_expired` so
/// timer-free charts pay only a branch per `step()`.
#[inline]
pub fn min_remaining(&self) -> Option<core::time::Duration> { pub fn min_remaining(&self) -> Option<core::time::Duration> {
self.entries.iter().map(|e| e.remaining_ns).min().map(|ns| { if self.entries.is_empty() {
let secs = (ns / 1_000_000_000) as u64; None
let rem = (ns % 1_000_000_000) as u32; } else {
core::time::Duration::new(secs, rem) self.min_remaining_nonempty()
}) }
}
#[cold]
fn min_remaining_nonempty(&self) -> Option<core::time::Duration> {
self.entries
.iter()
.map(|e| e.remaining_ns)
.min()
.map(core::time::Duration::from_nanos)
} }
} }
+19 -6
View File
@@ -12,7 +12,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -117,9 +117,12 @@ async fn det_descent_target_with_three_default_levels() {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Desc::<8>::CHART_HASH, chart_hash: Desc::<8>::CHART_HASH,
}, },
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
TraceEvent::Entered { state: SI }, TraceEvent::EnterBegan { state: SI },
entry(SI, A_IDLE_IN), entry(SI, A_IDLE_IN),
TraceEvent::Entered { state: SI },
TraceEvent::EventReceived { event: E_GO },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SI, handler_state: SI,
event: E_GO, event: E_GO,
@@ -127,17 +130,27 @@ async fn det_descent_target_with_three_default_levels() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SI), from: Some(SI),
to: ST, to: ST,
reason: TransitionReason::Event { event: E_GO },
}, },
TraceEvent::ExitBegan { state: SI },
exit_(SI, A_IDLE_OUT), exit_(SI, A_IDLE_OUT),
TraceEvent::Exited { state: SI }, TraceEvent::Exited { state: SI },
TraceEvent::Entered { state: ST }, TraceEvent::EnterBegan { state: ST },
entry(ST, A_TOWER_IN), entry(ST, A_TOWER_IN),
TraceEvent::Entered { state: SL1 }, TraceEvent::Entered { state: ST },
TraceEvent::EnterBegan { state: SL1 },
entry(SL1, A_L1_IN), entry(SL1, A_L1_IN),
TraceEvent::Entered { state: SL2 }, TraceEvent::Entered { state: SL1 },
TraceEvent::EnterBegan { state: SL2 },
entry(SL2, A_L2_IN), entry(SL2, A_L2_IN),
TraceEvent::Entered { state: SL3 }, TraceEvent::Entered { state: SL2 },
TraceEvent::EnterBegan { state: SL3 },
entry(SL3, A_L3_IN), entry(SL3, A_L3_IN),
TraceEvent::Entered { state: SL3 },
TraceEvent::TransitionComplete {
from: Some(SI),
to: ST,
},
]; ];
assert_eq!(actual, expected); assert_eq!(actual, expected);
+16 -5
View File
@@ -127,27 +127,38 @@ async fn det_initial_descent_records_full_path() {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Init4::<8>::CHART_HASH, chart_hash: Init4::<8>::CHART_HASH,
}, },
TraceEvent::Entered { state: SR }, TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN), entry(SR, A_R_IN),
TraceEvent::Entered { state: SA }, TraceEvent::Entered { state: SR },
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SB }, TraceEvent::Entered { state: SA },
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SC }, TraceEvent::Entered { state: SB },
TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN), entry(SC, A_C_IN),
TraceEvent::Entered { state: SD }, TraceEvent::Entered { state: SC },
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SD },
// Halt. // Halt.
TraceEvent::EventReceived { event: E_HALT },
TraceEvent::TerminateRequested { event: E_HALT }, TraceEvent::TerminateRequested { event: E_HALT },
// Bottom-up exit chain. // Bottom-up exit chain.
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT), exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD }, TraceEvent::Exited { state: SD },
TraceEvent::ExitBegan { state: SC },
exit_(SC, A_C_OUT), exit_(SC, A_C_OUT),
TraceEvent::Exited { state: SC }, TraceEvent::Exited { state: SC },
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT), exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB }, TraceEvent::Exited { state: SB },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SR },
exit_(SR, A_R_OUT), exit_(SR, A_R_OUT),
TraceEvent::Exited { state: SR }, TraceEvent::Exited { state: SR },
TraceEvent::Terminated, TraceEvent::Terminated,
+73 -12
View File
@@ -11,7 +11,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -146,11 +146,14 @@ fn initial_descent() -> Vec<TraceEvent> {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Lat::<8>::CHART_HASH, chart_hash: Lat::<8>::CHART_HASH,
}, },
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
TraceEvent::Entered { state: SP }, TraceEvent::EnterBegan { state: SP },
entry(SP, A_P_IN), entry(SP, A_P_IN),
TraceEvent::Entered { state: SA }, TraceEvent::Entered { state: SP },
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SA },
] ]
} }
@@ -185,6 +188,7 @@ async fn det_lateral_a_to_b_lca_is_parent() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOB },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SA, handler_state: SA,
event: E_GOB, event: E_GOB,
@@ -192,11 +196,18 @@ async fn det_lateral_a_to_b_lca_is_parent() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SA), from: Some(SA),
to: SB, to: SB,
reason: TransitionReason::Event { event: E_GOB },
}, },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::Entered { state: SB }, TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -215,6 +226,7 @@ async fn det_lateral_a_to_c_exits_parent() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOC },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SA, handler_state: SA,
event: E_GOC, event: E_GOC,
@@ -222,13 +234,21 @@ async fn det_lateral_a_to_c_exits_parent() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SA), from: Some(SA),
to: SC, to: SC,
reason: TransitionReason::Event { event: E_GOC },
}, },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT), exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP }, TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SC }, TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN), entry(SC, A_C_IN),
TraceEvent::Entered { state: SC },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SC,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -246,6 +266,7 @@ async fn det_lateral_target_with_children_default_descends() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOD },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SA, handler_state: SA,
event: E_GOD, event: E_GOD,
@@ -253,15 +274,24 @@ async fn det_lateral_target_with_children_default_descends() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SA), from: Some(SA),
to: SD, to: SD,
reason: TransitionReason::Event { event: E_GOD },
}, },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT), exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP }, TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SD }, TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SE }, TraceEvent::Entered { state: SD },
TraceEvent::EnterBegan { state: SE },
entry(SE, A_E_IN), entry(SE, A_E_IN),
TraceEvent::Entered { state: SE },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SD,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -280,6 +310,7 @@ async fn det_lateral_to_deeply_nested_target() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOE },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SA, handler_state: SA,
event: E_GOE, event: E_GOE,
@@ -287,15 +318,24 @@ async fn det_lateral_to_deeply_nested_target() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SA), from: Some(SA),
to: SE, to: SE,
reason: TransitionReason::Event { event: E_GOE },
}, },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT), exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP }, TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SD }, TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SE }, TraceEvent::Entered { state: SD },
TraceEvent::EnterBegan { state: SE },
entry(SE, A_E_IN), entry(SE, A_E_IN),
TraceEvent::Entered { state: SE },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SE,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -316,6 +356,7 @@ async fn det_lateral_chained_transitions() {
let mut expected = initial_descent(); let mut expected = initial_descent();
// A → B // A → B
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOB },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SA, handler_state: SA,
event: E_GOB, event: E_GOB,
@@ -323,14 +364,22 @@ async fn det_lateral_chained_transitions() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SA), from: Some(SA),
to: SB, to: SB,
reason: TransitionReason::Event { event: E_GOB },
}, },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::Entered { state: SB }, TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
]); ]);
// B → C (LCA = Root, so Parent exits too). // B → C (LCA = Root, so Parent exits too).
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOC },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SB, handler_state: SB,
event: E_GOC, event: E_GOC,
@@ -338,13 +387,21 @@ async fn det_lateral_chained_transitions() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SB), from: Some(SB),
to: SC, to: SC,
reason: TransitionReason::Event { event: E_GOC },
}, },
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT), exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB }, TraceEvent::Exited { state: SB },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT), exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP }, TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SC }, TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN), entry(SC, A_C_IN),
TraceEvent::Entered { state: SC },
TraceEvent::TransitionComplete {
from: Some(SB),
to: SC,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -360,9 +417,13 @@ async fn det_lateral_lca_stays_active() {
let _ = m.dispatch(Ev::GoB).await; let _ = m.dispatch(Ev::GoB).await;
let j = m.take_journal(); let j = m.take_journal();
// After initial Entered(Parent), Parent must not appear again. // After initial Entered(Parent), Parent must not appear again.
let initial_p_idx = j
.iter()
.position(|e| matches!(e, TraceEvent::Entered { state: SP }))
.unwrap();
let parent_events: Vec<&TraceEvent> = j let parent_events: Vec<&TraceEvent> = j
.iter() .iter()
.skip(3) .skip(initial_p_idx + 1)
.filter(|e| { .filter(|e| {
matches!( matches!(
e, e,
+15 -4
View File
@@ -17,7 +17,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -101,12 +101,16 @@ async fn det_root_targetable_by_chart_name() {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Sub::<8>::CHART_HASH, chart_hash: Sub::<8>::CHART_HASH,
}, },
TraceEvent::Entered { state: SR }, TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN), entry(SR, A_R_IN),
TraceEvent::Entered { state: SA }, TraceEvent::Entered { state: SR },
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SB }, TraceEvent::Entered { state: SA },
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::EventReceived { event: E_UPTOROOT },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SB, handler_state: SB,
event: E_UPTOROOT, event: E_UPTOROOT,
@@ -114,11 +118,18 @@ async fn det_root_targetable_by_chart_name() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SB), from: Some(SB),
to: SR, to: SR,
reason: TransitionReason::Event { event: E_UPTOROOT },
}, },
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT), exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB }, TraceEvent::Exited { state: SB },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::TransitionComplete {
from: Some(SB),
to: SR,
},
]; ];
assert_eq!( assert_eq!(
+24 -5
View File
@@ -13,7 +13,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -86,11 +86,14 @@ fn initial_descent() -> Vec<TraceEvent> {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Self_::<8>::CHART_HASH, chart_hash: Self_::<8>::CHART_HASH,
}, },
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
TraceEvent::Entered { state: SM }, TraceEvent::EnterBegan { state: SM },
entry(SM, A_M_IN), entry(SM, A_M_IN),
TraceEvent::Entered { state: SL }, TraceEvent::Entered { state: SM },
TraceEvent::EnterBegan { state: SL },
entry(SL, A_L_IN), entry(SL, A_L_IN),
TraceEvent::Entered { state: SL },
] ]
} }
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) { fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
@@ -123,6 +126,7 @@ async fn det_self_leaf_exits_and_reenters_only_leaf() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_SELFLEAF },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SL, handler_state: SL,
event: E_SELFLEAF, event: E_SELFLEAF,
@@ -130,11 +134,18 @@ async fn det_self_leaf_exits_and_reenters_only_leaf() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SL), from: Some(SL),
to: SL, to: SL,
reason: TransitionReason::Event { event: E_SELFLEAF },
}, },
TraceEvent::ExitBegan { state: SL },
exit_(SL, A_L_OUT), exit_(SL, A_L_OUT),
TraceEvent::Exited { state: SL }, TraceEvent::Exited { state: SL },
TraceEvent::Entered { state: SL }, TraceEvent::EnterBegan { state: SL },
entry(SL, A_L_IN), entry(SL, A_L_IN),
TraceEvent::Entered { state: SL },
TraceEvent::TransitionComplete {
from: Some(SL),
to: SL,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -209,6 +220,7 @@ async fn det_self_composite_default_descends() {
// Mid's entry actions don't fire. No default-descent. // Mid's entry actions don't fire. No default-descent.
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_SELFMID },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SM, handler_state: SM,
event: E_SELFMID, event: E_SELFMID,
@@ -216,9 +228,15 @@ async fn det_self_composite_default_descends() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SL), from: Some(SL),
to: SM, to: SM,
reason: TransitionReason::Event { event: E_SELFMID },
}, },
TraceEvent::ExitBegan { state: SL },
exit_(SL, A_L_OUT), exit_(SL, A_L_OUT),
TraceEvent::Exited { state: SL }, TraceEvent::Exited { state: SL },
TraceEvent::TransitionComplete {
from: Some(SL),
to: SM,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -257,7 +275,8 @@ async fn det_self_leaf_repeated_is_deterministic() {
e, e,
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SL), from: Some(SL),
to: SL to: SL,
..
} }
) )
}) })
+4 -2
View File
@@ -85,7 +85,8 @@ async fn det_self_composite_default_descends_after_re_entry() {
e, e,
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SP), from: Some(SP),
to: SP to: SP,
..
} }
) )
}) })
@@ -133,7 +134,8 @@ async fn det_self_composite_action_kinds_in_order() {
e, e,
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SP), from: Some(SP),
to: SP to: SP,
..
} }
) )
}) })
+35 -6
View File
@@ -15,7 +15,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -117,16 +117,21 @@ fn initial_descent() -> Vec<TraceEvent> {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Up::<8>::CHART_HASH, chart_hash: Up::<8>::CHART_HASH,
}, },
TraceEvent::Entered { state: SR }, TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN), entry(SR, A_R_IN),
TraceEvent::Entered { state: SA }, TraceEvent::Entered { state: SR },
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SB }, TraceEvent::Entered { state: SA },
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SC }, TraceEvent::Entered { state: SB },
TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN), entry(SC, A_C_IN),
TraceEvent::Entered { state: SD }, TraceEvent::Entered { state: SC },
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SD },
] ]
} }
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) { fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
@@ -159,6 +164,7 @@ async fn det_up_to_immediate_grandparent() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_UPTOC },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SD, handler_state: SD,
event: E_UPTOC, event: E_UPTOC,
@@ -166,9 +172,15 @@ async fn det_up_to_immediate_grandparent() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SD), from: Some(SD),
to: SC, to: SC,
reason: TransitionReason::Event { event: E_UPTOC },
}, },
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT), exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD }, TraceEvent::Exited { state: SD },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SC,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -186,6 +198,7 @@ async fn det_up_two_levels() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_UPTOB },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SD, handler_state: SD,
event: E_UPTOB, event: E_UPTOB,
@@ -193,11 +206,18 @@ async fn det_up_two_levels() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SD), from: Some(SD),
to: SB, to: SB,
reason: TransitionReason::Event { event: E_UPTOB },
}, },
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT), exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD }, TraceEvent::Exited { state: SD },
TraceEvent::ExitBegan { state: SC },
exit_(SC, A_C_OUT), exit_(SC, A_C_OUT),
TraceEvent::Exited { state: SC }, TraceEvent::Exited { state: SC },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SB,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -215,6 +235,7 @@ async fn det_up_three_levels_to_a() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_UPTOA },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SD, handler_state: SD,
event: E_UPTOA, event: E_UPTOA,
@@ -222,13 +243,21 @@ async fn det_up_three_levels_to_a() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SD), from: Some(SD),
to: SA, to: SA,
reason: TransitionReason::Event { event: E_UPTOA },
}, },
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT), exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD }, TraceEvent::Exited { state: SD },
TraceEvent::ExitBegan { state: SC },
exit_(SC, A_C_OUT), exit_(SC, A_C_OUT),
TraceEvent::Exited { state: SC }, TraceEvent::Exited { state: SC },
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT), exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB }, TraceEvent::Exited { state: SB },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SA,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
+20 -5
View File
@@ -12,7 +12,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -97,19 +97,22 @@ async fn replay_full_lifecycle_matches_expected() {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Replay::<8>::CHART_HASH, chart_hash: Replay::<8>::CHART_HASH,
}, },
TraceEvent::Entered { state: ST_ROOT }, TraceEvent::EnterBegan { state: ST_ROOT },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_ROOT, state: ST_ROOT,
action: A_ROOT_IN, action: A_ROOT_IN,
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_IDLE }, TraceEvent::Entered { state: ST_ROOT },
TraceEvent::EnterBegan { state: ST_IDLE },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_IDLE, state: ST_IDLE,
action: A_IDLE_IN, action: A_IDLE_IN,
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_IDLE },
// ── Go event delivered to Idle, transition to Active → Sub ── // ── Go event delivered to Idle, transition to Active → Sub ──
TraceEvent::EventReceived { event: E_GO },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: ST_IDLE, handler_state: ST_IDLE,
event: E_GO, event: E_GO,
@@ -117,39 +120,51 @@ async fn replay_full_lifecycle_matches_expected() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(ST_IDLE), from: Some(ST_IDLE),
to: ST_ACTIVE, to: ST_ACTIVE,
reason: TransitionReason::Event { event: E_GO },
}, },
TraceEvent::ExitBegan { state: ST_IDLE },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_IDLE, state: ST_IDLE,
action: A_IDLE_OUT, action: A_IDLE_OUT,
kind: ActionKind::Exit, kind: ActionKind::Exit,
}, },
TraceEvent::Exited { state: ST_IDLE }, TraceEvent::Exited { state: ST_IDLE },
TraceEvent::Entered { state: ST_ACTIVE }, TraceEvent::EnterBegan { state: ST_ACTIVE },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_ACTIVE, state: ST_ACTIVE,
action: A_ACTIVE_IN, action: A_ACTIVE_IN,
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_SUB }, TraceEvent::Entered { state: ST_ACTIVE },
TraceEvent::EnterBegan { state: ST_SUB },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_SUB, state: ST_SUB,
action: A_SUB_IN, action: A_SUB_IN,
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_SUB },
TraceEvent::TransitionComplete {
from: Some(ST_IDLE),
to: ST_ACTIVE,
},
// ── Halt event triggers termination: exit Sub → Active → root, then Terminated ── // ── Halt event triggers termination: exit Sub → Active → root, then Terminated ──
TraceEvent::EventReceived { event: E_HALT },
TraceEvent::TerminateRequested { event: E_HALT }, TraceEvent::TerminateRequested { event: E_HALT },
TraceEvent::ExitBegan { state: ST_SUB },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_SUB, state: ST_SUB,
action: A_SUB_OUT, action: A_SUB_OUT,
kind: ActionKind::Exit, kind: ActionKind::Exit,
}, },
TraceEvent::Exited { state: ST_SUB }, TraceEvent::Exited { state: ST_SUB },
TraceEvent::ExitBegan { state: ST_ACTIVE },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_ACTIVE, state: ST_ACTIVE,
action: A_ACTIVE_OUT, action: A_ACTIVE_OUT,
kind: ActionKind::Exit, kind: ActionKind::Exit,
}, },
TraceEvent::Exited { state: ST_ACTIVE }, TraceEvent::Exited { state: ST_ACTIVE },
TraceEvent::ExitBegan { state: ST_ROOT },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_ROOT, state: ST_ROOT,
action: A_ROOT_OUT, action: A_ROOT_OUT,
+429
View File
@@ -0,0 +1,429 @@
//! Verifies the textual format of the unified observation pipeline's
//! `trace-log` sink line-for-line.
//!
//! Each `__chart_observe!` arm has a stable logfmt-style format:
//!
//! `[statechart:<Name>] <verb> key=value key=value …`
//!
//! These tests pin every verb's exact rendering so a real-life log capture
//! can be diffed against expected behavior. The chart is exercised through
//! both the journal sink (so we can assert atom-by-atom equivalence) and
//! the trace-log sink (so we can assert the rendered strings).
#![cfg(all(feature = "tokio", feature = "journal", feature = "trace-log"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, Duration, TraceEvent, TransitionReason};
use std::sync::{Mutex, MutexGuard, OnceLock};
// ── Capture sink ─────────────────────────────────────────────────────
//
// log::set_logger can only be installed once per process. The capture
// holds a single Vec<String> shared across all tests, plus a global
// `TEST_LOCK` that each test holds across its drive-then-assert
// section so concurrent tests don't interleave their captured lines.
struct Capture {
lines: Mutex<Vec<String>>,
}
impl log::Log for Capture {
fn enabled(&self, _: &log::Metadata<'_>) -> bool {
true
}
fn log(&self, record: &log::Record<'_>) {
if let Ok(mut g) = self.lines.lock() {
g.push(format!("{}", record.args()));
}
}
fn flush(&self) {}
}
static CAPTURE: OnceLock<&'static Capture> = OnceLock::new();
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn install_capture() -> &'static Capture {
*CAPTURE.get_or_init(|| {
let leaked: &'static Capture = Box::leak(Box::new(Capture {
lines: Mutex::new(Vec::new()),
}));
let _ = log::set_logger(leaked);
log::set_max_level(log::LevelFilter::Trace);
leaked
})
}
/// RAII helper. Acquire before driving the chart, drop after assertions
/// — keeps `tests::*` from interleaving captured log lines.
fn lock_for_test() -> MutexGuard<'static, ()> {
install_capture();
let g = TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
// Clear any lines left by a previous test that paniced before
// draining (poison case).
let cap = CAPTURE.get().unwrap();
cap.lines.lock().unwrap().clear();
g
}
fn current_lines() -> Vec<String> {
let cap = install_capture();
cap.lines.lock().unwrap().clone()
}
// ── Test chart ───────────────────────────────────────────────────────
//
// Covers every observation verb the macro can emit. Two states with
// entry/exit actions, a handler that emits and transitions, a timer,
// a during, and a terminate event.
#[derive(Default)]
pub struct Ctx {
pub buf: u32,
}
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Step,
Halt,
}
statechart! {
TraceFmt {
context: Ctx;
events: Ev;
default(Idle);
terminate(Halt);
state Idle {
entry: idle_in;
exit: idle_out;
during: tick(buf);
on(after Duration::from_secs(60)) => Idle;
on(Go) => Active;
}
state Active {
entry: active_in;
exit: active_out;
on(Step) => bump;
on(Halt) => Idle;
}
}
}
async fn tick(_buf: &mut u32) -> Ev {
Ev::Step
}
impl TraceFmtActions for TraceFmtActionContext<'_> {
async fn idle_in(&mut self) {}
async fn idle_out(&mut self) {}
async fn active_in(&mut self) {}
async fn active_out(&mut self) {}
async fn bump(&mut self) {
let _ = self.emit(Ev::Halt);
}
}
// ── Helpers ──────────────────────────────────────────────────────────
/// Returns true iff `line` matches the expected `[statechart:TraceFmt] verb …`
/// shape. Every line emitted by the dispatcher must.
fn well_formed(line: &str) -> bool {
line.starts_with("[statechart:TraceFmt] ")
}
/// Filter `lines` to only those whose verb (the first token after the
/// prefix) equals `verb`. Useful for asserting on a single verb's lines.
fn lines_with_verb<'a>(lines: &'a [String], verb: &str) -> Vec<&'a str> {
let prefix = "[statechart:TraceFmt] ";
lines
.iter()
.filter_map(|l| l.strip_prefix(prefix))
.filter(|rest| rest.split_whitespace().next() == Some(verb))
.map(|s| s.as_ref())
.collect()
}
// ── Tests ────────────────────────────────────────────────────────────
#[tokio::test(flavor = "current_thread")]
async fn every_line_uses_statechart_prefix() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
let _ = m.dispatch(Ev::Halt).await;
})
.await;
let lines = current_lines();
assert!(!lines.is_empty(), "no log lines captured");
for l in &lines {
assert!(
well_formed(l),
"line missing [statechart:TraceFmt] prefix: {l:?}"
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn enter_and_exit_emit_begin_end_pairs() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let entering = lines_with_verb(&lines, "entering");
let entered = lines_with_verb(&lines, "entered");
assert_eq!(
entering.len(),
entered.len(),
"every `entering` must have a matching `entered`"
);
assert!(entering.contains(&"entering state=TraceFmt"));
assert!(entered.contains(&"entered state=TraceFmt"));
assert!(entering.contains(&"entering state=Idle"));
assert!(entered.contains(&"entered state=Idle"));
}
#[tokio::test(flavor = "current_thread")]
async fn transition_records_event_reason() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let begins = lines_with_verb(&lines, "transition-begin");
assert!(!begins.is_empty(), "no transition-begin lines captured");
let line = begins[0];
assert!(
line.contains("from=Idle"),
"transition-begin missing from=Idle: {line:?}"
);
assert!(
line.contains("to=Active"),
"transition-begin missing to=Active: {line:?}"
);
assert!(
line.contains("reason=event:Go"),
"transition-begin missing reason=event:Go: {line:?}"
);
let completes = lines_with_verb(&lines, "transition-complete");
assert!(
!completes.is_empty(),
"transition-complete must follow transition-begin"
);
assert!(completes[0].contains("from=Idle") && completes[0].contains("to=Active"));
}
#[tokio::test(flavor = "current_thread")]
async fn event_lifecycle_received_then_outcome() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let received = lines_with_verb(&lines, "event-received");
let delivered = lines_with_verb(&lines, "event-delivered");
assert!(received.iter().any(|l| l.contains("name=Go")));
assert!(delivered
.iter()
.any(|l| l.contains("name=Go") && l.contains("handler=Idle")));
}
#[tokio::test(flavor = "current_thread")]
async fn during_emits_started_and_cancelled() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
// Go enters Active, which causes Idle's during to be cancelled.
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let started = lines_with_verb(&lines, "during-started");
let cancelled = lines_with_verb(&lines, "during-cancelled");
assert!(started.iter().any(|l| l.contains("during=tick")));
assert!(cancelled.iter().any(|l| l.contains("during=tick")));
}
#[tokio::test(flavor = "current_thread")]
async fn timer_arm_then_cancel_lines() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let armed = lines_with_verb(&lines, "timer-armed");
let cancelled = lines_with_verb(&lines, "timer-cancelled");
assert!(
armed.iter().any(|l| l.contains("state=Idle")),
"timer should arm when Idle is entered"
);
assert!(
cancelled.iter().any(|l| l.contains("state=Idle")),
"timer should be cancelled when Idle is exited (Go transition)"
);
}
#[tokio::test(flavor = "current_thread")]
async fn emit_queued_appears_for_handler_emit() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
// Step's handler calls emit(Halt).
let _ = m.dispatch(Ev::Step).await;
})
.await;
let lines = current_lines();
let queued = lines_with_verb(&lines, "emit-queued");
assert!(
queued.iter().any(|l| l.contains("event=Halt")),
"expected emit-queued event=Halt line: {lines:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn terminate_records_request_then_terminated() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
// Halt from Active is a transition to Idle (no terminate);
// dispatch Halt twice via Idle to actually terminate.
// First Go landed us in Active; Halt → Idle (handler).
let _ = m.dispatch(Ev::Halt).await; // back to Idle
let _ = m.dispatch(Ev::Halt).await; // now terminate
})
.await;
let lines = current_lines();
let req = lines_with_verb(&lines, "terminate-requested");
let term = lines_with_verb(&lines, "terminated");
assert!(
req.iter().any(|l| l.contains("event=Halt")),
"expected terminate-requested event=Halt: {lines:?}"
);
assert!(
!term.is_empty(),
"expected a final `terminated` line: {lines:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn started_records_chart_hash() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let started = lines_with_verb(&lines, "started");
assert!(!started.is_empty(), "expected one `started` line");
assert!(
started[0].contains("chart_hash=0x"),
"started must include chart_hash in hex: {:?}",
started[0]
);
}
#[tokio::test(flavor = "current_thread")]
async fn trace_and_journal_have_matching_atom_count() {
// The "one journal, multiple outputs" contract: every TraceEvent
// recorded in the journal corresponds to exactly one log line in
// the trace, modulo verb-name differences.
let _g = lock_for_test();
let actual = tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
m.take_journal()
})
.await;
let lines = current_lines();
assert_eq!(
actual.len(),
lines.len(),
"journal/trace atom count diverged ({} vs {} lines)",
actual.len(),
lines.len()
);
}
#[tokio::test(flavor = "current_thread")]
async fn transition_reason_in_journal_matches_trace() {
// The journal records the structured `TransitionReason::Event { event }`
// and the trace renders it as `event:<VariantName>`. They must agree.
let _g = lock_for_test();
let actual = tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
m.take_journal()
})
.await;
let lines = current_lines();
let journal_reason = actual.iter().find_map(|e| match e {
TraceEvent::TransitionFired { reason, .. } => Some(*reason),
_ => None,
});
let trace_reason_line = lines_with_verb(&lines, "transition-begin")
.first()
.copied()
.map(|s| s.to_string());
assert_eq!(
journal_reason,
Some(TransitionReason::Event { event: 0 }),
"expected first transition driven by Ev::Go (event id 0)"
);
assert!(
trace_reason_line
.as_deref()
.is_some_and(|l| l.contains("reason=event:Go")),
"trace reason should match journal reason: {trace_reason_line:?}"
);
}