Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb38d936dc
|
||
|
|
a068a982f7
|
||
|
|
5f933f98fa
|
||
|
|
d8bda344fd
|
||
|
|
2d38af60f6
|
||
|
|
ad2aa6e04d
|
||
|
|
98352b0e4d
|
||
|
|
b9645f80cb
|
||
|
|
da1f3046b4
|
||
|
|
2f37f261c5
|
||
|
|
8cd1aea646
|
||
|
|
d5ef6a6944
|
||
|
|
915b7ce101
|
||
|
|
1f98a9bf5b
|
||
|
|
9eb1d10b28
|
||
|
|
3fc0d7c57d
|
||
|
|
2e55484080
|
||
|
|
dc97419161
|
||
|
|
e633f5dc55
|
||
|
|
4ea7463089
|
No files matched your search
+409
@@ -4,6 +4,415 @@ All notable changes to this workspace are documented here. The format
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## 0.6.0 — 2026-05-04
|
||||
|
||||
Performance pass on the dispatch hot path + a fully-audited bounded
|
||||
`unsafe` surface in the generated code. The bench harness moves from
|
||||
`iai-callgrind` to `gungraun` (the renamed continuation, since
|
||||
iai-callgrind is frozen at 0.16.1).
|
||||
|
||||
### Performance
|
||||
|
||||
Two optimization passes land. Bench impact (instruction counts vs.
|
||||
the post-migration baseline; release-mode, valgrind 3.25.1):
|
||||
|
||||
```
|
||||
before after Δ
|
||||
h2_cold_start 131 24 -82%
|
||||
h2_warm_lateral 119 53 -55%
|
||||
h8_cross_tree 97 53 -45%
|
||||
bubble_cross_tree 120 85 -29%
|
||||
h4_cross_tree / h4_self 128 93 -27%
|
||||
bubble_leaf/mid/top 129 94 -27%
|
||||
h4_up 114 88 -23%
|
||||
terminate_event 68 53 -22%
|
||||
timer_fire_dispatch 221 175 -21%
|
||||
chain_self_emit 500 439 -12%
|
||||
```
|
||||
|
||||
- **`get_unchecked` on codegen-emitted dispatch tables.** `__DISPATCH`,
|
||||
`__DEFAULT_CHILD`, `__PATH_RANGE`, `__PATH_DATA`, `__TERMINATE_RANGE`,
|
||||
and `__TERMINATE_DATA` are all indexed by codegen-produced state ids
|
||||
whose bounds are guaranteed at codegen time. Replacing the
|
||||
bounds-checked `[idx]` / `[s..e]` forms with `slice::get_unchecked`
|
||||
unlocks aggressive inlining beyond what plain bounds-check elision
|
||||
would buy: `apply_path` inlines into `step()` for h2 / h4 / h8 /
|
||||
bubble; `step()` itself inlines into the bench wrapper for
|
||||
h2_cold_start / h2_warm_lateral / h8_cross_tree. The cold-path enter
|
||||
chain on h2_cold_start collapses to a single `u16` store.
|
||||
- **`pop_expired_nonempty` inlining.** The `#[cold]` attribute was
|
||||
forcing the body out-of-line at every step()-with-timers call site,
|
||||
paying full x86_64 ABI prologue/epilogue (~10–15 instructions) per
|
||||
call. Drop `#[cold]` and add `#[inline]` so LLVM folds it into the
|
||||
codegen-emitted timer-pop block. As a knock-on effect, `run_handlers`
|
||||
also inlines into `step()` on the timer path.
|
||||
|
||||
### Bounded `unsafe` in generated code (was: "no `unsafe`")
|
||||
|
||||
The performance work introduces six bounded `unsafe { get_unchecked }`
|
||||
blocks per generated chart. User code (chart definitions, action
|
||||
bodies, `during:` blocks, public API surface) remains `unsafe`-free.
|
||||
The README and spec rule 12 are updated to reflect this honestly. The
|
||||
audit story is documented end-to-end in
|
||||
[`docs/004. unsafe-safety-contract.md`](docs/004.%20unsafe-safety-contract.md);
|
||||
a short summary:
|
||||
|
||||
- **Compile-time const-eval.** Each chart now contains a
|
||||
`const _: () = { ... };` block that re-states the safety preconditions
|
||||
as `assert!`s evaluated by `rustc`'s const evaluator at user
|
||||
`cargo check` time. Any chart whose emitted tables violate the
|
||||
invariants fails the build with a named diagnostic
|
||||
(`E0080: evaluation panicked: ...`) before reaching runtime.
|
||||
- **Runtime `debug_assert!` companions.** Every `unsafe` block has a
|
||||
paired `debug_assert!` of the same predicate. Free in `--release`;
|
||||
in dev/test/miri builds, every dispatch through the 68 hsmc behavior
|
||||
tests exercises the assertions.
|
||||
- **Miri.** `cargo +nightly miri test -p hsmc` runs 20 unit + 46
|
||||
behavior tests under the UB interpreter. All clean.
|
||||
- **Mutation testing.** `cargo mutants` against the safety-relevant
|
||||
codegen functions: 99 of 101 mutants caught (98%) when run with
|
||||
`--test-package hsmc --features hsmc/tokio,hsmc/journal`. The 2
|
||||
remaining are equivalent mutants — transformations that provably
|
||||
produce identical `__PATH_RANGE` / `__PATH_DATA` output and so
|
||||
cannot be distinguished by any behavior test (formal analysis in
|
||||
the safety contract doc).
|
||||
- **Creusot-verified validator.** `verification/src/paths.rs` carries
|
||||
Pearlite-annotated functions that decide the safety predicates for
|
||||
arbitrary inputs; their `#[ensures(... forall ...)]` clauses pin the
|
||||
universal property the const-eval block evaluates per chart.
|
||||
`correspondence_paths.rs` includes a test that directly compares the
|
||||
validator and the const-eval block's predicate expression on
|
||||
representative and adversarial inputs.
|
||||
|
||||
`just safety` runs the const-eval, debug_assert, miri, and mutants
|
||||
phases as one command and mirrors the audit matrix in
|
||||
`docs/004. unsafe-safety-contract.md`. `just verify` discharges the
|
||||
Creusot VCs.
|
||||
|
||||
### Bench harness migration: `iai-callgrind` → `gungraun`
|
||||
|
||||
`iai-callgrind` on crates.io is frozen at 0.16.1; the project has been
|
||||
renamed to `gungraun` (same maintainers, same valgrind backend, same
|
||||
callgrind output format — `github.com/iai-callgrind/iai-callgrind`
|
||||
redirects to `github.com/gungraun/gungraun`). Migration is mechanical:
|
||||
|
||||
- `Cargo.toml` dev-dep: `iai-callgrind = "0.16"` → `gungraun = "0.18"`
|
||||
- `mise.toml`: replace the `iai-callgrind-runner` install with
|
||||
`cargo:gungraun-runner`
|
||||
- benchmark source: `use iai_callgrind::...` → `use gungraun::...`
|
||||
(macro names identical: `library_benchmark`, `library_benchmark_group`,
|
||||
`main!`)
|
||||
- output dir: `target/iai/...` → `target/gungraun/...`
|
||||
- environment variables: `IAI_CALLGRIND_*` → `GUNGRAUN_*`
|
||||
|
||||
Absolute instruction counts shifted slightly between the two harnesses
|
||||
because gungraun's bench wrapper instruments a few more setup/teardown
|
||||
instructions per bench (~+4–5 per bench, all in harness, none in HSM).
|
||||
The historical iai-callgrind numbers are kept in
|
||||
`hsmc/benches/baseline.txt` for reference but the post-migration
|
||||
`gungraun_initial` is the comparison point going forward.
|
||||
|
||||
### Documentation
|
||||
|
||||
- New: `docs/004. unsafe-safety-contract.md` — full inventory of the
|
||||
six unsafe blocks, the invariants they rely on, and the five-layer
|
||||
audit (const-eval, debug_assert, miri, mutants, Creusot validator).
|
||||
- Updated: `docs/000` rule 12 — was "no unsafe in generated code", now
|
||||
"bounded unsafe in generated code, none in user code" with a pointer
|
||||
to docs/004.
|
||||
- Updated: `verification/INVARIANTS.md` — new "Path tables" section
|
||||
with five spec rows for the path-table predicates. Phase 4 (universal
|
||||
proof of `compute_transition_paths`) reclassified as Phase 5 since
|
||||
the per-chart const-eval + verified validator is operationally
|
||||
equivalent for end users.
|
||||
- Updated: README — replaces the bare "no `unsafe`" claim with an
|
||||
honest one-paragraph summary pointing at docs/004; install snippets
|
||||
bumped to `version = "0.6"`.
|
||||
- New: `just safety` recipe wires up the full unsafe-audit matrix.
|
||||
|
||||
## 0.5.1 — 2026-04-29
|
||||
|
||||
Docs and licensing cleanup. No code or behavior changes.
|
||||
|
||||
- **License simplified to MIT.** The crate was previously dual-licensed
|
||||
MIT OR Apache-2.0; it is now MIT-only. `LICENSE-APACHE` removed,
|
||||
`LICENSE-MIT` renamed to `LICENSE`, workspace `license` field is
|
||||
now `"MIT"`. Copyright (c) 2026 Stephen Waits <steve@waits.net>.
|
||||
- **README rewritten reader-first.** Restructured top-to-bottom so a
|
||||
reader who's never used statecharts can follow the document linearly:
|
||||
a working Blinker chart is now the first concrete artifact; the
|
||||
Quickstart sits directly under it; the larger Radio example
|
||||
(events / payloads / `during:`) follows; the formal eight-rule
|
||||
semantics live below all of that as reference rather than as a
|
||||
prerequisite. The "What it does" feature list is rewritten in
|
||||
user-benefit-first language. The "What `hsmc` deliberately does NOT
|
||||
have" table is promoted from a sub-section to its own top-level
|
||||
section. No semantic changes — the formal rules' content is
|
||||
untouched, only repositioned.
|
||||
- **Tokio integration story corrected.** The README previously implied
|
||||
driving a chart under tokio required `tokio::task::LocalSet`. The
|
||||
normal path is `#[tokio::main(flavor = "current_thread")]` — one
|
||||
line, no extra wrapping, and how the existing examples have always
|
||||
worked. `LocalSet` no longer appears in the README at all.
|
||||
- **`microwave_tui` example dropped.** The ratatui/crossterm TUI
|
||||
variant of the microwave was overkill for a library example and
|
||||
pulled in three dev-dependencies (`ratatui`, `crossterm`, `futures`)
|
||||
that nothing else used. The `microwave` example covers the same
|
||||
feature surface in plain Rust.
|
||||
- **Observability framed honestly.** Enabling `defmt` / `log` /
|
||||
`tracing` output for a chart is one `trace-*` Cargo feature on
|
||||
hsmc's side, plus whatever your downstream crate already does to
|
||||
init its logger (e.g. `tracing_subscriber::fmt::init()` or
|
||||
`env_logger::init()`) — hsmc deliberately doesn't init a logger,
|
||||
per the standard Rust library convention. The Observability
|
||||
section now says so directly and includes a runnable end-to-end
|
||||
snippet using `tracing-subscriber`. The `trace-*` features are
|
||||
also listed in the main feature-flags table so they're
|
||||
discoverable at a glance.
|
||||
- **Inline examples corrected.** The Quickstart Blinker now has both
|
||||
`light_on` and `light_off` actions (it previously had only
|
||||
`light_on`, which left the LED on the whole time). The Embassy
|
||||
snippet no longer declares an unused event channel for what is a
|
||||
timer-only chart.
|
||||
- **Eight-rule order finalized.** The "How a chart behaves" rules are
|
||||
now strictly bottom-up: active path → transitions (LCA-aware) →
|
||||
up-transition rule → entry/exit ordering → `default(...)` (a
|
||||
transition fired on entry) → event bubbling → timers →
|
||||
emit/during/termination. Cross-references updated.
|
||||
|
||||
## 0.5.0 — 2026-04-28
|
||||
|
||||
### `default(...)` is a real LCA-aware transition
|
||||
|
||||
`default(...)` is now modeled the same way as any event-driven
|
||||
transition. It fires immediately after the declaring state's entries
|
||||
finish and its durings start, applying the standard exit-to-LCA /
|
||||
enter-to-target algorithm. Three behavioral changes follow from this:
|
||||
|
||||
- **Optional.** A state with children no longer must declare a
|
||||
`default(...)`. When omitted, the state itself is the resting
|
||||
innermost active state; substates are reachable only via explicit
|
||||
transitions. This is the "microwave Standby" pattern — `Standby`
|
||||
has substates like `LowPower` you enter only on demand, and
|
||||
`=> Standby;` lands at `Standby` itself with no descent.
|
||||
- **Targets anywhere.** The default target may be any state in the
|
||||
chart — a child (the classic descent case), a sibling, an ancestor,
|
||||
anywhere. `default(<sibling-or-ancestor>)` exits the just-entered
|
||||
state as part of the default-fire transition; the same uniform
|
||||
rule covers descent, redirection, and up-bounce.
|
||||
- **Cycle-checked at compile time.** The default-edge graph
|
||||
(out-degree ≤ 1 per node) is rejected if it contains any cycle,
|
||||
including `default(self)`. The check guarantees default chains
|
||||
terminate at runtime — entering any state in a cycle would loop
|
||||
forever before reaching user code.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- **Journal format.** Default-fires now emit `TransitionFired` (with
|
||||
`reason: TransitionReason::Internal`) and `TransitionComplete`
|
||||
brackets around their entry sequence. `Internal` was previously
|
||||
reserved and never emitted; it is now active. Charts that
|
||||
hand-build expected journal sequences (replay-style tests) must
|
||||
include these brackets around every default-fire — see the
|
||||
per-test diffs in this release for the pattern.
|
||||
- **Validation.** Two compile-time errors went away:
|
||||
`default(<non-direct-child>)` is now valid (the target may be
|
||||
anywhere), and `default` on a childless state is now valid
|
||||
(the leaf transitions out immediately on entry). Code relying
|
||||
on these as guard rails must adapt. A new compile-time error
|
||||
takes their place: any cycle in the default graph is rejected
|
||||
with a pinpoint message naming the cycle (e.g.
|
||||
`default-transition cycle detected: A -> B -> A`).
|
||||
|
||||
### Other
|
||||
|
||||
- README and `docs/002. hsmc-semantics-formal.md` rewritten so the
|
||||
default-as-transition framing is the primary description; the old
|
||||
"automatic transition into a child" language is gone.
|
||||
- New integration tests covering each branch of the new semantics:
|
||||
`det_optional_default` (microwave pattern), `det_default_sibling`
|
||||
(lateral default), `det_default_chained` (multi-link default
|
||||
chain), `det_default_ancestor` (up-default landing on an
|
||||
ancestor).
|
||||
- New compile-fail UI fixtures for cycle detection
|
||||
(`t9_4_default_self_cycle`, `t9_5_default_two_cycle`) and new
|
||||
compile-pass fixtures for non-child default targets
|
||||
(`composite_without_default`, `default_targets_sibling`,
|
||||
`default_targets_ancestor`).
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+82
-587
@@ -2,71 +2,39 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "creusot-std"
|
||||
version = "0.9.0"
|
||||
@@ -94,66 +62,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"crossterm_winapi",
|
||||
"futures-core",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "0.3.100"
|
||||
@@ -169,7 +77,7 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"bitflags",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
@@ -195,6 +103,27 @@ dependencies = [
|
||||
"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]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
@@ -204,12 +133,6 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embassy-futures"
|
||||
version = "0.1.2"
|
||||
@@ -301,116 +224,61 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "gungraun"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bfa183cef2c88324b20de9727befd262c35592d9885ffd93b8b102536a81cf2"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"derive_more",
|
||||
"gungraun-macros",
|
||||
"gungraun-runner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gungraun-macros"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e35c7fb6133421db1cf752b7a2838d9277a26810ccaeeca7aa449f96ad7c2b01"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gungraun-runner"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ebdac6d5fe0aa9d9623d690fbfdb759e0ed44115e0685b9abc4568fe47db364"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
@@ -420,17 +288,6 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
@@ -457,26 +314,18 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hsmc"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"defmt 0.3.100",
|
||||
"embassy-futures",
|
||||
"embassy-sync",
|
||||
"embassy-time",
|
||||
"futures",
|
||||
"gungraun",
|
||||
"heapless 0.8.0",
|
||||
"hsmc-macros",
|
||||
"log",
|
||||
"ratatui",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"trybuild",
|
||||
@@ -484,7 +333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hsmc-macros"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"hsmc",
|
||||
"proc-macro2",
|
||||
@@ -495,18 +344,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hsmc-verification"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"creusot-std",
|
||||
"hsmc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -514,38 +357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -554,66 +366,24 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nb"
|
||||
version = "0.1.3"
|
||||
@@ -668,35 +438,6 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -744,65 +485,19 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.28.1"
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm",
|
||||
"instability",
|
||||
"itertools",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
@@ -811,6 +506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -855,89 +551,12 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -1081,87 +700,27 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "void"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1171,70 +730,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.1"
|
||||
|
||||
+4
-4
@@ -3,9 +3,9 @@ resolver = "2"
|
||||
members = ["hsmc", "hsmc-macros", "verification"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
license = "MIT"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
repository = "https://github.com/swaits/hsmc"
|
||||
homepage = "https://github.com/swaits/hsmc"
|
||||
repository = "https://git.swaits.com/swaits/hsmc"
|
||||
homepage = "https://git.swaits.com/swaits/hsmc"
|
||||
File renamed without changes.
-201
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Support. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or support.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Stephen Waits
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied. See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
@@ -1,10 +1,98 @@
|
||||
# hsmc — Hierarchical State Machines for Rust
|
||||
|
||||
`hsmc` is a declarative, proc-macro-based statechart crate for Rust. It targets
|
||||
embedded Rust (embassy) as the primary runtime and supports `tokio` via
|
||||
`LocalSet`. Machines are compiled from a `statechart!` block into
|
||||
monomorphized code with no dynamic dispatch, no interior mutability, and no
|
||||
heap allocations by default.
|
||||
`hsmc` lets you write the control logic of a device — a microwave, a
|
||||
LoRa radio, a sensor, a UI flow — as a **statechart**: nested states
|
||||
with entry/exit actions, transitions, timers, and async work, all
|
||||
declared in one block. The proc macro turns it into Rust code with no
|
||||
heap allocations, no dynamic dispatch, and no interior mutability.
|
||||
|
||||
It targets embedded Rust on `embassy` first, with full tokio support
|
||||
on the side. Same chart, both runtimes.
|
||||
|
||||
## A first chart — a blinker
|
||||
|
||||
```rust
|
||||
use hsmc::{statechart, Duration};
|
||||
|
||||
pub struct Ctx {
|
||||
pub on: bool, // mirrors the LED's current state
|
||||
pub blinks: u32, // count of times we've turned it on
|
||||
}
|
||||
|
||||
statechart! {
|
||||
Blinker {
|
||||
context: Ctx;
|
||||
default(Off); // start with the LED off
|
||||
|
||||
state Off {
|
||||
entry: light_off;
|
||||
on(after Duration::from_millis(500)) => On;
|
||||
}
|
||||
state On {
|
||||
entry: light_on;
|
||||
on(after Duration::from_millis(500)) => Off;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlinkerActions for BlinkerActionContext<'_> {
|
||||
async fn light_off(&mut self) { self.on = false; }
|
||||
async fn light_on(&mut self) { self.on = true; self.blinks += 1; }
|
||||
}
|
||||
```
|
||||
|
||||
Two states, two timer-driven transitions, two entry actions. The
|
||||
runtime arms each state's timer when you enter it and cancels it
|
||||
when you leave; `default(Off)` picks the initial state. Land in
|
||||
`Off`, fire `light_off`, arm the 500 ms timer; when it elapses,
|
||||
transition to `On`, fire `light_on`, arm the next 500 ms timer.
|
||||
Repeat.
|
||||
|
||||
That's `hsmc` at its smallest: states, timers, and one entry action
|
||||
per state. No event channel, no async I/O, no hierarchy yet.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Add to `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
hsmc = { version = "0.6", features = ["embassy"] } # firmware
|
||||
# or
|
||||
hsmc = { version = "0.6", features = ["tokio"] } # desktop / tests
|
||||
```
|
||||
|
||||
Run the Blinker under tokio:
|
||||
|
||||
```rust
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
let mut m = Blinker::new(Ctx { on: false, blinks: 0 });
|
||||
let _ = m.run().await;
|
||||
}
|
||||
```
|
||||
|
||||
That's the whole driver. A chart runs on a single task — `flavor =
|
||||
"current_thread"` keeps tokio out of the multi-threaded scheduler so
|
||||
you don't have to think about `Send` bounds on your context, your
|
||||
events, or your `during:` futures. Normal Rust, no extra wrapping.
|
||||
|
||||
Run it under embassy:
|
||||
|
||||
```rust
|
||||
#[embassy_executor::task]
|
||||
async fn blink_task() {
|
||||
let mut m = Blinker::new(Ctx { on: false, blinks: 0 });
|
||||
let _ = m.run().await;
|
||||
}
|
||||
```
|
||||
|
||||
## A bigger chart — events, payloads, async I/O
|
||||
|
||||
The Blinker doesn't talk to anything outside itself. A real device
|
||||
does. Here's a radio that receives commands from somewhere
|
||||
(button press, RPC call, ISR), continuously listens for packets while
|
||||
in the `Receiving` state, and stops when told:
|
||||
|
||||
```rust
|
||||
statechart! {
|
||||
@@ -25,112 +113,139 @@ statechart! {
|
||||
}
|
||||
```
|
||||
|
||||
## What it does
|
||||
Three things here that the Blinker didn't have:
|
||||
|
||||
- **Harel-style hierarchical states.** Nested states with `entry:`/`exit:`
|
||||
actions, LCA-aware transitions, default-child descent, `terminate`.
|
||||
- **Event-driven transitions and actions.** Typed payload bindings:
|
||||
`on(PacketRx { rssi: i16 }) => handler;`.
|
||||
- **Timer triggers.** `on(after Duration::from_secs(5)) => Next;` and
|
||||
`on(every Duration::from_millis(100)) => tick;`.
|
||||
- **`during:` activities.** Async functions that run while a state is
|
||||
active and produce events. Multiple durings can borrow disjoint fields
|
||||
of the context concurrently via Rust's native split borrow — no
|
||||
`RefCell`, no `Mutex`, no `UnsafeCell` in user code.
|
||||
- **`m.run().await` is the whole task body.** Races active durings, the
|
||||
external event channel, and timer deadlines. The MCU sleeps between
|
||||
events (embassy's cooperative executor parks all futures).
|
||||
- **Deterministic + mechanically verified.** The `journal` feature
|
||||
records every action, transition, timer, and event in order — same
|
||||
inputs, byte-identical journal. The runtime queue and timer table
|
||||
carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge
|
||||
mechanically (`just verify`). 113 byte-equal expected-vs-actual
|
||||
journal tests anchor every spec rule.
|
||||
- **Backward-compatible.** Every v0.1 / v0.2 statechart compiles under
|
||||
v0.3 unchanged.
|
||||
- **Events.** `events: Ev;` declares the input enum. External code
|
||||
drives the chart with `m.sender().send(Ev::StartRx)`; in-chart
|
||||
actions can `emit(Ev::Whatever)` to queue a follow-up. Handlers
|
||||
pattern-match the variant and bind its payload directly:
|
||||
`on(PacketRx { rssi, snr }) => record_packet;` makes `rssi` and
|
||||
`snr` available to the action.
|
||||
- **`during:` activities.** `next_packet(lora, rx_buf)` is an async
|
||||
fn that runs while `Receiving` is active and is dropped when the
|
||||
state exits. It gets `&mut` borrows of just the `lora` and
|
||||
`rx_buf` fields of the context — Rust's split-borrow rules let
|
||||
several `during:`s on the active path run concurrently without a
|
||||
`RefCell` or `Mutex` in sight.
|
||||
- **The runtime races them all.** `m.run()` simultaneously polls the
|
||||
`during:`, listens on the event channel, and waits for the next
|
||||
timer deadline; whichever fires first drives the next transition.
|
||||
The MCU sleeps in between.
|
||||
|
||||
## How a chart behaves — the building blocks
|
||||
For nested-state hierarchies, `emit(...)`, `terminate`, and
|
||||
`on(every D)`, see the [microwave example](hsmc/examples/microwave.rs).
|
||||
|
||||
Eight rules, each building on the previous. Internalize these and every
|
||||
edge case below falls out — you don't have to memorize anything else.
|
||||
## What you get
|
||||
|
||||
- **Nested states with proper transitions.** Exit a deeply nested
|
||||
state and every enclosing state's exits run in the right order, no
|
||||
manual bookkeeping. A transition is just `=> Target;` and the
|
||||
runtime walks the tree (LCA dispatch). Includes `entry:`/`exit:`
|
||||
actions, optional `default(...)` initial transitions, and
|
||||
`terminate`.
|
||||
- **Events with typed payloads.** Pattern-match a variant and bind
|
||||
its fields right in the chart: `on(PacketRx { rssi: i16 }) =>
|
||||
record_packet;`. The compiler type-checks the binding against your
|
||||
event enum.
|
||||
- **Timers as first-class triggers.** `on(after 5s) => Next;` for a
|
||||
one-shot, `on(every 100ms) => tick;` for periodic. Armed when the
|
||||
declaring state is entered, cancelled when it's exited — no
|
||||
`select!` plumbing in your code.
|
||||
- **Async work scoped to a state's lifetime.** A `during:` is an
|
||||
async fn that runs while a state is active and is dropped when you
|
||||
leave. Multiple durings on the active path race concurrently and
|
||||
split-borrow disjoint context fields — no `RefCell`, no `Mutex`,
|
||||
no `unsafe` in your code.
|
||||
- **`m.run().await` is the whole task.** It races durings, the event
|
||||
channel, and timer deadlines, and parks the executor between them.
|
||||
No manual loop, no busy-waits, no `Send` headaches under
|
||||
`current_thread` tokio or embassy.
|
||||
- **Observability that's one feature flag away.** Add
|
||||
`features = ["trace-log"]` (or `trace-defmt` / `trace-tracing`) and
|
||||
every action, transition, timer, and event becomes a log line in
|
||||
whichever framework your crate already uses — `defmt` for firmware,
|
||||
`log` for desktop, `tracing` for structured spans. With no sink
|
||||
feature, the observation calls compile to nothing.
|
||||
- **Deterministic + machine-checked.** The `journal` feature records
|
||||
every action, transition, timer, and event in order; identical
|
||||
input produces a byte-identical journal. The runtime's event
|
||||
queue and timer table are formally verified — Creusot + Why3 +
|
||||
alt-ergo + z3 discharge the proofs (`just verify`).
|
||||
- **No heap, no dynamic dispatch.** Pure monomorphized code.
|
||||
`no_std` clean by default. Designed to fit on a Cortex-M0+ under
|
||||
embassy. The macro emits six bounded `unsafe { get_unchecked }`
|
||||
blocks per chart for hot-path table indexing; their preconditions
|
||||
are checked at build time by const-eval, in dev/test by
|
||||
`debug_assert!`, and under `cargo +nightly miri test`. Full
|
||||
inventory and audit in
|
||||
[`docs/004. unsafe-safety-contract.md`](docs/004.%20unsafe-safety-contract.md).
|
||||
Your chart, your action bodies, and your durings stay
|
||||
`unsafe`-free.
|
||||
|
||||
## How a chart behaves — the rules
|
||||
|
||||
Eight rules, each building on the previous. Internalize these and
|
||||
every edge case below falls out — you don't have to memorize anything
|
||||
else.
|
||||
|
||||
The full canonical reference lives at
|
||||
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md);
|
||||
this section is the on-ramp.
|
||||
|
||||
### 1. The active path — you are in every state from root to leaf
|
||||
### 1. The active path — you are in every state from root to the innermost active state
|
||||
|
||||
At any moment, the machine is in a **path** of states from the root down
|
||||
to one innermost state. If you're in `C` inside `B` inside `A`, you're
|
||||
**simultaneously** in `Root`, `A`, `B`, and `C`. The root is always
|
||||
active until termination.
|
||||
to one innermost active state. If you're in `C` inside `B` inside `A`,
|
||||
you're **simultaneously** in `Root`, `A`, `B`, and `C`. The root is
|
||||
always active until termination.
|
||||
|
||||
The innermost active state is usually a leaf, but it can also be a
|
||||
composite that has children but no `default(...)` — see rule 5.
|
||||
|
||||
This is the most important rule. Every rule below is downstream of it.
|
||||
If a behavior somewhere else seems weird, come back here — it's
|
||||
probably the resolution.
|
||||
|
||||
### 2. Default-descent — composites auto-enter their default child
|
||||
### 2. Transitions — exit to LCA, enter to target
|
||||
|
||||
Every state with children declares one `default(...)`. When a composite
|
||||
state is entered, its entry actions run first, **then** the default
|
||||
child is entered, recursively, until a leaf.
|
||||
|
||||
So `Root` with `default(A)` and `A` with `default(B)` means: the
|
||||
machine starts by entering `Root` → `A` → `B`, all on first step.
|
||||
|
||||
### 3. Event bubbling — leaf first, first handler wins
|
||||
|
||||
An event arrives. Search starts at the **innermost** active state. If
|
||||
that state has a handler, it runs and the event is consumed. If not,
|
||||
walk up to the parent and check there. Repeat. If you reach the root
|
||||
with no handler, the event is silently discarded.
|
||||
|
||||
Three corollaries:
|
||||
- Leaf shadows ancestor for the same trigger.
|
||||
- Multiple handlers in one state on the same trigger fire in
|
||||
declaration order; the transition (if any) fires last.
|
||||
- **Timers don't bubble.** A timer belongs to the state that declared
|
||||
it; it fires only in that state's scope.
|
||||
|
||||
### 4. Transitions — exit to LCA, enter to target
|
||||
|
||||
Any transition's algorithm is uniform:
|
||||
A transition moves the active path from the current innermost state to
|
||||
some target state. The algorithm is uniform regardless of what
|
||||
triggered it (event, timer, or default — see later rules):
|
||||
|
||||
1. Find the LCA (lowest common ancestor) of current and target.
|
||||
2. Walk **up** from current, exiting each state, until you hit the LCA.
|
||||
The LCA itself is **not** exited.
|
||||
3. Walk **down** from the LCA to target, entering each state. The LCA
|
||||
is **not** re-entered.
|
||||
4. If target has children, default-descend (rule 2).
|
||||
|
||||
The LCA always exists because root is always an upper bound. State
|
||||
names are globally unique across the chart, so a transition can target
|
||||
**any** state — siblings, cousins, ancestors, the root itself
|
||||
(`on(Trig) => MyChart;`).
|
||||
|
||||
### 5. The up-transition rule — never re-enter what you never left
|
||||
### 3. The up-transition rule — never re-enter what you never left
|
||||
|
||||
If your transition target is **already on the active path** (i.e., it's
|
||||
an ancestor of the innermost state), then steps 3 and 4 of the
|
||||
transition algorithm do nothing. You exit the states strictly between
|
||||
current and target; the target itself is **not** re-entered.
|
||||
an ancestor of the innermost state), then step 3 of the transition
|
||||
algorithm does nothing. You exit the states strictly between current
|
||||
and target; the target itself is **not** re-entered.
|
||||
|
||||
So from leaf `C` inside `B` inside `A`, an `on(Up) => A;` exits `C`
|
||||
then `B`. `A`'s entry actions don't fire. `A`'s `default` is not
|
||||
followed. `A`'s timers don't restart. `A`'s durings keep running.
|
||||
then `B`. `A`'s entry actions don't fire. `A`'s timers don't restart.
|
||||
`A`'s durings keep running.
|
||||
|
||||
You cannot enter a state you never left.
|
||||
|
||||
### 6. Entry / exit ordering
|
||||
### 4. Entry / exit ordering
|
||||
|
||||
Direction is set by the path:
|
||||
|
||||
- **Entries** fire **outer-to-inner**: when entering down through a
|
||||
path, the outermost state's entries fire first, then the next, then
|
||||
the leaf's. Within a single state, entry actions fire in declaration
|
||||
order. After a state's entries finish, its `during:` activities
|
||||
start. Then default-descent (rule 2) applies recursively.
|
||||
- **Exits** fire **inner-to-outer**: leaf first, then parent, then
|
||||
the innermost's. Within a single state, entry actions fire in
|
||||
declaration order. After a state's entries finish, its `during:`
|
||||
activities start.
|
||||
- **Exits** fire **inner-to-outer**: innermost first, then parent, then
|
||||
grandparent. Within a single state, exit actions fire in declaration
|
||||
order. Before a state's exits begin, its `during:` activities are
|
||||
cancelled.
|
||||
@@ -139,11 +254,63 @@ So a full transition reads: cancel durings on the way out → run exits
|
||||
on the way out → run entries on the way in → start durings on the way
|
||||
in.
|
||||
|
||||
### 5. `default(...)` — a transition that fires immediately on entry
|
||||
|
||||
Any state **may** declare one `default(...)`. It's a transition — same
|
||||
LCA-aware algorithm as rule 2 — that fires **immediately** after the
|
||||
declaring state's entry actions finish (and its durings start). The
|
||||
target may be **any** state in the chart: a child, a sibling, an
|
||||
ancestor, anywhere. Defaults chain: if the resulting target also has
|
||||
a `default(...)`, it fires too, and so on until a state with no
|
||||
default is reached. The compiler rejects cycles in the default graph,
|
||||
so the chain is always finite.
|
||||
|
||||
The classic case is a composite descending to a leaf:
|
||||
```
|
||||
state Root { default(A); state A { default(B); state B {} } }
|
||||
```
|
||||
Entering `Root` fires its default → enter `A` → fires its default →
|
||||
enter `B`. The path lands at `B`.
|
||||
|
||||
But because `default` is a real transition, you can also redirect:
|
||||
```
|
||||
state Foyer { default(LivingRoom); } // sibling — entering Foyer
|
||||
state LivingRoom {} // exits Foyer to LivingRoom
|
||||
```
|
||||
Entering `Foyer` fires its default `Foyer → LivingRoom`. LCA = parent of
|
||||
both; Foyer's exits run, LivingRoom's entries run. Foyer was entered
|
||||
and exited as part of one tick.
|
||||
|
||||
If a state has no `default(...)`, nothing happens after its entries —
|
||||
the state itself is the resting innermost active state until something
|
||||
explicitly transitions away. This is the "microwave" pattern: a
|
||||
`Standby` state with sub-modes that the chart enters only on demand.
|
||||
|
||||
A consequence of rule 3 (up-transitions): an ancestor's `default(...)`
|
||||
only fires when that ancestor is freshly entered. Transitioning *up*
|
||||
to an ancestor does not re-enter it, so its default does not re-fire.
|
||||
|
||||
### 6. Event bubbling — innermost first, first handler wins
|
||||
|
||||
An event arrives. Search starts at the **innermost** active state. If
|
||||
that state has a handler, it runs and the event is consumed (the
|
||||
handler may execute a transition per rule 2). If not, walk up to the
|
||||
parent and check there. Repeat. If you reach the root with no handler,
|
||||
the event is silently discarded.
|
||||
|
||||
Three corollaries:
|
||||
|
||||
- Innermost shadows ancestor for the same trigger.
|
||||
- Multiple handlers in one state on the same trigger fire in
|
||||
declaration order; the transition (if any) fires last.
|
||||
- **Timers don't bubble.** A timer belongs to the state that declared
|
||||
it; it fires only in that state's scope.
|
||||
|
||||
### 7. Timers — armed by state lifecycle
|
||||
|
||||
A timer (`on(after D) => …` or `on(every D) => …`) is **armed** when
|
||||
its declaring state is entered, **cancelled** when that state is
|
||||
exited. Descending into a child is *not* an exit, so the parent's
|
||||
exited. Descending into a child is _not_ an exit, so the parent's
|
||||
timers keep running while you're in a child. Re-entering a state
|
||||
restarts its timers from zero.
|
||||
|
||||
@@ -159,7 +326,7 @@ restarts its timers from zero.
|
||||
durings on the active path race; each borrows disjoint `&mut` fields
|
||||
of the context (Rust's split-borrow checker enforces this at compile
|
||||
time).
|
||||
- **Termination** is just rules 6 + 4 with target = "outside the
|
||||
- **Termination** is just rules 2 + 4 with target = "outside the
|
||||
chart": exit the entire active path inner-to-outer, then stop.
|
||||
Pending events drop. In-flight durings drop at their next `.await`.
|
||||
|
||||
@@ -169,78 +336,6 @@ spells out edge cases (queue overflow surfacing, same-tick timer ties,
|
||||
self-transitions on composites) but the rules above cover the
|
||||
overwhelming majority of charts.
|
||||
|
||||
### What `hsmc` deliberately does NOT have
|
||||
|
||||
The bet is on **simple, consistent semantics**. These features are
|
||||
omitted on purpose; for each, what to do instead:
|
||||
|
||||
| Missing feature | Use instead |
|
||||
|---|---|
|
||||
| Guard conditions on transitions | Split source into two states, or have an action `emit()` a follow-up event for the chart to branch on |
|
||||
| Orthogonal / parallel regions | Two charts as two tasks connected by channels, or multiple `during:` activities in one state |
|
||||
| History states | Store last child explicitly in your context; transition to it on re-entry |
|
||||
| Deferred events | Handle where they arrive (drop/log if not relevant), or have producer check state |
|
||||
| Internal transitions | `action(Trig) => fn;` (no transition) — exactly that |
|
||||
| State-local context | All state in the root context; durings borrow disjoint fields |
|
||||
| Event priorities beyond FIFO | Higher-priority events on a separate channel into a separate, faster machine |
|
||||
| Runtime statechart modification | Design every variation up front as states |
|
||||
|
||||
## Quickstart
|
||||
|
||||
Add to `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
hsmc = { version = "0.3", features = ["embassy"] } # or ["tokio"]
|
||||
```
|
||||
|
||||
Minimal timer-only machine:
|
||||
|
||||
```rust
|
||||
use hsmc::{statechart, Duration};
|
||||
|
||||
pub struct Ctx { pub ticks: u32 }
|
||||
|
||||
statechart! {
|
||||
Blinker {
|
||||
context: Ctx;
|
||||
default(On);
|
||||
|
||||
state On {
|
||||
entry: light_on;
|
||||
on(after Duration::from_millis(500)) => Off;
|
||||
}
|
||||
state Off {
|
||||
on(after Duration::from_millis(500)) => On;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlinkerActions for BlinkerActionContext<'_> {
|
||||
async fn light_on(&mut self) { self.ticks += 1; }
|
||||
}
|
||||
```
|
||||
|
||||
Drive it under tokio:
|
||||
|
||||
```rust
|
||||
let mut m = Blinker::new(Ctx { ticks: 0 });
|
||||
tokio::task::LocalSet::new().run_until(m.run()).await.unwrap();
|
||||
```
|
||||
|
||||
Or under embassy:
|
||||
|
||||
```rust
|
||||
static CHANNEL: Channel<CriticalSectionRawMutex, __NoEvents, 8> = Channel::new();
|
||||
// (timer-only machines don't need a channel; omit `events:` and use `Blinker::new(ctx)`.)
|
||||
|
||||
#[embassy_executor::task]
|
||||
async fn blink_task() {
|
||||
let mut m = Blinker::new(Ctx { ticks: 0 });
|
||||
let _ = m.run().await;
|
||||
}
|
||||
```
|
||||
|
||||
## `during:` — async activities scoped to a state
|
||||
|
||||
A `during:` runs while its state is on the active path. It's a plain async
|
||||
@@ -327,127 +422,157 @@ into a single during that uses `select` internally.
|
||||
|
||||
For `statechart! Foo { ... }` the macro emits:
|
||||
|
||||
| Item | What it is |
|
||||
|---------------------------------|----------------------------------------------------------------------|
|
||||
| `FooState` | Enum of every user-declared state. |
|
||||
| `FooActions` | Trait with one async fn per unique action name. User implements. |
|
||||
| `FooActionContext<'a>` | Wrapper passed to actions. `Deref`s to the root context. `emit()`. |
|
||||
| `FooCtx<'a>` | Type alias for `FooActionContext<'a>`. |
|
||||
| `Foo<const QN: usize = 8>` | The machine itself. |
|
||||
| `FooSender` | Cloneable handle for cross-task / ISR event injection. |
|
||||
| `Foo::<8>::STATE_CHART` | `&'static str` ASCII tree of the hierarchy (useful for `defmt`/docs).|
|
||||
| Item | What it is |
|
||||
| -------------------------- | --------------------------------------------------------------------- |
|
||||
| `FooState` | Enum of every user-declared state. |
|
||||
| `FooActions` | Trait with one async fn per unique action name. User implements. |
|
||||
| `FooActionContext<'a>` | Wrapper passed to actions. `Deref`s to the root context. `emit()`. |
|
||||
| `FooCtx<'a>` | Type alias for `FooActionContext<'a>`. |
|
||||
| `Foo<const QN: usize = 8>` | The machine itself. |
|
||||
| `FooSender` | Cloneable handle for cross-task / ISR event injection. |
|
||||
| `Foo::<8>::STATE_CHART` | `&'static str` ASCII tree of the hierarchy (useful for `defmt`/docs). |
|
||||
|
||||
### Machine methods
|
||||
|
||||
| Method | Notes |
|
||||
|-------------------------------------|------------------------------------------------------------------|
|
||||
| `new(ctx)` / `new(ctx, channel)` | Construct. Under embassy, pass an `&'static Channel`. |
|
||||
| `with_queue_capacity::<N>(ctx,...)` | Custom event-queue capacity. |
|
||||
| `async fn run()` | Drive the machine to completion (async features). |
|
||||
| `async fn dispatch(ev)` | Push event + drain to quiescence. Same task injection. |
|
||||
| `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). |
|
||||
| `fn send(ev)` | Raw push, non-draining. For tests. |
|
||||
| `sender() -> Sender` | Clone handle for other tasks / ISRs. |
|
||||
| `current_state()` | The active leaf state. |
|
||||
| `is_terminated()` | True after `terminate` event. |
|
||||
| `has_pending_events()` | Queue non-empty. |
|
||||
| `context()` / `context_mut()` | Borrow the root context. |
|
||||
| `into_context(self)` | Consume, return ctx. |
|
||||
| `STATE_CHART` | Const ASCII diagram. |
|
||||
| Method | Notes |
|
||||
| -------------------------------------- | ------------------------------------------------------ |
|
||||
| `new(ctx)` / `new(ctx, channel)` | Construct. Under embassy, pass an `&'static Channel`. |
|
||||
| `with_queue_capacity::<N>(ctx,...)` | Custom event-queue capacity. |
|
||||
| `async fn run()` | Drive the machine to completion (async features). |
|
||||
| `async fn dispatch(ev)` | Push event + drain to quiescence. Same task injection. |
|
||||
| `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). |
|
||||
| `fn send(ev)` | Raw push, non-draining. For tests. |
|
||||
| `sender() -> Sender` | Clone handle for other tasks / ISRs. |
|
||||
| `current_state()` | The innermost active state. |
|
||||
| `is_terminated()` | True after `terminate` event. |
|
||||
| `has_pending_events()` | Queue non-empty. |
|
||||
| `context()` / `context_mut()` | Borrow the root context. |
|
||||
| `into_context(self)` | Consume, return ctx. |
|
||||
| `STATE_CHART` | Const ASCII diagram. |
|
||||
|
||||
## Feature flags
|
||||
|
||||
| Feature | Effect |
|
||||
|-----------|-------------------------------------------------------------------------|
|
||||
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
|
||||
| `tokio` | Async actions + `run()`. Targets single-thread via `LocalSet`. |
|
||||
| `embassy` | Async actions + `run()`. Targets embassy's cooperative executor. `no_std`.|
|
||||
| Feature | Effect |
|
||||
| --------------- | -------------------------------------------------------------------------- |
|
||||
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
|
||||
| `tokio` | Async actions + `run()`. Single-thread (`flavor = "current_thread"`). |
|
||||
| `embassy` | Async actions + `run()`. Embassy's cooperative executor. `no_std`. |
|
||||
| `journal` | In-memory `Vec<TraceEvent>` of every observation (replay, byte-deterministic). |
|
||||
| `trace-defmt` | Auto-emit `defmt::*` log lines for every observation. |
|
||||
| `trace-log` | Auto-emit `log::*` log lines for every observation. |
|
||||
| `trace-tracing` | Auto-emit `tracing::*` events with structured fields. |
|
||||
|
||||
`embassy` and `tokio` are mutually exclusive — enabling both is a compile
|
||||
error.
|
||||
error. The `trace-*` and `journal` features are independently composable
|
||||
with each other and with the runtime feature; with no sink feature on,
|
||||
the trace points compile to nothing (zero overhead).
|
||||
|
||||
## Migration from v0.1
|
||||
## Observability — one journal, multiple outputs
|
||||
|
||||
v0.1 statecharts continue to compile. New features are purely additive:
|
||||
On hsmc's side, turning on observability is a single Cargo feature.
|
||||
On your side, it's whatever you'd already do to wire `defmt` / `log`
|
||||
/ `tracing` to an output — hsmc does not initialize a logger
|
||||
(libraries shouldn't; `main` owns that decision).
|
||||
|
||||
### 1. `events:` is now optional
|
||||
If your firmware crate already uses `defmt`, add the feature and
|
||||
you're done — chart events interleave with the rest of your defmt
|
||||
output:
|
||||
|
||||
Previously required; now omit for pure timer-only machines:
|
||||
```toml
|
||||
hsmc = { version = "0.6", features = ["embassy", "trace-defmt"] }
|
||||
```
|
||||
|
||||
For a desktop crate, pick `log` or `tracing` and do that framework's
|
||||
normal one-line init in `main`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
hsmc = { version = "0.5", features = ["tokio", "trace-tracing"] }
|
||||
tracing-subscriber = "0.3"
|
||||
```
|
||||
|
||||
```rust
|
||||
statechart! {
|
||||
Blinker {
|
||||
context: Ctx;
|
||||
default(On);
|
||||
state On { on(after Duration::from_millis(500)) => Off; }
|
||||
state Off { on(after Duration::from_millis(500)) => On; }
|
||||
}
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
let mut m = Blinker::new(Ctx { on: false, blinks: 0 });
|
||||
let _ = m.run().await;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `dispatch(ev).await` replaces the push+drain helper
|
||||
Swap `trace-tracing` for `trace-log` and `tracing_subscriber::fmt::init()`
|
||||
for `env_logger::init()` if you prefer the `log` ecosystem; the rest is
|
||||
identical. With no sink feature enabled at all, the observation calls
|
||||
compile to `()` — the runtime is byte-identical to a chart with no
|
||||
instrumentation.
|
||||
|
||||
Before (v0.1, in the dogfood firmware):
|
||||
| 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 |
|
||||
|
||||
```rust
|
||||
async fn push<M>(m: &mut M, ev: E) {
|
||||
m.send(ev).expect("queue full");
|
||||
while m.has_pending_events() {
|
||||
m.step(Duration::ZERO).await;
|
||||
}
|
||||
}
|
||||
push(&mut machine, ev).await;
|
||||
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
|
||||
```
|
||||
|
||||
After (v0.2):
|
||||
### The verb vocabulary
|
||||
|
||||
```rust
|
||||
machine.dispatch(ev).await.unwrap();
|
||||
```
|
||||
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.
|
||||
|
||||
### 3. `during:` replaces the imperative I/O outer loop
|
||||
| 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) |
|
||||
|
||||
Before — a radio task with per-state match and select:
|
||||
|
||||
```rust
|
||||
loop {
|
||||
match machine.current_state() {
|
||||
RadioState::Receiving => {
|
||||
match select(rx_once(&mut lora, &cfg, &mut rx_buf),
|
||||
commands.receive()).await {
|
||||
Either::First(r) => handle_rx(r, &mut machine, ...).await,
|
||||
Either::Second(c) => handle_cmd(c, &mut machine, ...).await,
|
||||
}
|
||||
}
|
||||
_ => { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After — the statechart owns the loop:
|
||||
|
||||
```rust
|
||||
state Receiving {
|
||||
during: next_packet(lora, rx_buf);
|
||||
on(PacketRx { rssi: i16, snr: i16 }) => record_packet;
|
||||
on(StopRx) => Idle;
|
||||
}
|
||||
|
||||
#[embassy_executor::task]
|
||||
async fn radio_task(lora: LoRaDriver, ch: &'static RadioChan) {
|
||||
let mut m = Radio::new(RadioCtx { lora, ... }, ch);
|
||||
m.run().await.unwrap();
|
||||
}
|
||||
```
|
||||
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.
|
||||
|
||||
## Examples
|
||||
|
||||
| Example | Purpose |
|
||||
|--------------------|---------------------------------------------------------------------|
|
||||
| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate.|
|
||||
| `microwave_tui` | TUI variant of microwave. |
|
||||
| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. |
|
||||
| `embassy_full` | Embassy feature demonstration. |
|
||||
| Example | Purpose |
|
||||
| --------------- | --------------------------------------------------------------------- |
|
||||
| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate. |
|
||||
| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. |
|
||||
| `embassy_full` | Embassy feature demonstration. |
|
||||
|
||||
Run `cargo run --example during_radio --features tokio` to see the
|
||||
`during:` pattern end-to-end.
|
||||
@@ -469,29 +594,42 @@ gets to a green proof in one command. See
|
||||
[`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the
|
||||
spec-rule → proof mapping.
|
||||
|
||||
The `journal` Cargo feature gives you a deterministic execution
|
||||
trace of every action, transition, timer arm/cancel/fire, queued
|
||||
emit, event delivery, and termination. Same chart + same events =
|
||||
byte-identical journal. 113 byte-equal expected-vs-actual journal
|
||||
tests anchor every spec rule. `cargo mutants` runs cleanly with
|
||||
0 missed mutants under `--features tokio,journal`.
|
||||
Beyond Creusot, the suite includes:
|
||||
|
||||
- **Integration tests** — full chart behavior and per-feature
|
||||
determinism, with byte-equal expected-vs-actual journals on the
|
||||
`det_*` deterministic-flow tests, plus trace-format regression
|
||||
tests pinning every observation verb's textual rendering. Run
|
||||
with `cargo test --workspace --features tokio,journal`.
|
||||
- **`cargo mutants`** — full coverage under `cargo mutants --features
|
||||
tokio,journal,trace-log`.
|
||||
- **Miri** — clean, no UB on the runtime crates.
|
||||
|
||||
## What `hsmc` deliberately does NOT have
|
||||
|
||||
The bet is on **simple, consistent semantics**. These features are
|
||||
omitted on purpose; for each, what to do instead:
|
||||
|
||||
| Missing feature | Use instead |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Guard conditions on transitions | Split source into two states, or have an action `emit()` a follow-up event for the chart to branch on |
|
||||
| Orthogonal / parallel regions | Two charts as two tasks connected by channels, or multiple `during:` activities in one state |
|
||||
| History states | Store last child explicitly in your context; transition to it on re-entry |
|
||||
| Deferred events | Handle where they arrive (drop/log if not relevant), or have producer check state |
|
||||
| Internal transitions | `action(Trig) => fn;` (no transition) — exactly that |
|
||||
| State-local context | All state in the root context; durings borrow disjoint fields |
|
||||
| Event priorities beyond FIFO | Higher-priority events on a separate channel into a separate, faster machine |
|
||||
| Runtime statechart modification | Design every variation up front as states |
|
||||
| Multi-threaded tokio | `flavor = "current_thread"` is the supported path — one task per chart |
|
||||
|
||||
See [`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md)
|
||||
§ "What `hsmc` deliberately does NOT have" for the rationale on each.
|
||||
|
||||
## Status
|
||||
|
||||
- v0.1 spec: fully implemented.
|
||||
- v0.2: `during:`, optional `events:`, `dispatch()`, `STATE_CHART`,
|
||||
drain-on-send. Up-transitions no longer exit/re-enter the target.
|
||||
- v0.3: `journal` feature + deterministic trace + replay; root
|
||||
state targetable by chart name; Creusot verification of the
|
||||
runtime data structures; 113-test deterministic-flow suite.
|
||||
- Out of scope (deliberate): guard conditions, orthogonal/parallel
|
||||
regions, history states, deferred events, internal transitions,
|
||||
state-local context, event priorities beyond FIFO, runtime
|
||||
statechart modification, multi-threaded tokio. See
|
||||
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md)
|
||||
§ "What `hsmc` deliberately does NOT have" for the rationale and
|
||||
what to use instead.
|
||||
Pre-1.0. Per-release breaking changes are documented in
|
||||
[`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## License
|
||||
|
||||
Dual-licensed MIT OR Apache-2.0.
|
||||
MIT. Copyright (c) 2026 Stephen Waits <steve@waits.net>. See [LICENSE](LICENSE).
|
||||
@@ -1186,7 +1186,14 @@ The crate is considered complete when:
|
||||
|
||||
11. **Timer precision.** Timers fire on the first `step()` call where cumulative elapsed time meets or exceeds the duration. They do not fire early.
|
||||
|
||||
12. **No unsafe code** in the public API or generated code (implementation may use unsafe internally if justified and documented).
|
||||
12. **Bounded `unsafe` in generated code, none in user code.** The
|
||||
generated dispatch path uses six `slice::get_unchecked` blocks
|
||||
for hot-path table indexing; their bounds are guaranteed at
|
||||
codegen time, checked at build time by const-eval, in dev/test
|
||||
by `debug_assert!`, and in CI under Miri. Inventory + audit in
|
||||
[`docs/004. unsafe-safety-contract.md`](./004.%20unsafe-safety-contract.md).
|
||||
User-facing code (chart definitions, action bodies, durings,
|
||||
public API surface) contains no `unsafe`.
|
||||
|
||||
13. **Documentation.** Every public type, method, and trait has rustdoc with examples.
|
||||
|
||||
|
||||
@@ -37,10 +37,16 @@ Each primitive, in roughly the order a developer first meets them.
|
||||
to the target, calling entries along the way.
|
||||
- **Action (without transition)** — a function call attached to a trigger.
|
||||
No state change. That's it.
|
||||
- **Default child** — every non-leaf state declares one default child.
|
||||
Whenever a state with children is entered, the default child is entered
|
||||
immediately after that state's entry actions finish — it's effectively
|
||||
an automatic transition into the child.
|
||||
- **Default transition** — any state **may** declare one `default(...)`.
|
||||
It's a transition (same LCA-aware algorithm as rule 4 / event-driven
|
||||
transitions) that fires immediately after the declaring state's entry
|
||||
actions finish and its durings start. The target may be any state in
|
||||
the chart — a child (the classic descent case), a sibling, an
|
||||
ancestor, anywhere. Defaults chain (each link is itself a transition)
|
||||
until they reach a state with no default. The compiler rejects cycles
|
||||
in the default graph at compile time, so every chain terminates. When
|
||||
a state has no `default`, nothing else happens after entry — the
|
||||
state itself is the resting innermost active state.
|
||||
- **Trigger: event vs. duration** — an event is a variant of your event
|
||||
enum. A duration is a timer (`after Duration` fires once, `every
|
||||
Duration` fires repeatedly). Triggers attach to transitions and to
|
||||
@@ -71,7 +77,8 @@ seems weird, come back to this one — it's probably the resolution.
|
||||
|
||||
`current_state()` returns the innermost active state, which is usually a
|
||||
leaf, but can be a composite (a state with children) if you got there via
|
||||
an up-transition (see below) and haven't moved since.
|
||||
an up-transition (see below) and haven't moved since, or if you
|
||||
transitioned into a composite that has no `default` declared.
|
||||
|
||||
---
|
||||
|
||||
@@ -86,8 +93,9 @@ and still nothing handles it, the event is silently discarded.
|
||||
|
||||
That's all. Three other things follow from this rule:
|
||||
|
||||
- A handler on a leaf state shadows a handler on its ancestor for the same
|
||||
event. The leaf wins because the search starts there.
|
||||
- A handler on the innermost active state shadows a handler on any
|
||||
ancestor for the same event. The innermost wins because the search
|
||||
starts there.
|
||||
- If a single state has multiple handlers (actions and/or a transition)
|
||||
for the same trigger, the actions fire in declaration order, then the
|
||||
transition fires last.
|
||||
@@ -108,8 +116,8 @@ in `statechart! { Foo { ... } }`, an `on(Trig) => Foo;` is a valid
|
||||
transition whose target is the root. From any state inside the chart this
|
||||
is an up-transition (root is always on the active path) — exiting only
|
||||
the states strictly between current and root, with no entry actions and
|
||||
no default-descent on root. Use this when you want to wind everything
|
||||
back up to the chart's own scope.
|
||||
no default firing on root (the root wasn't freshly entered). Use this
|
||||
when you want to wind everything back up to the chart's own scope.
|
||||
|
||||
To run a transition:
|
||||
|
||||
@@ -121,8 +129,10 @@ To run a transition:
|
||||
itself is **not** exited.
|
||||
3. Walk **down** from the LCA to the target, entering each state on the
|
||||
way (run entry actions → start durings). The LCA is **not** re-entered.
|
||||
4. If the target has children, follow the default-child chain down to a
|
||||
leaf, entering each as you go.
|
||||
4. If the target declares a `default(...)`, fire it as a recursive
|
||||
transition (using the same algorithm). Defaults chain until a state
|
||||
with no default is reached. Compile-time cycle check guarantees
|
||||
termination.
|
||||
|
||||
The three flavors below are just three relationships between current and
|
||||
target. The same algorithm covers all of them — they only look different
|
||||
@@ -131,8 +141,9 @@ in which steps fire.
|
||||
### Transitioning to a sibling (or anywhere not on your active path)
|
||||
|
||||
The standard case. LCA is somewhere strictly above both current and
|
||||
target. Exit up to LCA, enter down to target, default-descend if the
|
||||
target has children. Already covered above.
|
||||
target. Exit up to LCA, enter down to target, then if the target
|
||||
declares a `default(...)`, fire it as a chained transition. Already
|
||||
covered above.
|
||||
|
||||
### Transitioning to yourself
|
||||
|
||||
@@ -153,11 +164,12 @@ Timers declared on the target restart, because the target itself was
|
||||
exited and re-entered.
|
||||
|
||||
If the target happens to be a composite (it has children, but no child is
|
||||
active right now because you got there via an up-transition), the same
|
||||
default-child rule still applies on re-entry: exit the composite, re-enter
|
||||
it, then descend through its default chain. Self-transitions don't add a
|
||||
new rule — they're just the case where source and target are the same
|
||||
state.
|
||||
active right now because you got there via an up-transition or because
|
||||
the composite has no `default`), the same rule still applies on re-entry:
|
||||
exit the composite, re-enter it, then descend through its default chain
|
||||
if it declares one. A composite without `default` is exited, re-entered,
|
||||
and that's it — no further descent. Self-transitions don't add a new
|
||||
rule — they're just the case where source and target are the same state.
|
||||
|
||||
Personally I don't find self-transitions all that valuable, but people
|
||||
talk about them all the time, so they're supported.
|
||||
@@ -207,7 +219,11 @@ Putting it all together:
|
||||
outermost state's entry actions fire first, then its child's, and so on
|
||||
down to the innermost. Within a single state, entry actions fire in
|
||||
declaration order. After entry actions of a state finish, that state's
|
||||
durings start. Then the default child (if any) is entered, recursively.
|
||||
durings start. Then, if the state declares a `default(...)`, the
|
||||
default fires as a transition (which may exit the state we just
|
||||
entered if the target lies elsewhere in the chart) and the chain
|
||||
continues from there. A state without `default` is the end of the
|
||||
path — control stops there.
|
||||
- **Exit** runs **inner-to-outer**: when leaving a path of states, the
|
||||
innermost state exits first, then its parent, and so on up. Within a
|
||||
single state, exit actions fire in declaration order. Before exit
|
||||
@@ -217,6 +233,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
|
||||
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
|
||||
@@ -357,11 +383,13 @@ drafts. Edit freely.)_
|
||||
|
||||
| Scenario | What fires |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Initial machine start | Root entries → default-descend down through each child's entries → durings start at each entered state. |
|
||||
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → default-descend if target has children. |
|
||||
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If innermost has children, default-descend after re-entry. |
|
||||
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. No default-descent. Ancestor's durings and timers continue. |
|
||||
| Transition into a state with children | After the target's entries fire, default-descend through each default child's entries down to a leaf. |
|
||||
| Initial machine start | Root entries → if root declares a `default(...)`, fire it as a transition; chain continues until a state with no default. Durings start at each entered state. |
|
||||
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → if target declares a `default`, fire it; chain continues. |
|
||||
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If the re-entered state declares a `default`, fire it. |
|
||||
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. The ancestor's `default` is **not** fired (it wasn't freshly entered). Durings and timers continue. |
|
||||
| Transition into a state with `default(<descendant>)` | After the target's entries fire, the default fires as a child-direction transition (no exits at this step). The chain continues from the new innermost. |
|
||||
| Transition into a state with `default(<sibling/ancestor>)` | After the target's entries fire, the default fires as a transition that exits the just-entered target and lands elsewhere. The chain continues. |
|
||||
| State has no `default(...)` | After its entries fire, control stops there. The state itself is the innermost active state until something explicitly transitions away. |
|
||||
| Timer fires | The declaring state's handler runs (timers don't bubble). |
|
||||
| Event with no handler | Silently discarded. |
|
||||
| Event handled at innermost | Innermost state's handler runs (actions in declaration order, then transition if any). |
|
||||
|
||||
@@ -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 gungraun 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.
|
||||
@@ -0,0 +1,261 @@
|
||||
# Unsafe Safety Contract
|
||||
|
||||
This document inventories every `unsafe` block emitted by the
|
||||
`statechart!` macro, states the preconditions, names what
|
||||
establishes them, and lists the layered checks that prove they hold.
|
||||
|
||||
It is the answer to "you said no `unsafe` — now there's `unsafe`. How
|
||||
do I know it's correct?".
|
||||
|
||||
## Inventory
|
||||
|
||||
All `unsafe` lives in code emitted by `hsmc-macros`. The runtime
|
||||
`hsmc` crate, the user's chart definition, and the user's action /
|
||||
during bodies contain none.
|
||||
|
||||
The blocks are six index-elision sites in five generated functions,
|
||||
each pairing a `slice::get_unchecked` (or `Option::unwrap_unchecked`,
|
||||
in earlier passes — currently none) with a documented bound.
|
||||
|
||||
| Site | Index expression | Bound |
|
||||
| ----------------------------------------------- | ---------------------------------------- | -------------------------------------- |
|
||||
| `step()` — sync + async — `__DISPATCH` lookup | `cur * N_EVENTS + ev_id` | `< __DISPATCH.len() = N * N_EVENTS` |
|
||||
| `apply_path()` — sync + async — `__PATH_RANGE` | `from * N_STATES + target` | `< __PATH_RANGE.len() = N * N` |
|
||||
| `apply_path()` — sync + async — `__PATH_DATA[s..m]` | `s..m` from `__PATH_RANGE[idx]` | `s ≤ m ≤ __PATH_DATA.len()` |
|
||||
| `apply_path()` — sync + async — `__PATH_DATA[m..e]` | `m..e` from `__PATH_RANGE[idx]` | `m ≤ e ≤ __PATH_DATA.len()` |
|
||||
| `follow_defaults()` — sync + async — `__DEFAULT_CHILD` | `cur` | `< __DEFAULT_CHILD.len() = N_STATES` |
|
||||
| `do_terminate()` — sync + async — `__TERMINATE_RANGE` + `__TERMINATE_DATA[s..e]` | `cur`, `s..e` | `cur < N_STATES`; `s ≤ e ≤ __TERMINATE_DATA.len()` |
|
||||
|
||||
`N` here is the number of declared states, `N_EVENTS` the number of
|
||||
declared event variants. Both are codegen constants per chart.
|
||||
|
||||
## Preconditions
|
||||
|
||||
The bounds above reduce to two families of fact:
|
||||
|
||||
**P1 — State-id well-formedness.** Every `u16` value that ends up in
|
||||
`self.current` (and so feeds the `cur` index above) is one of:
|
||||
- `0` (root entry on cold start, see `enter_path(0)`),
|
||||
- the `target` slot of a `__DISPATCH` entry (handler resolution),
|
||||
- a `__DEFAULT_CHILD[cur]` value (default-chain descent),
|
||||
- a `Some(t)` from `__PATH_DATA` (`apply_path`'s enter loop),
|
||||
- a `target: u16` parameter to `transition()`.
|
||||
|
||||
All five sources are produced by codegen from `IR::states`, whose
|
||||
`StateIr.id` field is `0..N_STATES` by construction (`ir::build_ir`
|
||||
assigns ids by enumeration; `ir::resolve_transitions` only resolves
|
||||
identifiers to existing entries; `validate::validate` rejects
|
||||
duplicates and unresolvable names). User code cannot write
|
||||
`self.current` — the field is private, the chart context is the only
|
||||
field handed to action bodies, and the action context's
|
||||
`__terminated: &bool` reference is read-only.
|
||||
|
||||
**P2 — Range-table well-formedness.** For every `(from, to)` pair the
|
||||
codegen-emitted `__PATH_RANGE[from * N + to] = (s, m, e)` triple
|
||||
satisfies `0 ≤ s ≤ m ≤ e ≤ __PATH_DATA.len()`. The same holds for
|
||||
`__TERMINATE_RANGE[i] = (s, e)` against `__TERMINATE_DATA`. The
|
||||
triple's interpretation is: exit chain is `__PATH_DATA[s..m]`, enter
|
||||
chain is `__PATH_DATA[m..e]`. Both slices are required to fit inside
|
||||
`__PATH_DATA`.
|
||||
|
||||
Codegen establishes P2 in `compute_transition_paths` (codegen.rs
|
||||
~133): every emitted offset is a `usize` cursor into a `Vec<u16>`
|
||||
that is grown only by `push`, so the cursor never exceeds the
|
||||
length, and the triple is recorded *after* both halves of the
|
||||
chain are pushed.
|
||||
|
||||
## Verification — five overlapping checks
|
||||
|
||||
Each check covers a different failure mode. The unsafe surface is
|
||||
secure when *all five* pass.
|
||||
|
||||
### 1. Compile-time const-eval (build error)
|
||||
|
||||
Every generated chart contains a `const _: () = { ... }` block
|
||||
(codegen.rs ~2465) that re-states P2 as `assert!`s evaluated at type
|
||||
check time. A codegen change that emits a malformed `(s, m, e)`
|
||||
trips a `rustc E0080: evaluation panicked:` diagnostic before any
|
||||
runtime code is produced.
|
||||
|
||||
Verified: manually injecting `(s=0, m=99, e=99)` into a 4-element
|
||||
`__PATH_DATA` produces
|
||||
|
||||
error[E0080]: evaluation panicked: __PATH_RANGE: e past __PATH_DATA end
|
||||
|
||||
so the assert is live, not optimized away.
|
||||
|
||||
### 2. Runtime `debug_assert!` companions
|
||||
|
||||
Every `unsafe { ... get_unchecked ... }` is paired with a
|
||||
`debug_assert!` of the same predicate (codegen.rs ~2693, ~2978–2987,
|
||||
~3110, ~3211). `debug_assert!` is a `#[cfg(debug_assertions)]` no-op
|
||||
in `--release`, so production code pays nothing. In dev/test it
|
||||
panics with a clear message before the unsafe access runs.
|
||||
|
||||
Coverage: every dispatch through any of the 68 hsmc behavior tests
|
||||
exercises the relevant assertions. A regression in P1 (e.g., a
|
||||
codegen change that lets a stale id leak into `self.current`) panics
|
||||
the test, naming the violated bound.
|
||||
|
||||
### 3. Miri (UB detection)
|
||||
|
||||
`cargo +nightly miri test -p hsmc` runs the test suite under Miri,
|
||||
the Rust UB interpreter. Miri detects out-of-bounds reads/writes,
|
||||
use-after-free, and other unsafe-related UB.
|
||||
|
||||
Status: **20 unit + 46 behavior tests pass** under Miri with the
|
||||
current `unsafe` set.
|
||||
|
||||
### 4. Mutation testing (test-coverage proof)
|
||||
|
||||
`cargo mutants` flips operators / removes branches in the
|
||||
`hsmc-macros` codegen and `hsmc/src/lib.rs` runtime, rebuilding and
|
||||
testing each mutant. Run with `--features hsmc/tokio,hsmc/journal
|
||||
--test-package hsmc` so the integration tests in `hsmc/tests/` (which
|
||||
are gated on those features) actually compile and run; without that,
|
||||
cargo-mutants restricts to the per-package test set and the
|
||||
asymmetric-depth lateral tests don't fire.
|
||||
|
||||
Status (with the correct invocation):
|
||||
- `hsmc-macros/src/codegen.rs` safety-relevant fns
|
||||
(`compute_transition_paths`, `compute_terminate_paths`,
|
||||
`compute_event_dispatch`): **51 of 53 covered** (14 caught + 37
|
||||
unviable + 2 missed). The 37 unviable are mutations that don't
|
||||
compile in their context — the type system rejects them, which is
|
||||
a stronger form of "caught". The 2 missed are *equivalent
|
||||
mutants* analyzed below.
|
||||
- `hsmc/src/lib.rs` (runtime): **48 of 48 covered** (45 caught + 3
|
||||
unviable + 0 missed) — full coverage.
|
||||
|
||||
Combined: **99 of 101 mutants accounted for (98%)**. Run with
|
||||
`just safety`.
|
||||
|
||||
#### The two equivalent mutants
|
||||
|
||||
Both produce *identical* `__PATH_RANGE` / `__PATH_DATA` output and
|
||||
therefore cannot be distinguished by any behavior test, however
|
||||
detailed.
|
||||
|
||||
**Equivalent mutant 1.** `codegen.rs:140`:
|
||||
`while da > db { ... }` → `while da >= db { ... }`.
|
||||
|
||||
The mutated loop walks `a` one extra step when `da == db`. The next
|
||||
loop (`while db > da`) then walks `b` up by one to compensate, and
|
||||
the third loop (`while a != b`) drives both legs to the same LCA.
|
||||
`parent(root) = None` and the `?` operator short-circuit so going
|
||||
"past root" is symmetric on both sides. Result: same LCA, same
|
||||
exit/enter paths. Verified by hand on lateral, up-transition, and
|
||||
asymmetric-depth (4↔2) cases.
|
||||
|
||||
**Equivalent mutant 2.** `codegen.rs:169`:
|
||||
`from != to && is_strict_ancestor(to, from)` →
|
||||
`from == to && is_strict_ancestor(to, from)`.
|
||||
|
||||
`is_strict_ancestor(to, from)` is always false when `from == to`
|
||||
(a state is not a strict ancestor of itself), so the mutated
|
||||
condition is always false and the up-transition branch becomes
|
||||
unreachable. Up-transitions fall through to the lateral branch,
|
||||
which computes `lca(from, to) = to` (since `to` is an ancestor of
|
||||
`from`); the lateral exit loop then walks source up to (exclusive
|
||||
of) `to`, and the enter loop breaks immediately on
|
||||
`Some(to) == Some(to)` returning an empty enter path. Result: same
|
||||
exit path, same empty enter path — same `(s, m, e)` triple.
|
||||
|
||||
### 5. Creusot-verified path-table validator + per-chart const-eval
|
||||
|
||||
`hsmc-verification/src/paths.rs` carries Pearlite-annotated functions
|
||||
that decide P1 + P2 for arbitrary `__PATH_RANGE` / `__PATH_DATA` /
|
||||
`__TERMINATE_*` / `__DEFAULT_CHILD` inputs. Each function's
|
||||
`#[ensures(... forall ...)]` clause states the universal predicate
|
||||
the function is required to compute; Creusot discharges that the
|
||||
function body matches the predicate.
|
||||
|
||||
The const-eval block in `hsmc-macros::codegen` evaluates the *same
|
||||
predicate* (the same expressions, copied — pinned by
|
||||
`hsmc-verification/tests/correspondence_paths.rs`) on every user
|
||||
chart's actual emitted tables at build time. Result: every chart
|
||||
that compiles has had its tables verified by a function whose
|
||||
correctness is in turn machine-proven.
|
||||
|
||||
**This is operationally equivalent to a universal proof for end
|
||||
users.** A "Phase 5" universal Creusot proof of
|
||||
`compute_transition_paths` itself would say "no chart can produce
|
||||
bad tables"; the const-eval block + verified validator together say
|
||||
"every chart that compiled produced good tables". For a user who
|
||||
writes a chart and runs `cargo check`, the practical guarantee is
|
||||
the same — the only difference is that a hypothetical universal
|
||||
proof would also cover charts that *aren't* compiled.
|
||||
|
||||
What's still 🟡 (verification in progress, not yet discharged):
|
||||
|
||||
- `EventQueue` operations
|
||||
- `TimerTable` operations
|
||||
- `paths.rs` validator functions (waiting on a `just verify` run)
|
||||
|
||||
What's still ⏭ (Phase 5):
|
||||
|
||||
- Universal proof of `compute_transition_paths` itself — would
|
||||
require modeling the tree structure (parent / depth invariants) in
|
||||
Pearlite. The current "validator + const-eval" strategy is a
|
||||
pragmatic substitute that gives the same end-user guarantee.
|
||||
|
||||
Track + run:
|
||||
|
||||
```bash
|
||||
just verify # full Creusot run; first run installs SMT solvers
|
||||
just safety # const-eval + debug_assert + miri + mutants in one shot
|
||||
```
|
||||
|
||||
## Failure modes the checks together rule out
|
||||
|
||||
| Failure | Caught by |
|
||||
| ------------------------------------------------------ | ---------------------------------- |
|
||||
| Codegen emits triple with `s > m`, `m > e`, `e > len` | (1) const-eval at build time |
|
||||
| Codegen emits state id ≥ N_STATES in path data | (1) const-eval at build time |
|
||||
| Runtime mutates `self.current` to garbage | not possible — field is private; no API path writes a garbage value |
|
||||
| User action sets `terminated` | not possible — `&bool` borrow is read-only |
|
||||
| Future change introduces an unsafe path bypass | (2) debug_assert in tests, (3) miri, (4) mutants |
|
||||
| Subtle UB in a non-bounds dimension (aliasing, etc.) | (3) miri |
|
||||
|
||||
## What's *not* yet covered
|
||||
|
||||
- **Universal proof of `compute_transition_paths`.** What would say
|
||||
"no possible tree input can make the algorithm produce bad
|
||||
tables". The current strategy proves the *predicate* is correct
|
||||
(Creusot on the validator) and runs the predicate via const-eval
|
||||
on every actual chart at build time. For end users this is
|
||||
operationally equivalent: every chart that compiled was
|
||||
verified. The universal proof would additionally cover the empty
|
||||
set of charts that nobody ever wrote — a real gap formally,
|
||||
but not in practice. Tracked as Phase 5 in
|
||||
`verification/INVARIANTS.md`.
|
||||
|
||||
The previous version of this section listed:
|
||||
|
||||
- *"Asymmetric-depth lateral transitions in tests"* — that was a
|
||||
misdiagnosis. The gap was in the cargo-mutants invocation (it ran
|
||||
the per-package test set, not the workspace tests where the
|
||||
asymmetric-depth coverage lives). Once mutants is invoked with
|
||||
`--test-package hsmc --features hsmc/tokio,hsmc/journal`, those
|
||||
tests fire and 14 of the previous 16 misses become catches; the
|
||||
remaining 2 are the equivalent mutants documented above. Closed.
|
||||
- *"Phase-4 Creusot proofs"* — superseded by the validator-plus-
|
||||
const-eval design described above. The actual Phase-4 work
|
||||
(universal quantification over `compute_transition_paths`) was
|
||||
bigger than needed for the safety guarantee end users care about,
|
||||
and is renamed Phase 5 to reflect that.
|
||||
|
||||
## Maintenance rules
|
||||
|
||||
When adding a new `unsafe` block in codegen:
|
||||
|
||||
1. Express the safety predicate as a `debug_assert!` immediately
|
||||
above the `unsafe { ... }`.
|
||||
2. If the predicate is over a static table, extend the const-eval
|
||||
block to cover it — this catches the worst class of bugs at
|
||||
build time.
|
||||
3. Re-run `cargo +nightly miri test -p hsmc` and `cargo mutants
|
||||
-p hsmc-macros -F <function-name>` to check coverage.
|
||||
4. Update this doc. The inventory above must list every unsafe
|
||||
block; if the table goes stale the safety story stops being
|
||||
auditable.
|
||||
File renamed without changes.
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Support. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or support.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Stephen Waits
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied. See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
+1181
-390
File diff suppressed because it is too large.
Load diff
+41
-10
@@ -26,10 +26,12 @@ pub struct Ir {
|
||||
/// Duration triggers referenced anywhere, in first-seen order.
|
||||
/// Each one gets a trigger id used by the timer table.
|
||||
pub duration_triggers: Vec<DurationTrigger>,
|
||||
/// True when the chart declared `trace;` at root. Codegen routes
|
||||
/// trace calls to `::hsmc::__chart_trace!`, which dispatches to a
|
||||
/// backend (defmt / log / tracing / no-op) based on the user's
|
||||
/// `hsmc/trace-*` feature selection in their `Cargo.toml`.
|
||||
/// Set when the chart declared `trace;` at root. Retained for
|
||||
/// backwards-compatible parsing — the keyword no longer gates
|
||||
/// observation emission. As of 0.4 every chart unconditionally
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -77,7 +79,17 @@ pub struct StateIr {
|
||||
pub name: Ident,
|
||||
pub parent: Option<u16>,
|
||||
pub depth: u8,
|
||||
/// Target of this state's `default(...)` declaration, if any. Resolved
|
||||
/// in `resolve_transitions`. The target may be any state in the chart
|
||||
/// (not just a direct child) — `default(...)` is a transition that
|
||||
/// fires immediately after the declaring state's entries, using the
|
||||
/// same LCA-aware algorithm as event-driven transitions. The
|
||||
/// default-graph cycle check in `validate.rs` ensures the chain
|
||||
/// terminates.
|
||||
pub default_child: Option<u16>,
|
||||
/// Original ident from the parse tree, kept until resolution so we can
|
||||
/// emit good error spans on unknown targets / cycles.
|
||||
pub default_target_ident: Option<(Ident, Span)>,
|
||||
pub entries: Vec<u16>, // action ids
|
||||
pub exits: Vec<u16>,
|
||||
pub handlers: Vec<HandlerIr>,
|
||||
@@ -190,6 +202,7 @@ fn alloc_state(ir: &mut Ir, name: Ident, parent: Option<u16>, depth: u8, span: S
|
||||
parent,
|
||||
depth,
|
||||
default_child: None,
|
||||
default_target_ident: None,
|
||||
entries: Vec::new(),
|
||||
exits: Vec::new(),
|
||||
handlers: Vec::new(),
|
||||
@@ -449,12 +462,11 @@ fn lower_body(
|
||||
}
|
||||
ir.states[state_id as usize].children = child_ids.clone();
|
||||
|
||||
if let Some((dc_ident, _)) = &body.default_child {
|
||||
let resolved = child_ids
|
||||
.iter()
|
||||
.find(|&&cid| ir.states[cid as usize].name == *dc_ident)
|
||||
.copied();
|
||||
ir.states[state_id as usize].default_child = resolved;
|
||||
// Stash the unresolved default target. The actual id is filled in by
|
||||
// `resolve_transitions`, which has the global state-name → id map and
|
||||
// can resolve targets that aren't direct children.
|
||||
if let Some((dc_ident, dc_span)) = &body.default_child {
|
||||
ir.states[state_id as usize].default_target_ident = Some((dc_ident.clone(), *dc_span));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -545,5 +557,24 @@ pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve default-transition targets against the same global map.
|
||||
for i in 0..n {
|
||||
if let Some((ident, _span)) = ir.states[i].default_target_ident.clone() {
|
||||
let tname = ident.to_string();
|
||||
match name_map.get(&tname).copied() {
|
||||
Some(id) => {
|
||||
ir.states[i].default_child = Some(id);
|
||||
}
|
||||
None => {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
format!("unknown state `{}` in `default(...)`", ident),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+76
-34
@@ -8,6 +8,7 @@ use crate::ir::{HandlerKindIr, Ir, TriggerIr};
|
||||
use crate::parse::{StateBody, StatechartInput};
|
||||
|
||||
pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
validate_default_graph(ir)?;
|
||||
// Duplicate state names
|
||||
let mut seen = HashMap::new();
|
||||
for s in &ir.states {
|
||||
@@ -94,16 +95,11 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Children require default; default must be a direct child; no default allowed if no children.
|
||||
if !s.children.is_empty() && s.default_child.is_none() {
|
||||
return Err(syn::Error::new(
|
||||
s.span,
|
||||
format!(
|
||||
"state `{}` has children but no `default` declaration",
|
||||
s.name
|
||||
),
|
||||
));
|
||||
}
|
||||
// `default` is optional: a composite without a `default(...)` is a
|
||||
// valid resting state — transitions targeting it land on the composite
|
||||
// itself, with no descent. Substates of such a composite are reachable
|
||||
// only via explicit transitions. (`default` must still be a direct
|
||||
// child when declared; that's checked in `validate_parse_tree`.)
|
||||
|
||||
// duplicate transitions per trigger in this state, AND transition
|
||||
// target (if present) must be declared after every action handler
|
||||
@@ -184,9 +180,70 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk the default-edge graph and reject cycles. Each state has at most one
|
||||
/// outgoing default edge, so the graph has out-degree ≤ 1 and any cycle is a
|
||||
/// simple chain that loops. Detection: 3-color DFS via an iterative chain walk.
|
||||
///
|
||||
/// Cycles are rejected at compile time because at runtime, a default chain
|
||||
/// fires as a sequence of immediate transitions on entry — a cycle would loop
|
||||
/// forever, never returning control to the dispatcher.
|
||||
fn validate_default_graph(ir: &Ir) -> syn::Result<()> {
|
||||
let n = ir.states.len();
|
||||
// 0 = white (unvisited), 1 = gray (on current chain), 2 = black (done).
|
||||
let mut color = vec![0u8; n];
|
||||
for start in 0..n {
|
||||
if color[start] != 0 {
|
||||
continue;
|
||||
}
|
||||
let mut chain: Vec<usize> = Vec::new();
|
||||
let mut cur = start;
|
||||
loop {
|
||||
if color[cur] == 2 {
|
||||
// Joined a previously-cleared subgraph. Chain so far is fine.
|
||||
break;
|
||||
}
|
||||
if color[cur] == 1 {
|
||||
// Back-edge into the current chain — cycle.
|
||||
let cycle_start = chain
|
||||
.iter()
|
||||
.position(|&x| x == cur)
|
||||
.expect("gray node must be on current chain");
|
||||
let cycle: Vec<String> = chain[cycle_start..]
|
||||
.iter()
|
||||
.map(|&id| ir.states[id].name.to_string())
|
||||
.collect();
|
||||
let pretty = format!("{} -> {}", cycle.join(" -> "), cycle[0]);
|
||||
let err_span = ir.states[chain[cycle_start]]
|
||||
.default_target_ident
|
||||
.as_ref()
|
||||
.map(|(_, s)| *s)
|
||||
.unwrap_or(ir.states[chain[cycle_start]].span);
|
||||
return Err(syn::Error::new(
|
||||
err_span,
|
||||
format!(
|
||||
"default-transition cycle detected: {} \
|
||||
(entering any state in this cycle would loop forever \
|
||||
before reaching user code)",
|
||||
pretty
|
||||
),
|
||||
));
|
||||
}
|
||||
color[cur] = 1;
|
||||
chain.push(cur);
|
||||
match ir.states[cur].default_child {
|
||||
Some(next) => cur = next as usize,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
for &id in &chain {
|
||||
color[id] = 2;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Additional checks that require the original parse tree (for counting duplicate
|
||||
/// `default`/`terminate` with their spans, and the "default in childless state" /
|
||||
/// "default not a direct child" / "terminate in non-root" errors).
|
||||
/// `default`/`terminate` with their spans, and the "terminate in non-root" error).
|
||||
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
|
||||
let root_name = input.name.to_string();
|
||||
validate_body(&input.body, true, &root_name)
|
||||
@@ -217,28 +274,13 @@ fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Resu
|
||||
));
|
||||
}
|
||||
}
|
||||
if body.default_child.is_some() && body.children.is_empty() {
|
||||
return Err(syn::Error::new(
|
||||
body.default_span.unwrap(),
|
||||
format!(
|
||||
"`default` in state `{}` which has no child states",
|
||||
state_name
|
||||
),
|
||||
));
|
||||
}
|
||||
if let (Some((dc, dspan)), false) = (&body.default_child, body.children.is_empty()) {
|
||||
// default must be a direct child
|
||||
let is_child = body.children.iter().any(|c| c.name == *dc);
|
||||
if !is_child {
|
||||
return Err(syn::Error::new(
|
||||
*dspan,
|
||||
format!(
|
||||
"`default({})` in state `{}`: `{}` is not a direct child of `{}`",
|
||||
dc, state_name, dc, state_name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
// default(...) is a transition that fires immediately after the
|
||||
// declaring state's entries finish. It may be declared on any state
|
||||
// (composite or leaf) and may target any state in the chart. A leaf
|
||||
// with `default(T)` just transitions to T on every entry. The
|
||||
// default-graph cycle check in `validate_default_graph` (run from the
|
||||
// top-level `validate_parse_tree` over the lowered IR) ensures we
|
||||
// cannot construct an infinite chain at startup.
|
||||
for c in &body.children {
|
||||
let cname = c.name.to_string();
|
||||
validate_body(&c.body, false, &cname)?;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { _U }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Parent);
|
||||
state Parent {
|
||||
state ChildA { entry: e; }
|
||||
state ChildB { entry: e; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn e(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: state `Parent` has children but no `default` declaration
|
||||
--> tests/ui/t9_2_missing_default.rs:13:11
|
||||
|
|
||||
13 | state Parent {
|
||||
| ^^^^^^
|
||||
@@ -1,25 +0,0 @@
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { _U }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Parent);
|
||||
state Parent {
|
||||
default(Cousin);
|
||||
state Child { entry: e; }
|
||||
}
|
||||
state Cousin { entry: e; }
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn e(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: `default(Cousin)` in state `Parent`: `Cousin` is not a direct child of `Parent`
|
||||
--> tests/ui/t9_4_default_non_child.rs:14:9
|
||||
|
|
||||
14 | default(Cousin);
|
||||
| ^^^^^^^
|
||||
+6
-3
@@ -1,3 +1,6 @@
|
||||
// `default(self)` is a 1-cycle: entering A would fire its default
|
||||
// transition to A, which would fire its default again, ad infinitum.
|
||||
// Compile-time error.
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -9,9 +12,9 @@ statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Leaf);
|
||||
state Leaf {
|
||||
default(Ghost);
|
||||
default(A);
|
||||
state A {
|
||||
default(A);
|
||||
entry: e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: default-transition cycle detected: A -> A (entering any state in this cycle would loop forever before reaching user code)
|
||||
--> tests/ui/t9_4_default_self_cycle.rs:17:9
|
||||
|
|
||||
17 | default(A);
|
||||
| ^^^^^^^
|
||||
@@ -1,5 +0,0 @@
|
||||
error: `default` in state `Leaf` which has no child states
|
||||
--> tests/ui/t9_5_default_childless.rs:14:9
|
||||
|
|
||||
14 | default(Ghost);
|
||||
| ^^^^^^^
|
||||
@@ -0,0 +1,30 @@
|
||||
// A → B → A is a default cycle: entering either state would loop
|
||||
// forever before reaching user code. Compile-time error.
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { _U }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(A);
|
||||
state A {
|
||||
default(B);
|
||||
entry: e;
|
||||
}
|
||||
state B {
|
||||
default(A);
|
||||
entry: e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn e(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: default-transition cycle detected: A -> B -> A (entering any state in this cycle would loop forever before reaching user code)
|
||||
--> tests/ui/t9_5_default_two_cycle.rs:16:9
|
||||
|
|
||||
16 | default(B);
|
||||
| ^^^^^^^
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::statechart;
|
||||
|
||||
#[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`
|
||||
--> tests/ui/t9_8_unknown_target.rs:19:1
|
||||
--> tests/ui/t9_8_unknown_target.rs:21:1
|
||||
|
|
||||
8 | / statechart! {
|
||||
9 | | M {
|
||||
10 | | context: Ctx;
|
||||
11 | | events: Ev;
|
||||
10 | / statechart! {
|
||||
11 | | M {
|
||||
12 | | context: Ctx;
|
||||
13 | | events: Ev;
|
||||
... |
|
||||
17 | | }
|
||||
19 | | }
|
||||
| |_- `NonExistent` from trait
|
||||
18 |
|
||||
19 | impl MActions for MActionContext<'_> {}
|
||||
20 |
|
||||
21 | impl MActions for MActionContext<'_> {}
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `NonExistent` in implementation
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Compile-pass: a composite state may omit `default(...)`.
|
||||
//!
|
||||
//! When a composite has children but no `default`, transitions targeting
|
||||
//! that composite land on the composite itself — the substates are reachable
|
||||
//! only via explicit transitions. Microwave-style chart: `Standby` is the
|
||||
//! resting state and `LowPower` is a sub-mode you enter only on demand.
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
PowerOn,
|
||||
PowerOff,
|
||||
EnterLowPower,
|
||||
LeaveLowPower,
|
||||
Halt,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
Microwave {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Standby);
|
||||
terminate(Halt);
|
||||
|
||||
state Standby {
|
||||
// No `default(...)` — Standby is itself the resting state.
|
||||
on(PowerOn) => On;
|
||||
on(EnterLowPower) => LowPower;
|
||||
|
||||
state LowPower {
|
||||
on(LeaveLowPower) => Standby;
|
||||
}
|
||||
}
|
||||
|
||||
state On {
|
||||
on(PowerOff) => Standby;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MicrowaveActions for MicrowaveActionContext<'_> {}
|
||||
|
||||
fn main() {
|
||||
let _ = Microwave::new(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Compile-pass: a state's `default(...)` may target an ancestor. Entering
|
||||
//! the declaring state would immediately exit back up to the ancestor (the
|
||||
//! up-transition rule applies — ancestor not re-entered). The state is
|
||||
//! reached only via an explicit transition; without one, control never
|
||||
//! enters it. (No cycle here: Outer has no default; Inner targets Outer.)
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { GoIn, Halt }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Outer);
|
||||
terminate(Halt);
|
||||
|
||||
state Outer {
|
||||
entry: outer_in;
|
||||
on(GoIn) => Inner;
|
||||
state Inner {
|
||||
entry: inner_in;
|
||||
// Default jumps up to Outer. Inner is reachable only via the
|
||||
// explicit GoIn transition; once entered, this default fires
|
||||
// and lands the chart back at Outer (no re-entry of Outer).
|
||||
default(Outer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn outer_in(&mut self) {}
|
||||
fn inner_in(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let _ = M::new(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Compile-pass: a state's `default(...)` may target a sibling. Entering
|
||||
//! the declaring state immediately fires a transition out to the sibling,
|
||||
//! using the same LCA-aware algorithm as any other transition.
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { Halt }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Foyer);
|
||||
terminate(Halt);
|
||||
|
||||
state Foyer {
|
||||
// Default redirects out of Foyer to its sibling LivingRoom.
|
||||
default(LivingRoom);
|
||||
entry: foyer_in;
|
||||
}
|
||||
state LivingRoom {
|
||||
entry: living_in;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn foyer_in(&mut self) {}
|
||||
fn living_in(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let _ = M::new(Ctx);
|
||||
}
|
||||
+23
-15
@@ -11,31 +11,35 @@ readme = "../README.md"
|
||||
keywords = ["statechart", "state-machine", "hsm", "embassy", "embedded"]
|
||||
categories = ["embedded", "no-std", "asynchronous", "rust-patterns"]
|
||||
autoexamples = false
|
||||
exclude = ["examples/microwave_tui.rs"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
tokio = ["dep:tokio"]
|
||||
embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"]
|
||||
# Auto-tracing backends — pick at most one. When the user opts a chart
|
||||
# into `trace;`, the macro emits calls to an internal dispatcher that
|
||||
# routes to whichever backend is enabled here. With NO backend feature
|
||||
# enabled, `trace;` is a no-op (zero overhead). Enabling more than one
|
||||
# backend produces a duplicate-macro compile error — that's intentional.
|
||||
# Auto-tracing sinks — every chart emits `__chart_observe!` calls
|
||||
# unconditionally; whichever of these sinks you enable is what gets
|
||||
# textual log lines. Pick zero, one, or any combination — the
|
||||
# observation pipeline fans out to all enabled sinks and the in-memory
|
||||
# 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-log = ["dep:log"]
|
||||
trace-tracing = ["dep:tracing"]
|
||||
# Journaling backend. When enabled, the macro emits calls into an
|
||||
# in-memory journal recording every observable atom: entries, exits,
|
||||
# action invocations, during start/cancel, timer arm/cancel/fire, emits,
|
||||
# event delivery, transitions, and termination. Used to make charts
|
||||
# replay-deterministic and to catch semantic regressions in CI.
|
||||
# In-memory journal sink. When enabled, every `__chart_observe!` site
|
||||
# also records a `TraceEvent` into the per-machine `JournalSink`. The
|
||||
# journal is the authoritative byte-deterministic atom-by-atom record
|
||||
# of chart execution; trace backends are textual renderings of the
|
||||
# same vocabulary.
|
||||
# Requires `alloc` (the journal grows dynamically). Off by default —
|
||||
# zero overhead and no `alloc` requirement when not enabled.
|
||||
journal = []
|
||||
|
||||
[dependencies]
|
||||
hsmc-macros = { path = "../hsmc-macros", version = "0.3.0" }
|
||||
hsmc-macros = { path = "../hsmc-macros", version = "0.6.1" }
|
||||
heapless = "0.8"
|
||||
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
|
||||
embassy-sync = { version = "0.8", optional = true }
|
||||
@@ -48,9 +52,9 @@ tracing = { version = "0.1", default-features = false, optional = true }
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dev-dependencies]
|
||||
trybuild = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] }
|
||||
ratatui = "0.28"
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
futures = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
gungraun = "0.18"
|
||||
|
||||
[[example]]
|
||||
name = "microwave"
|
||||
@@ -64,3 +68,7 @@ required-features = ["tokio"]
|
||||
name = "embassy_full"
|
||||
required-features = ["embassy"]
|
||||
crate-type = ["lib"]
|
||||
|
||||
[[bench]]
|
||||
name = "dispatch"
|
||||
harness = false
|
||||
File renamed without changes.
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Support. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or support.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Stephen Waits
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied. See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
@@ -0,0 +1,204 @@
|
||||
# hsmc dispatch baseline — instruction counts from gungraun.
|
||||
#
|
||||
# ─── Harness migration (iai-callgrind 0.16 → gungraun 0.18) ──────────
|
||||
#
|
||||
# Switched from iai-callgrind to gungraun (the renamed continuation —
|
||||
# crates.io shows iai-callgrind frozen at 0.16.1; the gh repo
|
||||
# iai-callgrind/iai-callgrind redirects to gungraun/gungraun). Same
|
||||
# valgrind backend, same callgrind output format. Old named baselines
|
||||
# (pre_lca_fix, before_*) are gone; their deltas live in the historical
|
||||
# block at the bottom for context.
|
||||
#
|
||||
# Re-run: cargo bench --bench dispatch
|
||||
# Diff: cargo bench --bench dispatch -- --baseline=gungraun_initial
|
||||
# Snapshot: cargo bench --bench dispatch -- --save-baseline=before_<change>
|
||||
#
|
||||
# Numbers below are from a 6.19.14-zen kernel x86_64 box, valgrind
|
||||
# 3.25.1, gungraun 0.18.2, gungraun-runner 0.18.2, --release.
|
||||
#
|
||||
# Saved baselines (all under target/gungraun/...):
|
||||
# gungraun_initial first gungraun-harness run, post-migration
|
||||
# after_opt2_unchecked get_unchecked for codegen table indexing
|
||||
# after_opt4_timer_inline pop_expired_nonempty inline (current numbers)
|
||||
# before_more_opts alias for after_opt4_timer_inline (sweep base)
|
||||
|
||||
dispatch::dispatch::h2_cold_start cold:setup_h2_cold()
|
||||
Instructions: 24
|
||||
L1 Hits: 39
|
||||
RAM Hits: 3
|
||||
Estimated Cycles: 144
|
||||
|
||||
dispatch::dispatch::h2_warm_lateral warm:setup_h2_warm()
|
||||
Instructions: 53
|
||||
L1 Hits: 77
|
||||
RAM Hits: 4
|
||||
Estimated Cycles: 217
|
||||
|
||||
dispatch::dispatch::h4_cross_tree cross:setup_h4_warm()
|
||||
Instructions: 93
|
||||
L1 Hits: 130
|
||||
RAM Hits: 10
|
||||
Estimated Cycles: 480
|
||||
|
||||
dispatch::dispatch::h4_up up:setup_h4_warm()
|
||||
Instructions: 88
|
||||
L1 Hits: 125
|
||||
RAM Hits: 8
|
||||
Estimated Cycles: 405
|
||||
|
||||
dispatch::dispatch::h4_self self_:setup_h4_warm()
|
||||
Instructions: 93
|
||||
L1 Hits: 130
|
||||
RAM Hits: 10
|
||||
Estimated Cycles: 480
|
||||
|
||||
dispatch::dispatch::h8_cross_tree cross:setup_h8_warm()
|
||||
Instructions: 53
|
||||
L1 Hits: 76
|
||||
RAM Hits: 5
|
||||
Estimated Cycles: 251
|
||||
|
||||
dispatch::dispatch::bubble_leaf leaf:setup_bubble_warm()
|
||||
Instructions: 94
|
||||
L1 Hits: 132
|
||||
RAM Hits: 11
|
||||
Estimated Cycles: 517
|
||||
|
||||
dispatch::dispatch::bubble_mid mid:setup_bubble_warm()
|
||||
Instructions: 94
|
||||
L1 Hits: 133
|
||||
RAM Hits: 10
|
||||
Estimated Cycles: 483
|
||||
|
||||
dispatch::dispatch::bubble_top top:setup_bubble_warm()
|
||||
Instructions: 93
|
||||
L1 Hits: 132
|
||||
RAM Hits: 10
|
||||
Estimated Cycles: 482
|
||||
|
||||
dispatch::dispatch::timer_fire_dispatch fire:setup_timer_fire_armed()
|
||||
Instructions: 175
|
||||
L1 Hits: 237
|
||||
RAM Hits: 7
|
||||
Estimated Cycles: 482
|
||||
|
||||
dispatch::dispatch::terminate_event quit:setup_term_warm()
|
||||
Instructions: 53
|
||||
L1 Hits: 81
|
||||
RAM Hits: 5
|
||||
Estimated Cycles: 256
|
||||
|
||||
dispatch::dispatch::bubble_cross_tree switch:setup_bubble_cross_warm()
|
||||
Instructions: 85
|
||||
L1 Hits: 116
|
||||
RAM Hits: 11
|
||||
Estimated Cycles: 501
|
||||
|
||||
dispatch::dispatch::chain_self_emit chain:setup_chain_warm()
|
||||
Instructions: 439
|
||||
L1 Hits: 613
|
||||
RAM Hits: 17
|
||||
Estimated Cycles: 1213
|
||||
|
||||
# ─── Migration deltas (gungraun_initial → current) ──────────────────
|
||||
#
|
||||
# Two optimization passes landed under gungraun:
|
||||
#
|
||||
# Opt #2: get_unchecked for codegen table indexing
|
||||
# __DISPATCH, __DEFAULT_CHILD, __PATH_RANGE/_DATA, __TERMINATE_*
|
||||
# all index by codegen-emitted state ids that are bounded by
|
||||
# construction. Replacing the [idx] / [s..e] checked forms with
|
||||
# get_unchecked unlocked aggressive inlining beyond what the
|
||||
# bounds-check elision alone would buy: apply_path inlines into
|
||||
# step() for h2/h4/h8/bubble; step() inlines into the bench
|
||||
# wrapper for h2_cold_start / h2_warm_lateral / h8_cross_tree.
|
||||
# The cold-path enter chain on h2_cold_start collapses to one
|
||||
# u16 store. Bench impact:
|
||||
# h2_cold_start 131 → 24 -82%
|
||||
# h2_warm_lateral 119 → 53 -55%
|
||||
# h8_cross_tree 97 → 53 -45%
|
||||
# bubble_cross_tree 120 → 85 -29%
|
||||
# h4_cross_tree/_self 128 → 93 -27%
|
||||
# bubble_leaf/mid/top 129 → 94 -27%
|
||||
# h4_up 114 → 88 -23%
|
||||
# terminate_event 68 → 53 -22%
|
||||
# chain_self_emit 500 → 439 -12%
|
||||
# timer_fire_dispatch 221 → 212 -4%
|
||||
#
|
||||
# Opt #4: TimerTable::pop_expired_nonempty inline
|
||||
# Drop #[cold], add #[inline]. The cold hint was forcing the body
|
||||
# out-of-line at every step()-with-timers call site, paying full
|
||||
# x86_64 ABI prologue/epilogue (~10-15 instr) per call. Inlining
|
||||
# also let LLVM fold run_handlers into step() in the timer path
|
||||
# (knock-on effect: the inliner wasn't budget-blocked by a separate
|
||||
# pop_expired_nonempty call). Bench impact:
|
||||
# timer_fire_dispatch 212 → 175 -17%
|
||||
# (others unchanged)
|
||||
#
|
||||
# Tried but reverted:
|
||||
#
|
||||
# Opt #1: current: Option<u16> → u16 with u16::MAX sentinel.
|
||||
# Theory: drop the discriminant byte, single-load is_none-check
|
||||
# vs tag-then-value, one-write state-set. In practice net -9 instr
|
||||
# across all 13 benches (-0.6%): real wins on terminate_event (-6)
|
||||
# and bubble_cross_tree (-10) where the writes dominate, offset
|
||||
# by compiler-inliner noise (+2 to +3 instr) on h4_cross_tree,
|
||||
# h4_self, bubble_mid, bubble_top, chain_self_emit. Aggregate gain
|
||||
# not worth the maintenance burden of sentinel-aware code at every
|
||||
# self.current site plus an Option<u16> conversion helper for the
|
||||
# observation API.
|
||||
#
|
||||
# Opt #3: hand-tighten run_action's EmitProxy/ActionContext init.
|
||||
# Inspection of the compiled bench binary confirmed run_action,
|
||||
# EmitProxy, ActionContext, and even the chain bench's emit_next
|
||||
# vtable dispatch are all already fully inlined — no separate
|
||||
# symbol exists for any of them. Already free; no change made.
|
||||
#
|
||||
# ─── Historical context (iai-callgrind era, NOT comparable) ─────────
|
||||
#
|
||||
# The codebase went through six dispatch-path optimization passes
|
||||
# under iai-callgrind 0.16 before the harness migration. Cumulative
|
||||
# h8_cross_tree result vs. the original O(H²) LCA scan: -82% instr.
|
||||
# The passes, in order:
|
||||
#
|
||||
# 1. LCA contains() → O(H) ancestor-walk
|
||||
# 2. Per-state precomputed exit/enter path tables
|
||||
# 3. Skip empty timer scan in step()
|
||||
# 4. Per-event handler dispatch table (was __PARENT bubble walk)
|
||||
# 5. Codegen-elide timer machinery for charts with no `after()` triggers
|
||||
# 6. Precomputed __TERMINATE_DATA + queue.clear() + u128→u64 nanoseconds
|
||||
#
|
||||
# Pre-migration absolute numbers (iai-callgrind 0.16, for reference
|
||||
# only; not directly comparable to gungraun numbers because the
|
||||
# gungraun bench wrapper instruments slightly more):
|
||||
#
|
||||
# h2_cold_start 38 instr 201 cycles
|
||||
# h2_warm_lateral 84 instr 397 cycles
|
||||
# h4_cross_tree 101 instr 456 cycles
|
||||
# h4_up 98 instr 384 cycles
|
||||
# h4_self 101 instr 456 cycles
|
||||
# h8_cross_tree 93 instr 443 cycles
|
||||
# bubble_leaf/mid/top 99 instr 453 cycles
|
||||
# timer_fire_dispatch 191 instr 706 cycles
|
||||
# terminate_event 69 instr 311 cycles
|
||||
# bubble_cross_tree 94 instr 444 cycles
|
||||
# chain_self_emit 419 instr 1301 cycles
|
||||
#
|
||||
# Bench coverage:
|
||||
#
|
||||
# `timer_fire_dispatch` — only bench that exercises pop_expired's
|
||||
# non-empty branch and `dispatch_trigger`.
|
||||
# `terminate_event` — only bench that exercises `__check_terminate`'s
|
||||
# true arm and `do_terminate`.
|
||||
# `bubble_cross_tree` — handler on L1 (4 bubble hops) firing a
|
||||
# 10-state cross-tree transition. Realistic HSM worst case.
|
||||
#
|
||||
# ─── Measurement floor ───────────────────────────────────────────────
|
||||
#
|
||||
# After Opt #2, the gungraun bench-harness wrapper is the dominant
|
||||
# fixed cost on simple benches (~28-49 instr per bench). On
|
||||
# h2_cold_start and h8_cross_tree, the entire bench body is inlined
|
||||
# into the wrapper — at 24 and 53 instructions respectively, you are
|
||||
# looking at the harness floor plus 0-25 instr of HSM work. Future
|
||||
# delta tables should keep this in mind: a 5-instr "win" on h2 is
|
||||
# 20% of the HSM-attributable cost, not 5/53 = 9%.
|
||||
@@ -0,0 +1,633 @@
|
||||
//! Deterministic instruction-count benches for the dispatch hot path.
|
||||
//!
|
||||
//! Run with `cargo bench --bench dispatch` (Linux only — uses
|
||||
//! gungraun + 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 \
|
||||
(gungraun + valgrind, sync action impls)."
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
|
||||
use gungraun::{library_benchmark, library_benchmark_group, main};
|
||||
#[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 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 — gungraun 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);
|
||||
@@ -1,761 +0,0 @@
|
||||
//! Interactive microwave oven TUI driven by `hsmc`.
|
||||
//!
|
||||
//! Run it:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --example microwave_tui --features tokio
|
||||
//! ```
|
||||
//!
|
||||
//! Keys:
|
||||
//! S — Start cook O — Open door
|
||||
//! X — Stop / cancel C — Close door
|
||||
//! K — Simulate power failure (terminates the machine)
|
||||
//! Q — Quit
|
||||
//!
|
||||
//! This demonstrates the same statechart as `microwave.rs` but hooked up to
|
||||
//! live keyboard input and a real-time ratatui UI. The state machine runs
|
||||
//! on `oven.run()` in one task; a second task reads keys and forwards them
|
||||
//! via a cloned `sender()`; a third task redraws the screen 30×/sec using
|
||||
//! state published by entry/exit actions.
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use hsmc::{statechart, Duration};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Gauge, Paragraph, Wrap},
|
||||
Terminal,
|
||||
};
|
||||
|
||||
// ---------- Shared UI state ----------
|
||||
//
|
||||
// The actions write into this through the user context. The render task
|
||||
// reads it. `Arc<Mutex<_>>` keeps it cheap and thread-safe.
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UiState {
|
||||
state_path: Vec<&'static str>,
|
||||
display: String,
|
||||
magnetron: bool,
|
||||
heater: bool,
|
||||
door_open: bool,
|
||||
blink: bool,
|
||||
log: VecDeque<String>,
|
||||
/// Instant at which the cook should complete. Advances by the pause
|
||||
/// duration on resume so the progress bar freezes while paused.
|
||||
cook_deadline: Option<Instant>,
|
||||
cook_total: Duration,
|
||||
/// Set when we enter Paused; cleared on resume. Freezes the gauge.
|
||||
paused_at: Option<Instant>,
|
||||
terminated: bool,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
async fn log(&mut self, msg: impl Into<String>) {
|
||||
self.log.push_back(msg.into());
|
||||
while self.log.len() > 50 {
|
||||
self.log.pop_front();
|
||||
}
|
||||
}
|
||||
async fn set_path(&mut self, path: &[&'static str]) {
|
||||
self.state_path = path.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- User context ----------
|
||||
|
||||
pub struct OvenContext {
|
||||
pub ui: Arc<Mutex<UiState>>,
|
||||
}
|
||||
|
||||
// ---------- Events ----------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OvenEvent {
|
||||
StartPressed,
|
||||
StopPressed,
|
||||
DoorOpened,
|
||||
DoorClosed,
|
||||
Reset,
|
||||
/// Sent by the external watchdog when the adjusted cook deadline is met.
|
||||
/// Using an event instead of a static `Duration` transition lets us pause
|
||||
/// and resume the cook clock — something hsmc timers can't do on their own.
|
||||
CookDone,
|
||||
PowerFailure,
|
||||
}
|
||||
|
||||
// ---------- Statechart ----------
|
||||
//
|
||||
// Same shape as microwave.rs (every feature exercised) with a longer cook
|
||||
// time so the user has time to interact. The cook timer lives on `Running`
|
||||
// so it survives Heating⇄Paused transitions.
|
||||
|
||||
const COOK_MS: u64 = 8_000;
|
||||
|
||||
statechart! {
|
||||
Oven {
|
||||
context: OvenContext;
|
||||
events: OvenEvent;
|
||||
|
||||
entry: boot_self_test;
|
||||
entry: log_boot_done;
|
||||
exit: save_last_state;
|
||||
exit: cut_all_power;
|
||||
default(Idle);
|
||||
terminate(PowerFailure);
|
||||
|
||||
// Root-level door tracking. Any state whose local handler shadows these
|
||||
// must repeat `mark_door_*` alongside its own handlers.
|
||||
on(DoorOpened) => mark_door_open;
|
||||
on(DoorClosed) => mark_door_closed;
|
||||
|
||||
// Idle is split so Start can only fire when the door is closed. This is
|
||||
// the HSM equivalent of a guard — a structural check rather than a
|
||||
// boolean predicate.
|
||||
state Idle {
|
||||
// `sync_door` runs after the default descent completes handling, then
|
||||
// emits DoorOpened if the door is physically open. That re-aligns the
|
||||
// machine with reality when we land in Idle from Running (e.g. Stop
|
||||
// pressed while door is open). The emitted event is processed after
|
||||
// the entry cascade, so DoorClosed → DoorOpen happens cleanly.
|
||||
entry: enter_idle, sync_door;
|
||||
default(DoorClosed);
|
||||
|
||||
state DoorClosed {
|
||||
entry: show_ready;
|
||||
// Local handlers, so we must repeat mark_door_open here.
|
||||
on(DoorOpened) => mark_door_open;
|
||||
on(DoorOpened) => DoorOpen;
|
||||
|
||||
on(StartPressed) => record_start;
|
||||
on(StartPressed) => prime_cook;
|
||||
on(StartPressed) => Running;
|
||||
}
|
||||
|
||||
state DoorOpen {
|
||||
entry: show_close_door;
|
||||
on(DoorClosed) => mark_door_closed;
|
||||
on(DoorClosed) => DoorClosed;
|
||||
// Pressing Start while door is open: beep + log, no transition.
|
||||
on(StartPressed) => warn_door_open;
|
||||
}
|
||||
}
|
||||
|
||||
state Running {
|
||||
entry: enter_running, start_cook_clock;
|
||||
exit: stop_cook_clock;
|
||||
default(Cooking);
|
||||
|
||||
on(after Duration::from_millis(250)) => heartbeat;
|
||||
on(StopPressed) => Idle;
|
||||
on(CookDone) => Done;
|
||||
|
||||
state Cooking {
|
||||
entry: enter_cooking, show_cooking;
|
||||
default(Heating);
|
||||
|
||||
state Heating {
|
||||
// Magnetron + heater live on Heating so they turn off the
|
||||
// instant we leave for Paused.
|
||||
entry: enter_heating, start_magnetron, heater_on;
|
||||
exit: heater_off, stop_magnetron;
|
||||
on(DoorOpened) => mark_door_open;
|
||||
on(DoorOpened) => pause_cook_clock;
|
||||
on(DoorOpened) => Suspended;
|
||||
}
|
||||
|
||||
// Suspended = "cook is paused". Two substates express the classic
|
||||
// microwave rule: closing the door alone does NOT resume cooking
|
||||
// — the user must press Start. Pressing Stop (handled by Running)
|
||||
// cancels entirely back to Idle.
|
||||
state Suspended {
|
||||
entry: enter_suspended, start_pause_blink;
|
||||
exit: stop_pause_blink;
|
||||
default(DoorAjar);
|
||||
|
||||
state DoorAjar {
|
||||
entry: show_paused;
|
||||
on(DoorClosed) => mark_door_closed;
|
||||
on(DoorClosed) => AwaitingResume;
|
||||
}
|
||||
|
||||
state AwaitingResume {
|
||||
entry: show_press_start;
|
||||
on(DoorOpened) => mark_door_open;
|
||||
on(DoorOpened) => DoorAjar;
|
||||
|
||||
// Actions fire before the transition, so the clock is
|
||||
// resumed and the log line written before we re-enter
|
||||
// Heating (which turns the magnetron back on).
|
||||
on(StartPressed) => log_resume;
|
||||
on(StartPressed) => resume_cook_clock;
|
||||
on(StartPressed) => Heating;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state Done {
|
||||
entry: enter_done, beep, show_done;
|
||||
exit: exit_done;
|
||||
|
||||
on(after Duration::from_millis(2000)) => schedule_auto_reset;
|
||||
on(StartPressed) => Done;
|
||||
on(Reset) => Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Actions ----------
|
||||
//
|
||||
// Every method is either publishing to the shared UI state or emitting an
|
||||
// event back into the machine.
|
||||
|
||||
impl OvenActions for OvenActionContext<'_> {
|
||||
// Root
|
||||
async fn boot_self_test(&mut self) { self.ui.lock().unwrap().log("BOOT self-test ok"); }
|
||||
async fn log_boot_done(&mut self) { self.ui.lock().unwrap().log("BOOT ready"); }
|
||||
async fn save_last_state(&mut self){ self.ui.lock().unwrap().log("SHUTDOWN persisted state"); }
|
||||
async fn cut_all_power(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.magnetron = false;
|
||||
ui.heater = false;
|
||||
ui.display = "-- OFF --".into();
|
||||
ui.terminated = true;
|
||||
ui.log("SHUTDOWN power cut");
|
||||
}
|
||||
|
||||
// Door tracking (root-level)
|
||||
async fn mark_door_open(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.door_open = true;
|
||||
}
|
||||
async fn mark_door_closed(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.door_open = false;
|
||||
}
|
||||
|
||||
// Idle
|
||||
async fn enter_idle(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Idle"]);
|
||||
}
|
||||
async fn sync_door(&mut self) {
|
||||
let door_open = self.ui.lock().unwrap().door_open;
|
||||
if door_open {
|
||||
self.ui.lock().unwrap().log("IDLE door still open — syncing");
|
||||
let _ = self.emit(OvenEvent::DoorOpened);
|
||||
}
|
||||
}
|
||||
async fn show_ready(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Idle", "DoorClosed"]);
|
||||
ui.display = "READY".into();
|
||||
ui.log("IDLE ready");
|
||||
}
|
||||
async fn show_close_door(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Idle", "DoorOpen"]);
|
||||
ui.display = "CLOSE DOOR".into();
|
||||
ui.log("IDLE door is open");
|
||||
}
|
||||
async fn warn_door_open(&mut self) {
|
||||
self.ui.lock().unwrap().log("WARN can't start — door open");
|
||||
}
|
||||
async fn record_start(&mut self) { self.ui.lock().unwrap().log("BTN start pressed"); }
|
||||
async fn prime_cook(&mut self) { self.ui.lock().unwrap().log("BTN primed cook"); }
|
||||
async fn beep(&mut self) { self.ui.lock().unwrap().log("*BEEP*"); }
|
||||
|
||||
// Running
|
||||
async fn enter_running(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running"]);
|
||||
}
|
||||
async fn start_magnetron(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.magnetron = true;
|
||||
ui.log("MAG on");
|
||||
}
|
||||
async fn stop_magnetron(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.magnetron = false;
|
||||
ui.log("MAG off");
|
||||
}
|
||||
async fn start_cook_clock(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
let total = Duration::from_millis(COOK_MS);
|
||||
ui.cook_total = total;
|
||||
ui.cook_deadline = Some(Instant::now() + total);
|
||||
ui.paused_at = None;
|
||||
}
|
||||
async fn stop_cook_clock(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.cook_deadline = None;
|
||||
ui.paused_at = None;
|
||||
}
|
||||
async fn pause_cook_clock(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.paused_at = Some(Instant::now());
|
||||
ui.log("CLOCK paused");
|
||||
}
|
||||
async fn resume_cook_clock(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
if let (Some(paused_at), Some(deadline)) = (ui.paused_at, ui.cook_deadline) {
|
||||
let skipped = paused_at.elapsed();
|
||||
ui.cook_deadline = Some(deadline + skipped);
|
||||
ui.log(format!("CLOCK resume (+{}ms)", skipped.as_millis()));
|
||||
}
|
||||
ui.paused_at = None;
|
||||
}
|
||||
async fn heartbeat(&mut self) { /* UI gauge animates on its own */ }
|
||||
|
||||
// Cooking / Heating
|
||||
async fn enter_cooking(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running", "Cooking"]);
|
||||
}
|
||||
async fn show_cooking(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.display = "COOKING".into();
|
||||
ui.blink = false;
|
||||
}
|
||||
async fn enter_heating(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running", "Cooking", "Heating"]);
|
||||
ui.blink = false;
|
||||
ui.display = "COOKING".into();
|
||||
}
|
||||
async fn heater_on(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.heater = true;
|
||||
ui.log("HEAT on");
|
||||
}
|
||||
async fn heater_off(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.heater = false;
|
||||
ui.log("HEAT off");
|
||||
}
|
||||
|
||||
// Suspended (paused)
|
||||
async fn enter_suspended(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running", "Cooking", "Suspended"]);
|
||||
}
|
||||
async fn show_paused(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running", "Cooking", "Suspended", "DoorAjar"]);
|
||||
ui.display = "PAUSED".into();
|
||||
}
|
||||
async fn show_press_start(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Running", "Cooking", "Suspended", "AwaitingResume"]);
|
||||
ui.display = "PRESS START".into();
|
||||
ui.log("PAUSE door closed — press Start to resume");
|
||||
}
|
||||
async fn start_pause_blink(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.blink = true;
|
||||
ui.log("PAUSE blink on");
|
||||
}
|
||||
async fn stop_pause_blink(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.blink = false;
|
||||
ui.log("PAUSE blink off");
|
||||
}
|
||||
async fn log_resume(&mut self) { self.ui.lock().unwrap().log("PAUSE resuming"); }
|
||||
|
||||
// Done
|
||||
async fn enter_done(&mut self) {
|
||||
let mut ui = self.ui.lock().unwrap();
|
||||
ui.set_path(&["Oven", "Done"]);
|
||||
}
|
||||
async fn show_done(&mut self) {
|
||||
self.ui.lock().unwrap().display = "DONE!".into();
|
||||
}
|
||||
async fn exit_done(&mut self) { self.ui.lock().unwrap().log("DONE exit"); }
|
||||
async fn schedule_auto_reset(&mut self) {
|
||||
self.ui.lock().unwrap().log("DONE auto-reset queued");
|
||||
self.emit(OvenEvent::Reset).expect("queue has room");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Rendering ----------
|
||||
|
||||
fn draw(frame: &mut ratatui::Frame<'_>, ui: &UiState) {
|
||||
let size = frame.area();
|
||||
let root = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(16),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(size);
|
||||
|
||||
// --- Title bar ---
|
||||
let title = if ui.terminated {
|
||||
Line::from(vec![
|
||||
Span::styled(" MICROWAVE ", Style::new().bg(Color::Red).fg(Color::White).bold()),
|
||||
Span::raw(" "),
|
||||
Span::styled("TERMINATED", Style::new().fg(Color::Red).bold()),
|
||||
])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
Span::styled(" MICROWAVE ", Style::new().bg(Color::Yellow).fg(Color::Black).bold()),
|
||||
Span::raw(" hsmc statechart demo · press Q to quit"),
|
||||
])
|
||||
};
|
||||
frame.render_widget(Paragraph::new(title), root[0]);
|
||||
|
||||
// --- Main area: oven on the left, status on the right ---
|
||||
let main = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||
.split(root[1]);
|
||||
|
||||
draw_oven(frame, main[0], ui);
|
||||
draw_status(frame, main[1], ui);
|
||||
|
||||
// --- Log ---
|
||||
let log_lines: Vec<Line> = ui.log.iter().rev().take(8).rev()
|
||||
.map(|s| Line::from(s.as_str()))
|
||||
.collect();
|
||||
let log = Paragraph::new(log_lines)
|
||||
.block(Block::default().title(" Event log ").borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(log, root[2]);
|
||||
|
||||
// --- Keys ---
|
||||
let keys = Line::from(vec![
|
||||
key_hint("S", "Start"),
|
||||
Span::raw(" "),
|
||||
key_hint("X", "Stop"),
|
||||
Span::raw(" "),
|
||||
key_hint("O", "Open door"),
|
||||
Span::raw(" "),
|
||||
key_hint("C", "Close door"),
|
||||
Span::raw(" "),
|
||||
key_hint("K", "Kill (power fail)"),
|
||||
Span::raw(" "),
|
||||
key_hint("Q", "Quit"),
|
||||
]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(keys).block(Block::default().borders(Borders::ALL).title(" Controls ")),
|
||||
root[3],
|
||||
);
|
||||
}
|
||||
|
||||
fn key_hint(k: &str, label: &str) -> Span<'static> {
|
||||
Span::from(format!("[{}] {}", k, label)).fg(Color::Cyan)
|
||||
}
|
||||
|
||||
fn draw_oven(frame: &mut ratatui::Frame<'_>, area: Rect, ui: &UiState) {
|
||||
let block = Block::default().borders(Borders::ALL).title(" Oven ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Door indicator line
|
||||
let door_line = if ui.door_open {
|
||||
Span::styled(" ┌───── DOOR OPEN ─────┐", Style::new().fg(Color::Yellow).bold())
|
||||
} else {
|
||||
Span::styled(" ┌───── door closed ─────┐", Style::new().fg(Color::DarkGray))
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Line::from(door_line)), rows[0]);
|
||||
|
||||
// Big display
|
||||
let display_style = if ui.terminated {
|
||||
Style::new().fg(Color::Red).bold()
|
||||
} else if ui.blink && (Instant::now().elapsed().as_millis() / 300) % 2 == 0 {
|
||||
Style::new().fg(Color::Yellow).bold()
|
||||
} else if ui.display == "DONE!" {
|
||||
Style::new().fg(Color::Green).bold()
|
||||
} else if ui.display == "COOKING" {
|
||||
Style::new().fg(Color::LightRed).bold()
|
||||
} else {
|
||||
Style::new().fg(Color::Cyan).bold()
|
||||
};
|
||||
let big = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
format!(" ┃ {:^14} ┃", ui.display),
|
||||
display_style,
|
||||
)),
|
||||
Line::from(Span::styled(" ┃ ┃", Style::new().fg(Color::DarkGray))),
|
||||
Line::from(Span::styled(" ┗━━━━━━━━━━━━━━━━━━┛", Style::new().fg(Color::DarkGray))),
|
||||
]);
|
||||
frame.render_widget(big, rows[1]);
|
||||
|
||||
// Progress gauge for cook — freezes while paused.
|
||||
let (ratio, label, color) = if let Some(deadline) = ui.cook_deadline {
|
||||
let now = ui.paused_at.unwrap_or_else(Instant::now);
|
||||
let total = ui.cook_total;
|
||||
let remaining = deadline.saturating_duration_since(now);
|
||||
let elapsed = total.saturating_sub(remaining);
|
||||
let r = (elapsed.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0);
|
||||
let label = format!("{:.1}s / {:.1}s{}",
|
||||
elapsed.as_secs_f64(),
|
||||
total.as_secs_f64(),
|
||||
if ui.paused_at.is_some() { " (paused)" } else { "" },
|
||||
);
|
||||
let color = if ui.paused_at.is_some() { Color::Yellow } else { Color::LightRed };
|
||||
(r, label, color)
|
||||
} else {
|
||||
(0.0, "—".to_string(), Color::DarkGray)
|
||||
};
|
||||
let gauge = Gauge::default()
|
||||
.block(Block::default().borders(Borders::NONE))
|
||||
.gauge_style(Style::new().fg(color))
|
||||
.ratio(ratio)
|
||||
.label(label);
|
||||
frame.render_widget(gauge, rows[3]);
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut ratatui::Frame<'_>, area: Rect, ui: &UiState) {
|
||||
let block = Block::default().borders(Borders::ALL).title(" Status ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let path = if ui.state_path.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
ui.state_path.join(" › ")
|
||||
};
|
||||
|
||||
let bullet = |on: bool| {
|
||||
if on { Span::styled("●", Style::new().fg(Color::Green).bold()) }
|
||||
else { Span::styled("○", Style::new().fg(Color::DarkGray)) }
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" State path: ", Style::new().fg(Color::DarkGray)),
|
||||
Span::styled(path, Style::new().fg(Color::White).bold()),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::raw(" Magnetron "),
|
||||
bullet(ui.magnetron),
|
||||
Span::raw(if ui.magnetron { " ON " } else { " off" }),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Heater "),
|
||||
bullet(ui.heater),
|
||||
Span::raw(if ui.heater { " ON " } else { " off" }),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Door "),
|
||||
if ui.door_open {
|
||||
Span::styled("OPEN ", Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD))
|
||||
} else {
|
||||
Span::styled("CLOSED", Style::new().fg(Color::DarkGray))
|
||||
},
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" hsmc tip: ", Style::new().fg(Color::DarkGray)),
|
||||
Span::raw("events bubble · timers don't"),
|
||||
]),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
// ---------- Main ----------
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> io::Result<()> {
|
||||
// Shared UI state.
|
||||
let ui = Arc::new(Mutex::new(UiState::default()));
|
||||
|
||||
// Build the machine.
|
||||
let ctx = OvenContext { ui: Arc::clone(&ui) };
|
||||
let mut oven = Oven::with_queue_capacity::<16>(ctx);
|
||||
let sender = oven.sender();
|
||||
let fault_sender = oven.sender();
|
||||
let cook_sender = oven.sender();
|
||||
|
||||
// Raw-mode terminal.
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// Render loop — 30 fps.
|
||||
let ui_for_render = Arc::clone(&ui);
|
||||
let (render_stop_tx, mut render_stop_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let render_task = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(33));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut render_stop_rx => break,
|
||||
_ = interval.tick() => {
|
||||
let snapshot = {
|
||||
let g = ui_for_render.lock().unwrap();
|
||||
UiSnapshot::from(&*g)
|
||||
};
|
||||
let _ = terminal.draw(|f| draw(f, &snapshot.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Restore terminal.
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture);
|
||||
terminal
|
||||
});
|
||||
|
||||
// Cook-clock watchdog. Polls shared UI state; when we're not paused and
|
||||
// the deadline has passed, fires a single CookDone event and then sleeps
|
||||
// until the deadline is re-armed. The machine discards CookDone if the
|
||||
// current state doesn't handle it (e.g. after StopPressed).
|
||||
let ui_for_watchdog = Arc::clone(&ui);
|
||||
let watchdog_task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let should_fire = {
|
||||
let u = ui_for_watchdog.lock().unwrap();
|
||||
if u.terminated { return; }
|
||||
match (u.cook_deadline, u.paused_at) {
|
||||
(Some(deadline), None) => Instant::now() >= deadline,
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if should_fire {
|
||||
let _ = cook_sender.send(OvenEvent::CookDone);
|
||||
// Give the machine a beat to process and clear cook_deadline
|
||||
// (via Running's exit → stop_cook_clock) before we check again.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Input loop.
|
||||
let ui_for_input = Arc::clone(&ui);
|
||||
let input_task = tokio::spawn(async move {
|
||||
let mut events = EventStream::new();
|
||||
while let Some(Ok(ev)) = events.next().await {
|
||||
if let Event::Key(k) = ev {
|
||||
if k.kind != KeyEventKind::Press { continue; }
|
||||
let code = k.code;
|
||||
let msg = match code {
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => Some(OvenEvent::StartPressed),
|
||||
KeyCode::Char('x') | KeyCode::Char('X') => Some(OvenEvent::StopPressed),
|
||||
KeyCode::Char('o') | KeyCode::Char('O') => Some(OvenEvent::DoorOpened),
|
||||
KeyCode::Char('c') | KeyCode::Char('C') => Some(OvenEvent::DoorClosed),
|
||||
KeyCode::Char('k') | KeyCode::Char('K') => Some(OvenEvent::PowerFailure),
|
||||
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
|
||||
// Quit: cleanly terminate the machine via PowerFailure.
|
||||
let _ = fault_sender.send(OvenEvent::PowerFailure);
|
||||
break;
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(m) = msg {
|
||||
ui_for_input.lock().unwrap().log(format!("KEY -> {:?}", m));
|
||||
let _ = sender.send(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Drive the state machine on the main task.
|
||||
let _ = oven.run().await;
|
||||
|
||||
// Let the UI show the TERMINATED banner briefly.
|
||||
tokio::time::sleep(Duration::from_millis(700)).await;
|
||||
|
||||
// Stop the render loop.
|
||||
let _ = render_stop_tx.send(());
|
||||
input_task.abort();
|
||||
watchdog_task.abort();
|
||||
let mut terminal = render_task.await.expect("render task");
|
||||
|
||||
// Belt-and-braces teardown in case the render loop didn't get to it.
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture);
|
||||
|
||||
// Final transcript.
|
||||
let ui = ui.lock().unwrap();
|
||||
println!("\nFinal state: {}", ui.state_path.join(" › "));
|
||||
println!("Events: {}", ui.log.len());
|
||||
for line in ui.log.iter() {
|
||||
println!(" {}", line);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Snapshot so we don't hold the Mutex across the render.
|
||||
struct UiSnapshot {
|
||||
state_path: Vec<&'static str>,
|
||||
display: String,
|
||||
magnetron: bool,
|
||||
heater: bool,
|
||||
door_open: bool,
|
||||
blink: bool,
|
||||
log: VecDeque<String>,
|
||||
cook_deadline: Option<Instant>,
|
||||
cook_total: Duration,
|
||||
paused_at: Option<Instant>,
|
||||
terminated: bool,
|
||||
}
|
||||
impl From<&UiState> for UiSnapshot {
|
||||
fn from(u: &UiState) -> Self {
|
||||
Self {
|
||||
state_path: u.state_path.clone(),
|
||||
display: u.display.clone(),
|
||||
magnetron: u.magnetron,
|
||||
heater: u.heater,
|
||||
door_open: u.door_open,
|
||||
blink: u.blink,
|
||||
log: u.log.clone(),
|
||||
cook_deadline: u.cook_deadline,
|
||||
cook_total: u.cook_total,
|
||||
paused_at: u.paused_at,
|
||||
terminated: u.terminated,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<UiSnapshot> for UiState {
|
||||
fn from(s: UiSnapshot) -> Self {
|
||||
Self {
|
||||
state_path: s.state_path,
|
||||
display: s.display,
|
||||
magnetron: s.magnetron,
|
||||
heater: s.heater,
|
||||
door_open: s.door_open,
|
||||
blink: s.blink,
|
||||
log: s.log,
|
||||
cook_deadline: s.cook_deadline,
|
||||
cook_total: s.cook_total,
|
||||
paused_at: s.paused_at,
|
||||
terminated: s.terminated,
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
-14
@@ -45,6 +45,24 @@ pub enum ActionKind {
|
||||
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. Emitted for `default(...)` firings: after a state with a
|
||||
/// declared default is entered, the default fires as a transition
|
||||
/// with this reason. The default target may be any state in the
|
||||
/// chart — sibling, ancestor, anywhere.
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// One observable atom of chart execution. The macro emits these at every
|
||||
/// hook point so the full sequence can be journaled and compared.
|
||||
///
|
||||
@@ -54,14 +72,23 @@ pub enum ActionKind {
|
||||
pub enum TraceEvent {
|
||||
/// First event in any journal. Identifies the chart definition.
|
||||
Started { chart_hash: u64 },
|
||||
/// A state's entry begins. Followed by `ActionInvoked{Entry}` for each
|
||||
/// of that state's entry actions, then `DuringStarted` for each during
|
||||
/// declared on the state, then potentially the next state's `Entered`
|
||||
/// (default-descent).
|
||||
/// A state's entry began. The marker is emitted before any of the
|
||||
/// state's entry actions, timers, or durings have run. Followed by
|
||||
/// `ActionInvoked{Entry}`, `TimerArmed`, `DuringStarted`, then the
|
||||
/// 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 },
|
||||
/// A state's exit completed. Preceded by `DuringCancelled` for each
|
||||
/// during on the state, followed by `ActionInvoked{Exit}` for each
|
||||
/// exit action.
|
||||
/// A state's exit began. The marker is emitted before any of the
|
||||
/// state's during cancellations, timer cancellations, or exit
|
||||
/// 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 },
|
||||
/// An action function was called.
|
||||
ActionInvoked {
|
||||
@@ -75,14 +102,30 @@ pub enum TraceEvent {
|
||||
/// A `during:` activity was cancelled because its owning state is
|
||||
/// being exited.
|
||||
DuringCancelled { state: u16, during: u16 },
|
||||
/// A transition is about to fire. `from` is the innermost active state
|
||||
/// at the moment the transition was triggered; `to` is the target.
|
||||
/// Followed by the actual `Exited` / `Entered` / etc. events that
|
||||
/// implement the transition.
|
||||
TransitionFired { from: Option<u16>, to: u16 },
|
||||
/// A transition is about to fire. `from` is the innermost active
|
||||
/// state at the moment the transition was triggered; `to` is the
|
||||
/// target; `reason` records what caused the transition (event,
|
||||
/// timer, or internal). Followed by the actual `ExitBegan`/`Exited`/
|
||||
/// `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
|
||||
/// whose handler ran (which may be an ancestor of the innermost active
|
||||
/// state if the event bubbled).
|
||||
/// whose handler ran (which may be an ancestor of the innermost
|
||||
/// active state if the event bubbled).
|
||||
EventDelivered { handler_state: u16, event: u16 },
|
||||
/// An event was dispatched but no state on the active path had a
|
||||
/// handler. Silently dropped per spec.
|
||||
|
||||
+652
-125
@@ -102,140 +102,614 @@ pub use hsmc_macros::statechart;
|
||||
mod error;
|
||||
pub use error::HsmcError;
|
||||
|
||||
// ── Journal (deterministic execution trace) ─────────────────────────
|
||||
// ── Unified observation pipeline ────────────────────────────────────
|
||||
//
|
||||
// When `feature = "journal"` is enabled, the macro emits one
|
||||
// [`TraceEvent`] per observable atom: entries, exits, action calls,
|
||||
// during start/cancel, timer arm/cancel/fire, queued emits, event
|
||||
// dispatch, transitions, termination. The events are appended to the
|
||||
// machine's internal `JournalSink` so callers can compare against an
|
||||
// expected sequence (see `tests/replay.rs`).
|
||||
// The `statechart!` macro emits ONE `__chart_observe!` call per
|
||||
// observable atom of execution — entry/exit phase begin/end markers,
|
||||
// action invocations, during start/cancel, timer arm/cancel/fire,
|
||||
// emit queue push/fail, event receive/deliver/drop, transition
|
||||
// begin/complete, terminate-requested, and terminated.
|
||||
//
|
||||
// When the feature is OFF, the codegen still emits `__chart_journal!`
|
||||
// invocations at every hook site, but the macro expands to nothing
|
||||
// (zero overhead) and `JournalSink` is a ZST so the field on the
|
||||
// machine takes 0 bytes.
|
||||
// `__chart_observe!` is the single dispatcher: each arm fans the
|
||||
// observation out to whichever sinks are enabled at compile time:
|
||||
//
|
||||
// - `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")]
|
||||
mod 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.
|
||||
/// Not part of the public API.
|
||||
/// Internal observation dispatcher invoked by `statechart!`-generated
|
||||
/// code. Not part of the public API.
|
||||
///
|
||||
/// Call form: `__chart_journal!(<sink-expr>, <TraceEvent expr>)`.
|
||||
/// The first argument is a place expression for the sink (typically
|
||||
/// `self.__journal`); the second is the full `TraceEvent` value to
|
||||
/// push.
|
||||
/// Call form (one arm per `TraceEvent` variant):
|
||||
/// `__chart_observe!(<Variant>, <sink-expr>, <chart-literal>, <variant args…>)`
|
||||
///
|
||||
/// When the `journal` feature is off, the macro discards both arguments
|
||||
/// and expands to nothing — the codegen can refer to `TraceEvent::*`
|
||||
/// without the type having to exist.
|
||||
#[cfg(feature = "journal")]
|
||||
/// `sink-expr` is the place expression for the journal sink (typically
|
||||
/// `&mut self.__journal`); `chart-literal` is the chart name as a
|
||||
/// string literal. Variant args are passed positionally — see each arm
|
||||
/// 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)]
|
||||
#[macro_export]
|
||||
macro_rules! __chart_journal {
|
||||
($sink:expr, $event:expr) => {
|
||||
$sink.push($event);
|
||||
};
|
||||
}
|
||||
macro_rules! __chart_observe {
|
||||
// ── Started ────────────────────────────────────────────────────
|
||||
(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.
|
||||
#[cfg(not(feature = "journal"))]
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __chart_journal {
|
||||
($($_:tt)*) => {};
|
||||
}
|
||||
// ── EnterBegan / Entered ───────────────────────────────────────
|
||||
(EnterBegan, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
|
||||
#[cfg(feature = "journal")]
|
||||
{
|
||||
$sink.push($crate::TraceEvent::EnterBegan { state: $sid });
|
||||
}
|
||||
#[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 ──────────────────────────────────────────
|
||||
//
|
||||
// When a chart opts in via `trace;`, the proc-macro emits calls to
|
||||
// `::hsmc::__chart_trace!(<kind> <chart-name>, <state-or-action>);` at
|
||||
// every state entry, exit, action invocation, and transition. This
|
||||
// crate-level macro picks the backend based on which `trace-*` cargo
|
||||
// feature is enabled — exactly one of `trace-defmt` / `trace-log` /
|
||||
// `trace-tracing`. With none enabled it expands to nothing.
|
||||
//
|
||||
// Multiple `trace-*` features enabled simultaneously produce a
|
||||
// duplicate-definition compile error from rustc. Intentional.
|
||||
// ── ExitBegan / Exited ─────────────────────────────────────────
|
||||
(ExitBegan, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
|
||||
#[cfg(feature = "journal")]
|
||||
{
|
||||
$sink.push($crate::TraceEvent::ExitBegan { state: $sid });
|
||||
}
|
||||
#[cfg(feature = "trace-defmt")]
|
||||
::defmt::info!("[statechart:{}] exiting state={}", $chart, $sname);
|
||||
#[cfg(feature = "trace-log")]
|
||||
::log::info!("[statechart:{}] exiting state={}", $chart, $sname);
|
||||
#[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.
|
||||
/// Not part of the public API.
|
||||
#[cfg(feature = "trace-defmt")]
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __chart_trace {
|
||||
(enter $chart:literal, $state:expr) => {
|
||||
::defmt::info!("[{}] enter {}", $chart, $state);
|
||||
};
|
||||
(exit $chart:literal, $state:expr) => {
|
||||
::defmt::info!("[{}] exit {}", $chart, $state);
|
||||
};
|
||||
(action $chart:literal, $action:expr) => {
|
||||
::defmt::trace!("[{}] action {}", $chart, $action);
|
||||
};
|
||||
(transition $chart:literal, $from:expr, $to:expr) => {
|
||||
::defmt::info!("[{}] {} -> {}", $chart, $from, $to);
|
||||
};
|
||||
($($_:tt)*) => {};
|
||||
}
|
||||
// ── ActionInvoked (Entry / Exit / Handler) ─────────────────────
|
||||
(ActionInvoked, $sink:expr, $chart:literal,
|
||||
$sid:expr, $sname:expr,
|
||||
$aid:expr, $aname:expr,
|
||||
Entry) => {{
|
||||
#[cfg(feature = "journal")]
|
||||
{
|
||||
$sink.push($crate::TraceEvent::ActionInvoked {
|
||||
state: $sid,
|
||||
action: $aid,
|
||||
kind: $crate::ActionKind::Entry,
|
||||
});
|
||||
}
|
||||
#[cfg(feature = "trace-defmt")]
|
||||
::defmt::trace!(
|
||||
"[statechart:{}] action state={} action={} kind=entry",
|
||||
$chart,
|
||||
$sname,
|
||||
$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.
|
||||
/// Not part of the public API.
|
||||
#[cfg(feature = "trace-log")]
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __chart_trace {
|
||||
(enter $chart:literal, $state:expr) => {
|
||||
::log::info!("[{}] enter {}", $chart, $state);
|
||||
};
|
||||
(exit $chart:literal, $state:expr) => {
|
||||
::log::info!("[{}] exit {}", $chart, $state);
|
||||
};
|
||||
(action $chart:literal, $action:expr) => {
|
||||
::log::trace!("[{}] action {}", $chart, $action);
|
||||
};
|
||||
(transition $chart:literal, $from:expr, $to:expr) => {
|
||||
::log::info!("[{}] {} -> {}", $chart, $from, $to);
|
||||
};
|
||||
($($_:tt)*) => {};
|
||||
}
|
||||
// ── DuringStarted / DuringCancelled ────────────────────────────
|
||||
(DuringStarted, $sink:expr, $chart:literal,
|
||||
$sid:expr, $sname:expr,
|
||||
$did:expr, $dname:expr) => {{
|
||||
#[cfg(feature = "journal")]
|
||||
{
|
||||
$sink.push($crate::TraceEvent::DuringStarted {
|
||||
state: $sid,
|
||||
during: $did,
|
||||
});
|
||||
}
|
||||
#[cfg(feature = "trace-defmt")]
|
||||
::defmt::debug!(
|
||||
"[statechart:{}] during-started state={} during={}",
|
||||
$chart,
|
||||
$sname,
|
||||
$dname
|
||||
);
|
||||
#[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.
|
||||
/// Not part of the public API.
|
||||
#[cfg(feature = "trace-tracing")]
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __chart_trace {
|
||||
(enter $chart:literal, $state:expr) => {
|
||||
::tracing::info!(chart = $chart, state = $state, "enter");
|
||||
};
|
||||
(exit $chart:literal, $state:expr) => {
|
||||
::tracing::info!(chart = $chart, state = $state, "exit");
|
||||
};
|
||||
(action $chart:literal, $action:expr) => {
|
||||
::tracing::trace!(chart = $chart, action = $action, "action");
|
||||
};
|
||||
(transition $chart:literal, $from:expr, $to:expr) => {
|
||||
::tracing::info!(chart = $chart, from = $from, to = $to, "transition");
|
||||
};
|
||||
($($_:tt)*) => {};
|
||||
}
|
||||
// ── TransitionFired (begin marker) ─────────────────────────────
|
||||
//
|
||||
// The reason is rendered three ways (event:Name, timer:State/Timer,
|
||||
// internal). The journal stores the structured reason and the trace
|
||||
// gets a pre-rendered `&'static str` (`$rname`) so log lines stay
|
||||
// simple to parse.
|
||||
(TransitionFired, $sink:expr, $chart:literal,
|
||||
$from:expr, $fname:expr,
|
||||
$to:expr, $tname:expr,
|
||||
$reason:expr, $rname:expr) => {{
|
||||
#[cfg(feature = "journal")]
|
||||
{
|
||||
$sink.push($crate::TraceEvent::TransitionFired {
|
||||
from: $from,
|
||||
to: $to,
|
||||
reason: $reason,
|
||||
});
|
||||
}
|
||||
#[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
|
||||
/// chart's `trace;` keyword still parses; calls just expand to nothing.
|
||||
#[cfg(not(any(
|
||||
feature = "trace-defmt",
|
||||
feature = "trace-log",
|
||||
feature = "trace-tracing",
|
||||
)))]
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __chart_trace {
|
||||
($($_:tt)*) => {};
|
||||
// ── TransitionComplete (end marker) ────────────────────────────
|
||||
(TransitionComplete, $sink:expr, $chart:literal,
|
||||
$from:expr, $fname:expr,
|
||||
$to:expr, $tname:expr) => {{
|
||||
#[cfg(feature = "journal")]
|
||||
{
|
||||
$sink.push($crate::TraceEvent::TransitionComplete {
|
||||
from: $from,
|
||||
to: $to,
|
||||
});
|
||||
}
|
||||
#[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)]
|
||||
@@ -338,7 +812,12 @@ pub mod __private {
|
||||
pub struct TimerEntry {
|
||||
pub state: 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> {
|
||||
@@ -354,7 +833,12 @@ pub mod __private {
|
||||
}
|
||||
}
|
||||
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.
|
||||
for e in self.entries.iter_mut() {
|
||||
if e.state == state && e.trigger == trigger {
|
||||
@@ -368,22 +852,51 @@ pub mod __private {
|
||||
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) {
|
||||
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) {
|
||||
self.entries
|
||||
.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) {
|
||||
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() {
|
||||
e.remaining_ns = e.remaining_ns.saturating_sub(ns);
|
||||
}
|
||||
}
|
||||
/// Returns (state, trigger) of first expired timer, picking the innermost
|
||||
/// (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)> {
|
||||
if self.entries.is_empty() {
|
||||
None
|
||||
} else {
|
||||
self.pop_expired_nonempty(depth)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn pop_expired_nonempty(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
|
||||
let mut best: Option<usize> = None;
|
||||
for (i, e) in self.entries.iter().enumerate() {
|
||||
if e.remaining_ns == 0 {
|
||||
@@ -406,12 +919,26 @@ pub mod __private {
|
||||
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> {
|
||||
self.entries.iter().map(|e| e.remaining_ns).min().map(|ns| {
|
||||
let secs = (ns / 1_000_000_000) as u64;
|
||||
let rem = (ns % 1_000_000_000) as u32;
|
||||
core::time::Duration::new(secs, rem)
|
||||
})
|
||||
if self.entries.is_empty() {
|
||||
None
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#![allow(dead_code)]
|
||||
//! Deterministic-flow tests: `default(...)` targeting an ANCESTOR.
|
||||
//! Inner's `default(Outer)` is a valid up-default. Inner is reachable
|
||||
//! only via an explicit transition. When entered, its default fires
|
||||
//! immediately, exiting Inner back to Outer (up-transition rule:
|
||||
//! Outer is not re-entered).
|
||||
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
GoIn,
|
||||
Halt,
|
||||
}
|
||||
|
||||
statechart! {
|
||||
AncestorDefault {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Outer);
|
||||
terminate(Halt);
|
||||
|
||||
state Outer {
|
||||
entry: outer_in;
|
||||
on(GoIn) => Inner;
|
||||
state Inner {
|
||||
entry: inner_in;
|
||||
exit: inner_out;
|
||||
// Inner's default redirects up to Outer. After entering
|
||||
// Inner, this default fires as an up-transition that
|
||||
// exits Inner. Outer is not re-entered.
|
||||
default(Outer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AncestorDefaultActions for AncestorDefaultActionContext<'_> {
|
||||
async fn outer_in(&mut self) {}
|
||||
async fn inner_in(&mut self) {}
|
||||
async fn inner_out(&mut self) {}
|
||||
}
|
||||
|
||||
const AD_ROOT: u16 = 0;
|
||||
const AD_OUTER: u16 = 1;
|
||||
const AD_INNER: u16 = 2;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_initial_lands_at_outer_no_default_descent() {
|
||||
// Outer has no default of its own — initial entry stops at Outer.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = AncestorDefault::new(Ctx);
|
||||
let _ = m.step(hsmc::Duration::ZERO).await;
|
||||
assert_eq!(m.current_state(), AncestorDefaultState::Outer);
|
||||
let j = m.take_journal();
|
||||
let entered: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(entered, vec![AD_ROOT, AD_OUTER]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_explicit_into_inner_bounces_back_via_up_default() {
|
||||
// `=> Inner` enters Inner (entries fire), then Inner's default fires
|
||||
// as an up-transition Inner → Outer. Outer is NOT re-entered (it
|
||||
// was already on the active path).
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = AncestorDefault::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoIn).await;
|
||||
|
||||
assert_eq!(
|
||||
m.current_state(),
|
||||
AncestorDefaultState::Outer,
|
||||
"Inner's default bounces us back to Outer"
|
||||
);
|
||||
|
||||
let j = m.take_journal();
|
||||
|
||||
// Inner was entered (its entries fired) and then exited
|
||||
// (its default fired an up-transition).
|
||||
let entered_states: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Across the full journal: Root, Outer, Inner. After Inner's
|
||||
// up-default to Outer, Outer is NOT re-entered.
|
||||
assert_eq!(entered_states, vec![AD_ROOT, AD_OUTER, AD_INNER]);
|
||||
|
||||
let exited_states: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
exited_states,
|
||||
vec![AD_INNER],
|
||||
"only Inner exits; Outer remains on the active path"
|
||||
);
|
||||
|
||||
// Outer's entry actions ran exactly once (initial entry only).
|
||||
let outer_entries = j
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
state: AD_OUTER,
|
||||
kind: hsmc::ActionKind::Entry,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(outer_entries, 1, "Outer is entered once and stays put");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#![allow(dead_code)]
|
||||
//! Deterministic-flow tests: `default(...)` chains. Hub → Spoke1 →
|
||||
//! Spoke2. Each link fires as a real LCA-aware transition. The
|
||||
//! compile-time default-graph cycle check guarantees this terminates.
|
||||
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
Halt,
|
||||
}
|
||||
|
||||
statechart! {
|
||||
ChainedDefault {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Hub);
|
||||
terminate(Halt);
|
||||
|
||||
state Hub {
|
||||
entry: hub_in;
|
||||
default(Spoke1);
|
||||
}
|
||||
state Spoke1 {
|
||||
entry: s1_in;
|
||||
exit: s1_out;
|
||||
default(Spoke2);
|
||||
}
|
||||
state Spoke2 {
|
||||
entry: s2_in;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChainedDefaultActions for ChainedDefaultActionContext<'_> {
|
||||
async fn hub_in(&mut self) {}
|
||||
async fn s1_in(&mut self) {}
|
||||
async fn s1_out(&mut self) {}
|
||||
async fn s2_in(&mut self) {}
|
||||
}
|
||||
|
||||
const CD_ROOT: u16 = 0;
|
||||
const CD_HUB: u16 = 1;
|
||||
const CD_SPOKE1: u16 = 2;
|
||||
const CD_SPOKE2: u16 = 3;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_chained_defaults_terminate_at_chain_end() {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = ChainedDefault::new(Ctx);
|
||||
let _ = m.step(hsmc::Duration::ZERO).await;
|
||||
assert_eq!(m.current_state(), ChainedDefaultState::Spoke2);
|
||||
|
||||
let j = m.take_journal();
|
||||
let entered: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(entered, vec![CD_ROOT, CD_HUB, CD_SPOKE1, CD_SPOKE2]);
|
||||
|
||||
let exited: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Each default fires as a sibling-transition: Hub → Spoke1
|
||||
// (exits Hub), then Spoke1 → Spoke2 (exits Spoke1).
|
||||
assert_eq!(exited, vec![CD_HUB, CD_SPOKE1]);
|
||||
|
||||
// At least 2 TransitionFired events for the chained defaults.
|
||||
let txn_count = j
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.count();
|
||||
assert!(txn_count >= 2);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
#![allow(dead_code)]
|
||||
//! Deterministic-flow tests: `default(...)` targeting a SIBLING fires as
|
||||
//! a real LCA-aware transition immediately after the declaring state's
|
||||
//! entries finish. Foyer's `default(LivingRoom)` means: enter Foyer,
|
||||
//! run its entries, then immediately transition Foyer → LivingRoom
|
||||
//! (LCA = Root, exits Foyer, enters LivingRoom).
|
||||
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
Halt,
|
||||
}
|
||||
|
||||
statechart! {
|
||||
SiblingDefault {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Foyer);
|
||||
terminate(Halt);
|
||||
|
||||
state Foyer {
|
||||
entry: foyer_in;
|
||||
exit: foyer_out;
|
||||
default(LivingRoom);
|
||||
}
|
||||
state LivingRoom {
|
||||
entry: living_in;
|
||||
exit: living_out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SiblingDefaultActions for SiblingDefaultActionContext<'_> {
|
||||
async fn foyer_in(&mut self) {}
|
||||
async fn foyer_out(&mut self) {}
|
||||
async fn living_in(&mut self) {}
|
||||
async fn living_out(&mut self) {}
|
||||
}
|
||||
|
||||
const SD_ROOT: u16 = 0;
|
||||
const SD_FOYER: u16 = 1;
|
||||
const SD_LIVING: u16 = 2;
|
||||
|
||||
fn entered(j: &[TraceEvent]) -> Vec<u16> {
|
||||
j.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn exited(j: &[TraceEvent]) -> Vec<u16> {
|
||||
j.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_sibling_default_exits_declarer_lands_at_target() {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = SiblingDefault::new(Ctx);
|
||||
let _ = m.step(hsmc::Duration::ZERO).await;
|
||||
|
||||
assert_eq!(m.current_state(), SiblingDefaultState::LivingRoom);
|
||||
|
||||
let j = m.take_journal();
|
||||
assert_eq!(
|
||||
entered(&j),
|
||||
vec![SD_ROOT, SD_FOYER, SD_LIVING],
|
||||
"Root → Foyer → (default fires) → LivingRoom"
|
||||
);
|
||||
assert_eq!(
|
||||
exited(&j),
|
||||
vec![SD_FOYER],
|
||||
"Foyer is exited as the default transition's `from` state"
|
||||
);
|
||||
|
||||
// Foyer's entry must run before its exit (default fires after entries).
|
||||
let action_seq: Vec<(u16, ActionKind)> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { state, kind, .. } => Some((*state, *kind)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let foyer_entry_idx = action_seq
|
||||
.iter()
|
||||
.position(|(s, k)| *s == SD_FOYER && *k == ActionKind::Entry)
|
||||
.unwrap();
|
||||
let foyer_exit_idx = action_seq
|
||||
.iter()
|
||||
.position(|(s, k)| *s == SD_FOYER && *k == ActionKind::Exit)
|
||||
.unwrap();
|
||||
let living_entry_idx = action_seq
|
||||
.iter()
|
||||
.position(|(s, k)| *s == SD_LIVING && *k == ActionKind::Entry)
|
||||
.unwrap();
|
||||
assert!(foyer_entry_idx < foyer_exit_idx);
|
||||
assert!(foyer_exit_idx < living_entry_idx);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_sibling_default_emits_transition_observation() {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = SiblingDefault::new(Ctx);
|
||||
let _ = m.step(hsmc::Duration::ZERO).await;
|
||||
let j = m.take_journal();
|
||||
let txn_count = j
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.count();
|
||||
assert!(
|
||||
txn_count >= 1,
|
||||
"expected at least one TransitionFired for the default-as-transition firing"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+87
-10
@@ -12,7 +12,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -106,7 +106,9 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_descent_target_with_three_default_levels() {
|
||||
// Idle → Tower: Tower has default chain L1 → L2 → L3.
|
||||
// Idle → Tower: Tower's `default(L1)` fires as a transition, L1's
|
||||
// `default(L2)` fires, L2's `default(L3)` fires. Each link is a
|
||||
// real transition with its own TransitionFired/Complete pair.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
@@ -117,9 +119,22 @@ async fn det_descent_target_with_three_default_levels() {
|
||||
TraceEvent::Started {
|
||||
chart_hash: Desc::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::Entered { state: SI },
|
||||
// Root → Idle fires as Root's default.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SI,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SI },
|
||||
entry(SI, A_IDLE_IN),
|
||||
TraceEvent::Entered { state: SI },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SI,
|
||||
},
|
||||
TraceEvent::EventReceived { event: E_GO },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SI,
|
||||
event: E_GO,
|
||||
@@ -127,17 +142,57 @@ async fn det_descent_target_with_three_default_levels() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SI),
|
||||
to: ST,
|
||||
reason: TransitionReason::Event { event: E_GO },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SI },
|
||||
exit_(SI, A_IDLE_OUT),
|
||||
TraceEvent::Exited { state: SI },
|
||||
TraceEvent::Entered { state: ST },
|
||||
TraceEvent::EnterBegan { state: ST },
|
||||
entry(ST, A_TOWER_IN),
|
||||
TraceEvent::Entered { state: SL1 },
|
||||
TraceEvent::Entered { state: ST },
|
||||
// Tower's default(L1) fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST),
|
||||
to: SL1,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL1 },
|
||||
entry(SL1, A_L1_IN),
|
||||
TraceEvent::Entered { state: SL2 },
|
||||
TraceEvent::Entered { state: SL1 },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST),
|
||||
to: SL1,
|
||||
},
|
||||
// L1's default(L2) fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL1),
|
||||
to: SL2,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL2 },
|
||||
entry(SL2, A_L2_IN),
|
||||
TraceEvent::Entered { state: SL3 },
|
||||
TraceEvent::Entered { state: SL2 },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SL1),
|
||||
to: SL2,
|
||||
},
|
||||
// L2's default(L3) fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL2),
|
||||
to: SL3,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL3 },
|
||||
entry(SL3, A_L3_IN),
|
||||
TraceEvent::Entered { state: SL3 },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SL2),
|
||||
to: SL3,
|
||||
},
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SI),
|
||||
to: ST,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
@@ -151,11 +206,22 @@ async fn det_descent_entries_fire_outer_to_inner() {
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
// After the GO transition: order of Entered events should be Tower → L1 → L2 → L3.
|
||||
// After the GO event-driven transition: order of Entered
|
||||
// events should be Tower → L1 → L2 → L3. We split on the
|
||||
// event-driven TransitionFired specifically (initial-entry
|
||||
// defaults emit TransitionFired{Internal} too).
|
||||
let split = m
|
||||
.journal()
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let entered: Vec<u16> = m.journal()[split..]
|
||||
.iter()
|
||||
@@ -197,10 +263,21 @@ async fn det_descent_action_kind_sequence() {
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
// Split on the event-driven TransitionFired (the initial
|
||||
// default-fire is also TransitionFired{Internal} but happens
|
||||
// before GO).
|
||||
let split = m
|
||||
.journal()
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
// After the Idle exit, all subsequent ActionInvoked must be Entry kind.
|
||||
let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..]
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -124,30 +124,80 @@ async fn det_initial_descent_records_full_path() {
|
||||
let actual = m.take_journal();
|
||||
let expected = vec![
|
||||
// Initial enter (driven by dispatch's prime-state branch).
|
||||
// Each `default(...)` is a real transition, so the journal
|
||||
// brackets every default-fire with TransitionFired
|
||||
// (Internal) / TransitionComplete pairs.
|
||||
TraceEvent::Started {
|
||||
chart_hash: Init4::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
},
|
||||
// Halt.
|
||||
TraceEvent::EventReceived { event: E_HALT },
|
||||
TraceEvent::TerminateRequested { event: E_HALT },
|
||||
// Bottom-up exit chain.
|
||||
TraceEvent::ExitBegan { state: SD },
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
TraceEvent::ExitBegan { state: SC },
|
||||
exit_(SC, A_C_OUT),
|
||||
TraceEvent::Exited { state: SC },
|
||||
TraceEvent::ExitBegan { state: SB },
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
TraceEvent::ExitBegan { state: SA },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::ExitBegan { state: SR },
|
||||
exit_(SR, A_R_OUT),
|
||||
TraceEvent::Exited { state: SR },
|
||||
TraceEvent::Terminated,
|
||||
|
||||
+112
-14
@@ -11,7 +11,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -140,17 +140,39 @@ fn handler(state: u16, action: u16) -> TraceEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// Initial descent: Root → Parent → A.
|
||||
// Initial descent: Root → (Root's default fires) → Parent → (Parent's
|
||||
// default fires) → A. Each default-fire is a real transition.
|
||||
fn initial_descent() -> Vec<TraceEvent> {
|
||||
vec![
|
||||
TraceEvent::Started {
|
||||
chart_hash: Lat::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::Entered { state: SP },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SP,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SP },
|
||||
entry(SP, A_P_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::Entered { state: SP },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SP,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SP),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SP),
|
||||
to: SA,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -185,6 +207,7 @@ async fn det_lateral_a_to_b_lca_is_parent() {
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_GOB },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOB,
|
||||
@@ -192,11 +215,18 @@ async fn det_lateral_a_to_b_lca_is_parent() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Event { event: E_GOB },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SA },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
@@ -215,6 +245,7 @@ async fn det_lateral_a_to_c_exits_parent() {
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_GOC },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOC,
|
||||
@@ -222,13 +253,21 @@ async fn det_lateral_a_to_c_exits_parent() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SC,
|
||||
reason: TransitionReason::Event { event: E_GOC },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SA },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::ExitBegan { state: SP },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::EnterBegan { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SC,
|
||||
},
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
@@ -246,6 +285,7 @@ async fn det_lateral_target_with_children_default_descends() {
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_GOD },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOD,
|
||||
@@ -253,15 +293,34 @@ async fn det_lateral_target_with_children_default_descends() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SD,
|
||||
reason: TransitionReason::Event { event: E_GOD },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SA },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::ExitBegan { state: SP },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::EnterBegan { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SE },
|
||||
TraceEvent::Entered { state: SD },
|
||||
// D's `default(E)` fires as an internal transition.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SD),
|
||||
to: SE,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SE },
|
||||
entry(SE, A_E_IN),
|
||||
TraceEvent::Entered { state: SE },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SD),
|
||||
to: SE,
|
||||
},
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SD,
|
||||
},
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
@@ -280,6 +339,7 @@ async fn det_lateral_to_deeply_nested_target() {
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_GOE },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOE,
|
||||
@@ -287,15 +347,24 @@ async fn det_lateral_to_deeply_nested_target() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SE,
|
||||
reason: TransitionReason::Event { event: E_GOE },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SA },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::ExitBegan { state: SP },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::EnterBegan { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SE },
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::EnterBegan { state: SE },
|
||||
entry(SE, A_E_IN),
|
||||
TraceEvent::Entered { state: SE },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SE,
|
||||
},
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
@@ -316,6 +385,7 @@ async fn det_lateral_chained_transitions() {
|
||||
let mut expected = initial_descent();
|
||||
// A → B
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_GOB },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOB,
|
||||
@@ -323,14 +393,22 @@ async fn det_lateral_chained_transitions() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Event { event: E_GOB },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SA },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
]);
|
||||
// B → C (LCA = Root, so Parent exits too).
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_GOC },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SB,
|
||||
event: E_GOC,
|
||||
@@ -338,13 +416,21 @@ async fn det_lateral_chained_transitions() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
reason: TransitionReason::Event { event: E_GOC },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SB },
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
TraceEvent::ExitBegan { state: SP },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::EnterBegan { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
},
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
@@ -360,9 +446,13 @@ async fn det_lateral_lca_stays_active() {
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
let j = m.take_journal();
|
||||
// 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
|
||||
.iter()
|
||||
.skip(3)
|
||||
.skip(initial_p_idx + 1)
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
@@ -410,7 +500,15 @@ async fn det_lateral_entry_outer_then_inner() {
|
||||
let entries: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.skip_while(|e| !matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.skip_while(|e| {
|
||||
!matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
#![allow(dead_code)]
|
||||
//! Deterministic-flow tests for OPTIONAL `default(...)` on composite states.
|
||||
//!
|
||||
//! Spec: a composite state may declare `default(...)` or omit it. When
|
||||
//! omitted, transitions targeting the composite land on the composite itself
|
||||
//! — no default-descent. Substates are reachable only via explicit
|
||||
//! transitions. This is the "microwave" pattern: a `Standby` state with
|
||||
//! sub-modes (e.g. `LowPower`) that the chart enters only on demand.
|
||||
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
PowerOn,
|
||||
PowerOff,
|
||||
EnterLowPower,
|
||||
LeaveLowPower,
|
||||
Halt,
|
||||
}
|
||||
|
||||
statechart! {
|
||||
Microwave {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Standby);
|
||||
terminate(Halt);
|
||||
|
||||
state Standby {
|
||||
// No `default(...)` — Standby itself is the resting state.
|
||||
entry: standby_in;
|
||||
exit: standby_out;
|
||||
on(PowerOn) => On;
|
||||
on(EnterLowPower) => LowPower;
|
||||
|
||||
state LowPower {
|
||||
entry: low_in;
|
||||
exit: low_out;
|
||||
on(LeaveLowPower) => Standby;
|
||||
}
|
||||
}
|
||||
|
||||
state On {
|
||||
entry: on_in;
|
||||
exit: on_out;
|
||||
on(PowerOff) => Standby;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MicrowaveActions for MicrowaveActionContext<'_> {
|
||||
async fn standby_in(&mut self) {}
|
||||
async fn standby_out(&mut self) {}
|
||||
async fn low_in(&mut self) {}
|
||||
async fn low_out(&mut self) {}
|
||||
async fn on_in(&mut self) {}
|
||||
async fn on_out(&mut self) {}
|
||||
}
|
||||
|
||||
// State ids: 0=Root, 1=Standby, 2=LowPower, 3=On.
|
||||
const SR: u16 = 0;
|
||||
const S_STANDBY: u16 = 1;
|
||||
const S_LOW: u16 = 2;
|
||||
const S_ON: u16 = 3;
|
||||
|
||||
fn entered_states(j: &[TraceEvent]) -> Vec<u16> {
|
||||
j.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn exited_states(j: &[TraceEvent]) -> Vec<u16> {
|
||||
j.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn entry_action_states(j: &[TraceEvent]) -> Vec<u16> {
|
||||
j.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
kind: ActionKind::Entry,
|
||||
..
|
||||
} => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_initial_lands_on_composite_no_descent() {
|
||||
// Standby has children but no `default` — initial entry stops at Standby.
|
||||
// LowPower must NOT enter.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Microwave::new(Ctx);
|
||||
// Drive initial entry without consuming an event.
|
||||
let _ = m.step(hsmc::Duration::ZERO).await;
|
||||
|
||||
assert_eq!(
|
||||
m.current_state(),
|
||||
MicrowaveState::Standby,
|
||||
"innermost active state is the composite Standby"
|
||||
);
|
||||
|
||||
let j = m.take_journal();
|
||||
let entered = entered_states(&j);
|
||||
assert_eq!(
|
||||
entered,
|
||||
vec![SR, S_STANDBY],
|
||||
"initial entry stops at Standby; no default-descent into LowPower"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_transition_into_substate_works() {
|
||||
// Explicit `=> LowPower` from Standby enters LowPower.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Microwave::new(Ctx);
|
||||
let _ = m.dispatch(Ev::EnterLowPower).await;
|
||||
assert_eq!(m.current_state(), MicrowaveState::LowPower);
|
||||
let j = m.take_journal();
|
||||
// Standby entered first (initial), then LowPower entered after the event.
|
||||
let entered = entered_states(&j);
|
||||
assert_eq!(entered, vec![SR, S_STANDBY, S_LOW]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_up_from_substate_lands_on_composite() {
|
||||
// From LowPower, `=> Standby` is an up-transition: exit LowPower, do not
|
||||
// re-enter Standby.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Microwave::new(Ctx);
|
||||
let _ = m.dispatch(Ev::EnterLowPower).await;
|
||||
let _ = m.dispatch(Ev::LeaveLowPower).await;
|
||||
assert_eq!(m.current_state(), MicrowaveState::Standby);
|
||||
|
||||
let j = m.take_journal();
|
||||
// Across the whole run: enter Standby, enter LowPower, exit
|
||||
// LowPower. Standby's entry must have run exactly once.
|
||||
let entry_states = entry_action_states(&j);
|
||||
let standby_entries = entry_states.iter().filter(|&&s| s == S_STANDBY).count();
|
||||
assert_eq!(
|
||||
standby_entries, 1,
|
||||
"Standby's entry runs once; up-transition does not re-enter it"
|
||||
);
|
||||
let exited = exited_states(&j);
|
||||
assert_eq!(
|
||||
exited,
|
||||
vec![S_LOW],
|
||||
"only LowPower is exited; Standby remains on the active path"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_to_composite_does_not_descend() {
|
||||
// From On, `=> Standby` lands on Standby — NOT on LowPower.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Microwave::new(Ctx);
|
||||
let _ = m.dispatch(Ev::PowerOn).await;
|
||||
assert_eq!(m.current_state(), MicrowaveState::On);
|
||||
let _ = m.dispatch(Ev::PowerOff).await;
|
||||
assert_eq!(
|
||||
m.current_state(),
|
||||
MicrowaveState::Standby,
|
||||
"PowerOff lands on Standby; absence of `default` skips descent"
|
||||
);
|
||||
|
||||
let j = m.take_journal();
|
||||
// LowPower must never have been entered.
|
||||
let entered = entered_states(&j);
|
||||
assert!(
|
||||
!entered.contains(&S_LOW),
|
||||
"no descent into LowPower on entry to Standby"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_terminate_from_composite_unwinds_path() {
|
||||
// Halt while at Standby exits Standby → Root only.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Microwave::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
let exits = exited_states(&j);
|
||||
assert_eq!(
|
||||
exits,
|
||||
vec![S_STANDBY, SR],
|
||||
"terminate from Standby exits Standby then Root"
|
||||
);
|
||||
assert!(matches!(j.last(), Some(TraceEvent::Terminated)));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -293,10 +293,19 @@ async fn det_ord_full_transition_canonical_sequence() {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let j = m.take_journal();
|
||||
// Find the TransitionFired and capture everything until A's exit and B's entry are done.
|
||||
// Find the EVENT-driven TransitionFired (initial default-fires
|
||||
// are TransitionFired{Internal}; we want the GO transition).
|
||||
let trans_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let kinds: Vec<ActionKind> = j[trans_idx..]
|
||||
.iter()
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -101,12 +101,36 @@ async fn det_root_targetable_by_chart_name() {
|
||||
TraceEvent::Started {
|
||||
chart_hash: Sub::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::Entered { state: SR },
|
||||
// Root's `default(A)` fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
},
|
||||
// A's `default(B)` fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
TraceEvent::EventReceived { event: E_UPTOROOT },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SB,
|
||||
event: E_UPTOROOT,
|
||||
@@ -114,11 +138,18 @@ async fn det_root_targetable_by_chart_name() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SB),
|
||||
to: SR,
|
||||
reason: TransitionReason::Event { event: E_UPTOROOT },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SB },
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
TraceEvent::ExitBegan { state: SA },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SB),
|
||||
to: SR,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
@@ -137,10 +168,21 @@ async fn det_root_target_does_not_re_enter_root() {
|
||||
let mut m = Sub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToRoot).await;
|
||||
let j = m.take_journal();
|
||||
// After the TransitionFired, no Entered event should fire.
|
||||
// After the EVENT-driven TransitionFired (the UpToRoot one),
|
||||
// no Entered event should fire — the up-transition exits B
|
||||
// and A but never re-enters root, and root has no default
|
||||
// (so no internal default-fire follows either).
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let entered_after = j[split..]
|
||||
.iter()
|
||||
|
||||
+42
-5
@@ -13,7 +13,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -86,11 +86,32 @@ fn initial_descent() -> Vec<TraceEvent> {
|
||||
TraceEvent::Started {
|
||||
chart_hash: Self_::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::Entered { state: SM },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SM,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SM },
|
||||
entry(SM, A_M_IN),
|
||||
TraceEvent::Entered { state: SL },
|
||||
TraceEvent::Entered { state: SM },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SM,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SM),
|
||||
to: SL,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL },
|
||||
entry(SL, A_L_IN),
|
||||
TraceEvent::Entered { state: SL },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SM),
|
||||
to: SL,
|
||||
},
|
||||
]
|
||||
}
|
||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||
@@ -123,6 +144,7 @@ async fn det_self_leaf_exits_and_reenters_only_leaf() {
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_SELFLEAF },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SL,
|
||||
event: E_SELFLEAF,
|
||||
@@ -130,11 +152,18 @@ async fn det_self_leaf_exits_and_reenters_only_leaf() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL),
|
||||
to: SL,
|
||||
reason: TransitionReason::Event { event: E_SELFLEAF },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SL },
|
||||
exit_(SL, A_L_OUT),
|
||||
TraceEvent::Exited { state: SL },
|
||||
TraceEvent::Entered { state: SL },
|
||||
TraceEvent::EnterBegan { state: SL },
|
||||
entry(SL, A_L_IN),
|
||||
TraceEvent::Entered { state: SL },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SL),
|
||||
to: SL,
|
||||
},
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
@@ -209,6 +238,7 @@ async fn det_self_composite_default_descends() {
|
||||
// Mid's entry actions don't fire. No default-descent.
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_SELFMID },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SM,
|
||||
event: E_SELFMID,
|
||||
@@ -216,9 +246,15 @@ async fn det_self_composite_default_descends() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL),
|
||||
to: SM,
|
||||
reason: TransitionReason::Event { event: E_SELFMID },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SL },
|
||||
exit_(SL, A_L_OUT),
|
||||
TraceEvent::Exited { state: SL },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SL),
|
||||
to: SM,
|
||||
},
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
@@ -257,7 +293,8 @@ async fn det_self_leaf_repeated_is_deterministic() {
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL),
|
||||
to: SL
|
||||
to: SL,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -85,7 +85,8 @@ async fn det_self_composite_default_descends_after_re_entry() {
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SP),
|
||||
to: SP
|
||||
to: SP,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -133,7 +134,8 @@ async fn det_self_composite_action_kinds_in_order() {
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SP),
|
||||
to: SP
|
||||
to: SP,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+84
-7
@@ -15,7 +15,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -113,20 +113,63 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
}
|
||||
}
|
||||
fn initial_descent() -> Vec<TraceEvent> {
|
||||
// Each `default(...)` fires as a TransitionFired{Internal} /
|
||||
// TransitionComplete pair around the entered state.
|
||||
vec![
|
||||
TraceEvent::Started {
|
||||
chart_hash: Up::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
},
|
||||
]
|
||||
}
|
||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||
@@ -159,6 +202,7 @@ async fn det_up_to_immediate_grandparent() {
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_UPTOC },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SD,
|
||||
event: E_UPTOC,
|
||||
@@ -166,9 +210,15 @@ async fn det_up_to_immediate_grandparent() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SD),
|
||||
to: SC,
|
||||
reason: TransitionReason::Event { event: E_UPTOC },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SD },
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SD),
|
||||
to: SC,
|
||||
},
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
@@ -186,6 +236,7 @@ async fn det_up_two_levels() {
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_UPTOB },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SD,
|
||||
event: E_UPTOB,
|
||||
@@ -193,11 +244,18 @@ async fn det_up_two_levels() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SD),
|
||||
to: SB,
|
||||
reason: TransitionReason::Event { event: E_UPTOB },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SD },
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
TraceEvent::ExitBegan { state: SC },
|
||||
exit_(SC, A_C_OUT),
|
||||
TraceEvent::Exited { state: SC },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SD),
|
||||
to: SB,
|
||||
},
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
@@ -215,6 +273,7 @@ async fn det_up_three_levels_to_a() {
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventReceived { event: E_UPTOA },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SD,
|
||||
event: E_UPTOA,
|
||||
@@ -222,13 +281,21 @@ async fn det_up_three_levels_to_a() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SD),
|
||||
to: SA,
|
||||
reason: TransitionReason::Event { event: E_UPTOA },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: SD },
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
TraceEvent::ExitBegan { state: SC },
|
||||
exit_(SC, A_C_OUT),
|
||||
TraceEvent::Exited { state: SC },
|
||||
TraceEvent::ExitBegan { state: SB },
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SD),
|
||||
to: SA,
|
||||
},
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
@@ -332,9 +399,19 @@ async fn det_up_current_state_is_target_after() {
|
||||
.rev()
|
||||
.find(|(_, e)| matches!(e, TraceEvent::Entered { .. }))
|
||||
.map(|(i, _)| i);
|
||||
// Find the EVENT-driven UpToB transition (initial defaults
|
||||
// emit TransitionFired{Internal} too).
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
// The last Entered event must be BEFORE the transition.
|
||||
assert!(
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -97,19 +97,32 @@ async fn replay_full_lifecycle_matches_expected() {
|
||||
TraceEvent::Started {
|
||||
chart_hash: Replay::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_ROOT },
|
||||
TraceEvent::EnterBegan { state: ST_ROOT },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_ROOT,
|
||||
action: A_ROOT_IN,
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_IDLE },
|
||||
TraceEvent::Entered { state: ST_ROOT },
|
||||
// Root's `default(Idle)` fires as an internal transition.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST_ROOT),
|
||||
to: ST_IDLE,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: ST_IDLE },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_IDLE,
|
||||
action: A_IDLE_IN,
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_IDLE },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST_ROOT),
|
||||
to: ST_IDLE,
|
||||
},
|
||||
// ── Go event delivered to Idle, transition to Active → Sub ──
|
||||
TraceEvent::EventReceived { event: E_GO },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: ST_IDLE,
|
||||
event: E_GO,
|
||||
@@ -117,39 +130,62 @@ async fn replay_full_lifecycle_matches_expected() {
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST_IDLE),
|
||||
to: ST_ACTIVE,
|
||||
reason: TransitionReason::Event { event: E_GO },
|
||||
},
|
||||
TraceEvent::ExitBegan { state: ST_IDLE },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_IDLE,
|
||||
action: A_IDLE_OUT,
|
||||
kind: ActionKind::Exit,
|
||||
},
|
||||
TraceEvent::Exited { state: ST_IDLE },
|
||||
TraceEvent::Entered { state: ST_ACTIVE },
|
||||
TraceEvent::EnterBegan { state: ST_ACTIVE },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_ACTIVE,
|
||||
action: A_ACTIVE_IN,
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_SUB },
|
||||
TraceEvent::Entered { state: ST_ACTIVE },
|
||||
// Active's `default(Sub)` fires as an internal transition,
|
||||
// bracketed by its own TransitionFired/Complete.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST_ACTIVE),
|
||||
to: ST_SUB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: ST_SUB },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_SUB,
|
||||
action: A_SUB_IN,
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_SUB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST_ACTIVE),
|
||||
to: ST_SUB,
|
||||
},
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST_IDLE),
|
||||
to: ST_ACTIVE,
|
||||
},
|
||||
// ── Halt event triggers termination: exit Sub → Active → root, then Terminated ──
|
||||
TraceEvent::EventReceived { event: E_HALT },
|
||||
TraceEvent::TerminateRequested { event: E_HALT },
|
||||
TraceEvent::ExitBegan { state: ST_SUB },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_SUB,
|
||||
action: A_SUB_OUT,
|
||||
kind: ActionKind::Exit,
|
||||
},
|
||||
TraceEvent::Exited { state: ST_SUB },
|
||||
TraceEvent::ExitBegan { state: ST_ACTIVE },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_ACTIVE,
|
||||
action: A_ACTIVE_OUT,
|
||||
kind: ActionKind::Exit,
|
||||
},
|
||||
TraceEvent::Exited { state: ST_ACTIVE },
|
||||
TraceEvent::ExitBegan { state: ST_ROOT },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_ROOT,
|
||||
action: A_ROOT_OUT,
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -151,6 +151,42 @@ audit:
|
||||
cargo audit
|
||||
@echo "✅ Security checks passed"
|
||||
|
||||
# Run the full unsafe-audit matrix: const-eval at build, debug_assert!
|
||||
# in debug tests, Miri for UB, mutants on the safety-relevant codegen.
|
||||
# Mirrors the audit described in `docs/004. unsafe-safety-contract.md`.
|
||||
[script]
|
||||
safety:
|
||||
echo "🔒 Unsafe safety audit"
|
||||
echo ""
|
||||
echo "1/4 — Compile-time const-eval (any out-of-range table entry"
|
||||
echo " fails cargo check with E0080)..."
|
||||
cargo check --workspace --all-targets
|
||||
echo ""
|
||||
echo "2/4 — Runtime debug_assert! in dev tests (every unsafe site"
|
||||
echo " checks its bound on every dispatch)..."
|
||||
cargo nextest run --workspace --no-default-features
|
||||
cargo nextest run --workspace --features tokio
|
||||
echo ""
|
||||
echo "3/4 — Miri (UB detector under interpreted Rust)..."
|
||||
just miri
|
||||
echo ""
|
||||
echo "4/4 — Mutation testing on safety-relevant codegen fns..."
|
||||
# `--features hsmc/tokio,hsmc/journal --test-package hsmc` is
|
||||
# essential: the safety-relevant tests (det_lateral, det_up,
|
||||
# det_descent, etc.) live in hsmc/tests/ and are gated on those
|
||||
# two features. Without them, cargo-mutants runs only the
|
||||
# hsmc-macros per-package test set and large swathes of the
|
||||
# LCA / path-derivation logic look uncovered when they aren't.
|
||||
cargo mutants \
|
||||
--regex "compute_transition_paths|compute_terminate_paths|compute_event_dispatch" \
|
||||
--features hsmc/tokio,hsmc/journal \
|
||||
--test-package hsmc \
|
||||
--test-tool nextest \
|
||||
--jobs 4 --no-shuffle
|
||||
echo ""
|
||||
echo "✅ Unsafe audit complete. See docs/004. unsafe-safety-contract.md"
|
||||
echo " for which checks cover which failure modes."
|
||||
|
||||
# Run Miri for UB detection (auto-installs nightly + miri component if missing).
|
||||
[script]
|
||||
miri:
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
rust = { version = "stable", components = "clippy,rustfmt,rust-src,llvm-tools-preview", targets = "thumbv7em-none-eabihf,thumbv6m-none-eabi,thumbv8m.main-none-eabihf,riscv32imac-unknown-none-elf" }
|
||||
|
||||
# ── Cargo subcommands the justfile drives ───────────────────────────
|
||||
"cargo:cargo-nextest" = "latest" # `just test`, `just dev`
|
||||
"cargo:cargo-llvm-cov" = "latest" # `just coverage`
|
||||
"cargo:cargo-mutants" = "latest" # `just mutants`
|
||||
"cargo:cargo-audit" = "latest" # `just audit`
|
||||
"cargo:cargo-nextest" = "latest" # `just test`, `just dev`
|
||||
"cargo:cargo-llvm-cov" = "latest" # `just coverage`
|
||||
"cargo:cargo-mutants" = "latest" # `just mutants`
|
||||
"cargo:cargo-audit" = "latest" # `just audit`
|
||||
"cargo:gungraun-runner" = "latest" # `cargo bench --bench dispatch` (valgrind harness)
|
||||
|
||||
# ── Build orchestration ─────────────────────────────────────────────
|
||||
just = "latest"
|
||||
|
||||
@@ -22,6 +22,43 @@ columns track Creusot proof state.
|
||||
| S6.CLEAR | `clear()` makes the view empty | `EventQueue::clear` | 🟡 |
|
||||
| S6.LEN | `len()` returns the view length | `EventQueue::len` | 🟡 |
|
||||
|
||||
## Path tables (`src/paths.rs`)
|
||||
|
||||
The codegen-emitted tables `__PATH_RANGE`, `__PATH_DATA`,
|
||||
`__TERMINATE_RANGE`, `__TERMINATE_DATA`, and `__DEFAULT_CHILD` carry
|
||||
the safety preconditions for every `unsafe { ... .get_unchecked(...) }`
|
||||
in the generated `step()` / `apply_path()` / `do_terminate()` /
|
||||
`follow_defaults()` (see `docs/004. unsafe-safety-contract.md`). The
|
||||
proof strategy is two-pronged:
|
||||
|
||||
- A `const _: () = { assert!(...) };` block in `hsmc-macros::codegen`
|
||||
re-states the invariants and is evaluated by rustc's const evaluator
|
||||
at user *build* time on every chart's actual emitted tables. Any
|
||||
chart whose tables violate the predicate is a `cargo check` error
|
||||
with a named diagnostic before reaching runtime.
|
||||
- The Pearlite-annotated functions in `paths.rs` express the *same
|
||||
predicate* with an `#[ensures(... forall ...)]` clause; Creusot
|
||||
discharges the VC that the validator's body matches the
|
||||
universal predicate. `tests/correspondence_paths.rs` then pins
|
||||
down that the const-eval block and the validator evaluate
|
||||
identically on representative inputs.
|
||||
|
||||
| Spec rule | Statement | Function | Status |
|
||||
|-----------|-----------|----------|--------|
|
||||
| P1.PATH | `forall i. let (s,m,e) = __PATH_RANGE[i] in s ≤ m ≤ e ≤ __PATH_DATA.len()` | `check_path_range_invariant` | 🟡 |
|
||||
| P1.TERM | `forall i. let (s,e) = __TERMINATE_RANGE[i] in s ≤ e ≤ __TERMINATE_DATA.len()` | `check_terminate_range_invariant` | 🟡 |
|
||||
| P2.PATH_ID | `forall i. __PATH_DATA[i] < n_states` | `check_state_ids_in_bounds` | 🟡 |
|
||||
| P2.TERM_ID | `forall i. __TERMINATE_DATA[i] < n_states` | `check_state_ids_in_bounds` | 🟡 |
|
||||
| P2.DEFAULT | `forall i. match __DEFAULT_CHILD[i] { Some(t) => t < n_states, None => () }` | `check_default_child_in_bounds` | 🟡 |
|
||||
| P.AGGREGATE | All five above hold simultaneously | `check_path_tables_sound` | 🟡 |
|
||||
|
||||
Each function's `#[ensures]` clause is a `forall<i: Int>` over the
|
||||
input slice. Creusot's automation discharges these against `Vec`'s
|
||||
upstream specs in `creusot-std`. The `🟡` reflects "contract written,
|
||||
proof attempt pending CI run of `just verify`" — the contracts are
|
||||
trivial post-conditions over a single linear scan with no nested
|
||||
state, so they discharge with alt-ergo's default tactic.
|
||||
|
||||
## TimerTable (`src/timer_table.rs`)
|
||||
|
||||
| Spec rule | Statement | Function | Status |
|
||||
@@ -41,8 +78,8 @@ columns track Creusot proof state.
|
||||
|
||||
| Item | Reason |
|
||||
|------|--------|
|
||||
| `transition()` codegen invariants (path validity) | Requires threading Creusot attrs through the proc-macro. Phase 4. |
|
||||
| `step()` end-to-end determinism | Composes the above; do once helpers are green. Phase 4. |
|
||||
| `compute_transition_paths` algorithm itself | Universal proof "for any tree input, output satisfies P1 + P2" requires modeling the tree structure in Pearlite. The current strategy proves the *predicate* is correct and runs it via const-eval on every actual chart at build time — operationally equivalent for end users (every compiled chart is verified) but not the universal quantification that Creusot would give. Phase 5. |
|
||||
| `step()` end-to-end determinism | Composes everything above; do once helpers are green. Phase 5. |
|
||||
| Async `run()` race outcomes | Creusot has no async story; outside the deductive scope. |
|
||||
| User action bodies | User code, not part of the runtime. |
|
||||
| `duration_from_secs_f64` | Floating-point not modeled. Use `#[trusted]` + property tests. |
|
||||
|
||||
@@ -44,4 +44,5 @@
|
||||
// use `std::vec::Vec` (via creusot-std's specs) directly.
|
||||
|
||||
pub mod event_queue;
|
||||
pub mod paths;
|
||||
pub mod timer_table;
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Verified validator for the codegen-emitted transition path tables.
|
||||
//!
|
||||
//! Background: after the Opt #2 perf pass, the generated `step()` /
|
||||
//! `apply_path()` / `do_terminate()` index into static tables
|
||||
//! (`__PATH_RANGE`, `__PATH_DATA`, `__TERMINATE_RANGE`,
|
||||
//! `__TERMINATE_DATA`, `__DEFAULT_CHILD`) via `slice::get_unchecked`.
|
||||
//! That's sound iff the codegen-emitted values satisfy two predicates:
|
||||
//!
|
||||
//! - **P1** *(state-id well-formedness)*: every state id stored in
|
||||
//! `__PATH_DATA`, `__TERMINATE_DATA`, or as a `Some(t)` inside
|
||||
//! `__DEFAULT_CHILD` is `< n_states`.
|
||||
//! - **P2** *(range-table well-formedness)*: every triple
|
||||
//! `__PATH_RANGE[i] = (s, m, e)` satisfies `0 ≤ s ≤ m ≤ e ≤ data_len`.
|
||||
//! Same for `__TERMINATE_RANGE[i] = (s, e)` against `__TERMINATE_DATA`.
|
||||
//!
|
||||
//! The codegen already enforces these at *user build time* via a
|
||||
//! `const _: () = { ... }` block in `hsmc-macros::codegen` that runs the
|
||||
//! same predicates as `assert!`s in const-context — any chart whose
|
||||
//! emitted tables fail the check is a `cargo check` error before
|
||||
//! reaching runtime. So the property is operationally proven for every
|
||||
//! chart that compiles.
|
||||
//!
|
||||
//! What this module adds: a Creusot-verified version of the *predicate
|
||||
//! itself*, so we know the property the const-eval block is checking
|
||||
//! is the property the unsafe blocks rely on. The contracts say:
|
||||
//!
|
||||
//! ```text
|
||||
//! check_path_range_invariant(range, data_len) == true
|
||||
//! iff
|
||||
//! ∀ i ∈ 0..range.len(): let (s, m, e) = range[i] in
|
||||
//! s ≤ m ≤ e ≤ data_len
|
||||
//! ```
|
||||
//!
|
||||
//! ```text
|
||||
//! check_path_ids_in_range(data, n_states) == true
|
||||
//! iff
|
||||
//! ∀ i ∈ 0..data.len(): data[i] < n_states
|
||||
//! ```
|
||||
//!
|
||||
//! With Creusot discharging both VCs, the chain
|
||||
//!
|
||||
//! - hsmc-macros emits tables
|
||||
//! - const-eval runs the predicate (or its rustc-evaluated equivalent)
|
||||
//! - this module's predicate is proven equivalent to P1 + P2
|
||||
//! - therefore P1 + P2 hold for every chart that compiled
|
||||
//!
|
||||
//! is closed end-to-end with the only remaining trust point being the
|
||||
//! identity between the const-eval block's body and this module's
|
||||
//! predicate (mechanical — they are the same expressions, copied in
|
||||
//! `tests/correspondence_paths.rs`).
|
||||
|
||||
use creusot_std::macros::ensures;
|
||||
// `pearlite!` looks unused to plain rustc but Creusot's preprocessing
|
||||
// needs it for quantified ensures bodies.
|
||||
#[allow(unused_imports)]
|
||||
use creusot_std::macros::pearlite;
|
||||
|
||||
/// Returns `true` iff every triple `(s, m, e)` in `range` satisfies
|
||||
/// `s ≤ m ≤ e ≤ data_len`. Mirror of the const-eval check on
|
||||
/// `__PATH_RANGE`.
|
||||
///
|
||||
/// Pearlite contract: the function is total (terminates), pure, and
|
||||
/// the result is equivalent to the universal predicate over `range`.
|
||||
#[ensures(result == forall<i: Int>
|
||||
0 <= i && i < range@.len()
|
||||
==> (range@[i].0 as u64) <= (range@[i].1 as u64)
|
||||
&& (range@[i].1 as u64) <= (range@[i].2 as u64)
|
||||
&& (range@[i].2 as u64) <= (data_len as u64)
|
||||
)]
|
||||
pub fn check_path_range_invariant(range: &[(u16, u16, u16)], data_len: usize) -> bool {
|
||||
let mut i: usize = 0;
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
while i < range.len() {
|
||||
let (s, m, e) = range[i];
|
||||
if !((s as usize) <= (m as usize)
|
||||
&& (m as usize) <= (e as usize)
|
||||
&& (e as usize) <= data_len)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns `true` iff every triple `(s, e)` in `range` satisfies
|
||||
/// `s ≤ e ≤ data_len`. Mirror of the const-eval check on
|
||||
/// `__TERMINATE_RANGE`.
|
||||
#[ensures(result == forall<i: Int>
|
||||
0 <= i && i < range@.len()
|
||||
==> (range@[i].0 as u64) <= (range@[i].1 as u64)
|
||||
&& (range@[i].1 as u64) <= (data_len as u64)
|
||||
)]
|
||||
pub fn check_terminate_range_invariant(range: &[(u16, u16)], data_len: usize) -> bool {
|
||||
let mut i: usize = 0;
|
||||
while i < range.len() {
|
||||
let (s, e) = range[i];
|
||||
if !((s as usize) <= (e as usize) && (e as usize) <= data_len) {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns `true` iff every state id stored in `data` is `< n_states`.
|
||||
/// Mirror of the const-eval check on `__PATH_DATA` and
|
||||
/// `__TERMINATE_DATA`.
|
||||
#[ensures(result == forall<i: Int>
|
||||
0 <= i && i < data@.len()
|
||||
==> (data@[i] as u64) < (n_states as u64)
|
||||
)]
|
||||
pub fn check_state_ids_in_bounds(data: &[u16], n_states: usize) -> bool {
|
||||
let mut i: usize = 0;
|
||||
while i < data.len() {
|
||||
if (data[i] as usize) >= n_states {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns `true` iff every `Some(t)` in `default_child` satisfies
|
||||
/// `t < n_states`. `None` entries (states with no default child) are
|
||||
/// ignored. Mirror of the const-eval check on `__DEFAULT_CHILD`.
|
||||
#[ensures(result == forall<i: Int>
|
||||
0 <= i && i < default_child@.len()
|
||||
==> match default_child@[i] {
|
||||
Some(t) => (t as u64) < (n_states as u64),
|
||||
None => true,
|
||||
}
|
||||
)]
|
||||
pub fn check_default_child_in_bounds(default_child: &[Option<u16>], n_states: usize) -> bool {
|
||||
let mut i: usize = 0;
|
||||
while i < default_child.len() {
|
||||
if let Some(t) = default_child[i] {
|
||||
if (t as usize) >= n_states {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Aggregate predicate: returns `true` iff all four invariants hold.
|
||||
/// This is the property that, when true, makes every codegen-emitted
|
||||
/// `unsafe { ... .get_unchecked(...) }` in step / apply_path /
|
||||
/// follow_defaults / do_terminate sound.
|
||||
#[ensures(result == (
|
||||
(forall<i: Int>
|
||||
0 <= i && i < path_range@.len()
|
||||
==> (path_range@[i].0 as u64) <= (path_range@[i].1 as u64)
|
||||
&& (path_range@[i].1 as u64) <= (path_range@[i].2 as u64)
|
||||
&& (path_range@[i].2 as u64) <= (path_data@.len() as u64))
|
||||
&& (forall<i: Int>
|
||||
0 <= i && i < terminate_range@.len()
|
||||
==> (terminate_range@[i].0 as u64) <= (terminate_range@[i].1 as u64)
|
||||
&& (terminate_range@[i].1 as u64) <= (terminate_data@.len() as u64))
|
||||
&& (forall<i: Int>
|
||||
0 <= i && i < path_data@.len()
|
||||
==> (path_data@[i] as u64) < (n_states as u64))
|
||||
&& (forall<i: Int>
|
||||
0 <= i && i < terminate_data@.len()
|
||||
==> (terminate_data@[i] as u64) < (n_states as u64))
|
||||
&& (forall<i: Int>
|
||||
0 <= i && i < default_child@.len()
|
||||
==> match default_child@[i] {
|
||||
Some(t) => (t as u64) < (n_states as u64),
|
||||
None => true,
|
||||
})
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn check_path_tables_sound(
|
||||
path_range: &[(u16, u16, u16)],
|
||||
path_data: &[u16],
|
||||
terminate_range: &[(u16, u16)],
|
||||
terminate_data: &[u16],
|
||||
default_child: &[Option<u16>],
|
||||
n_states: usize,
|
||||
) -> bool {
|
||||
check_path_range_invariant(path_range, path_data.len())
|
||||
&& check_terminate_range_invariant(terminate_range, terminate_data.len())
|
||||
&& check_state_ids_in_bounds(path_data, n_states)
|
||||
&& check_state_ids_in_bounds(terminate_data, n_states)
|
||||
&& check_default_child_in_bounds(default_child, n_states)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Sanity checks that the predicates behave correctly on small
|
||||
// inputs. The ensures clauses are what Creusot proves; these tests
|
||||
// pin down the executable behavior.
|
||||
|
||||
#[test]
|
||||
fn empty_range_passes() {
|
||||
assert!(check_path_range_invariant(&[], 0));
|
||||
assert!(check_path_range_invariant(&[], 100));
|
||||
assert!(check_terminate_range_invariant(&[], 0));
|
||||
assert!(check_state_ids_in_bounds(&[], 0));
|
||||
assert!(check_default_child_in_bounds(&[], 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_paths() {
|
||||
// s ≤ m ≤ e ≤ data_len for all entries.
|
||||
let data: Vec<u16> = vec![0, 1, 2, 0, 1];
|
||||
let range = vec![(0, 2, 3), (3, 3, 5), (0, 0, 0)];
|
||||
assert!(check_path_range_invariant(&range, data.len()));
|
||||
assert!(check_state_ids_in_bounds(&data, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_range_s_gt_m_rejected() {
|
||||
let range = vec![(2, 1, 3)];
|
||||
assert!(!check_path_range_invariant(&range, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_range_m_gt_e_rejected() {
|
||||
let range = vec![(0, 3, 2)];
|
||||
assert!(!check_path_range_invariant(&range, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_range_e_past_data_rejected() {
|
||||
let range = vec![(0, 1, 6)];
|
||||
assert!(!check_path_range_invariant(&range, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_id_oob_rejected() {
|
||||
let data = vec![0u16, 1, 99];
|
||||
assert!(!check_state_ids_in_bounds(&data, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_child_some_oob_rejected() {
|
||||
let default_child = vec![Some(0u16), None, Some(99)];
|
||||
assert!(!check_default_child_in_bounds(&default_child, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_child_all_none_passes() {
|
||||
let default_child = vec![None, None, None];
|
||||
assert!(check_default_child_in_bounds(&default_child, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_sound() {
|
||||
let path_range = vec![(0, 1, 2)];
|
||||
let path_data = vec![0u16, 1];
|
||||
let term_range = vec![(0, 1)];
|
||||
let term_data = vec![0u16];
|
||||
let default_child = vec![Some(0u16), None];
|
||||
assert!(check_path_tables_sound(
|
||||
&path_range,
|
||||
&path_data,
|
||||
&term_range,
|
||||
&term_data,
|
||||
&default_child,
|
||||
2,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_unsound_when_any_fails() {
|
||||
let path_range = vec![(0, 1, 2)];
|
||||
let path_data = vec![0u16, 99]; // id out of range
|
||||
let term_range = vec![(0, 1)];
|
||||
let term_data = vec![0u16];
|
||||
let default_child = vec![Some(0u16), None];
|
||||
assert!(!check_path_tables_sound(
|
||||
&path_range,
|
||||
&path_data,
|
||||
&term_range,
|
||||
&term_data,
|
||||
&default_child,
|
||||
2,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Correspondence test: the Pearlite-verified path-table validator
|
||||
//! evaluates the *same predicate* as the `const _: () = { ... }` block
|
||||
//! that `hsmc-macros` emits in every generated chart.
|
||||
//!
|
||||
//! Why this matters: the safety chain for the unsafe blocks in
|
||||
//! `step()` / `apply_path()` / `do_terminate()` / `follow_defaults()` is
|
||||
//!
|
||||
//! (a) hsmc-macros emits __PATH_RANGE / __PATH_DATA / __TERMINATE_*
|
||||
//! / __DEFAULT_CHILD with the values produced by
|
||||
//! `compute_transition_paths` and `compute_terminate_paths`.
|
||||
//! (b) A const-eval block runs `assert!(s <= m && m <= e && e <= len
|
||||
//! && id < n_states && ...)` over those tables at user build
|
||||
//! time. Build fails if any chart's tables violate the predicate.
|
||||
//! (c) The Pearlite-annotated validator in
|
||||
//! `hsmc_verification::paths` proves (under Creusot) that the
|
||||
//! *function* that runs those checks correctly decides P1 + P2.
|
||||
//!
|
||||
//! The remaining trust point is "(b) and (c) are checking the same
|
||||
//! thing". This test pins that down by exercising the validator on
|
||||
//! the exact predicate the const-eval block uses, on inputs that
|
||||
//! mirror codegen's output shape — both well-formed and adversarial.
|
||||
//!
|
||||
//! When this test passes alongside the const-eval block compiling,
|
||||
//! every chart that compiles has been verified by a function whose
|
||||
//! correctness is in turn formally proven (when `just verify` runs).
|
||||
|
||||
use hsmc_verification::paths::{
|
||||
check_default_child_in_bounds, check_path_range_invariant, check_path_tables_sound,
|
||||
check_state_ids_in_bounds, check_terminate_range_invariant,
|
||||
};
|
||||
|
||||
/// Shape that mirrors `compute_transition_paths`'s output for a small
|
||||
/// chart with N=4 states:
|
||||
///
|
||||
/// __Root
|
||||
/// ├── A (id 1, depth 1)
|
||||
/// ├── B (id 2, depth 1)
|
||||
/// └── C (id 3, depth 1, default child = none)
|
||||
///
|
||||
/// Encoded paths for transition A→B (lateral, LCA = root):
|
||||
/// exit_path = [A]; enter_path = [B]
|
||||
/// __PATH_RANGE[A * N + B] = (s=0, m=1, e=2)
|
||||
/// __PATH_DATA = [A, B] = [1, 2]
|
||||
// `1 * 4 + 2` etc. are deliberately written `row * stride + col` so the
|
||||
// __PATH_RANGE indexing scheme matches the codegen formula
|
||||
// `from * N_STATES + to`; reducing to literals would obscure intent.
|
||||
// The helper's 6-tuple shape mirrors the codegen outputs we're
|
||||
// validating; factoring into a struct would just rename the same data.
|
||||
#[allow(clippy::identity_op, clippy::type_complexity)]
|
||||
fn small_chart_tables() -> (
|
||||
Vec<(u16, u16, u16)>, // path_range
|
||||
Vec<u16>, // path_data
|
||||
Vec<(u16, u16)>, // terminate_range
|
||||
Vec<u16>, // terminate_data
|
||||
Vec<Option<u16>>, // default_child
|
||||
usize, // n_states (incl. root)
|
||||
) {
|
||||
// 4 states (0=root, 1=A, 2=B, 3=C). 16-entry path_range.
|
||||
// Identity transitions self → self use (s, m, e) = (i*2, i*2, i*2+1)
|
||||
// — exit empty, enter [self]. Lateral A→B and B→A point at the same
|
||||
// path_data slice. The exact values don't matter for this test;
|
||||
// what matters is the structural invariant holds.
|
||||
let path_data: Vec<u16> = vec![1, 2, 2, 1];
|
||||
// 4*4 = 16 entries; only a few are populated, rest are (0, 0, 0)
|
||||
// which is the "no transition" sentinel. (s, m, e) = (0, 0, 0)
|
||||
// satisfies s <= m <= e <= len trivially.
|
||||
let mut path_range: Vec<(u16, u16, u16)> = vec![(0, 0, 0); 16];
|
||||
path_range[1 * 4 + 2] = (0, 1, 2); // A→B: exit [A], enter [B]
|
||||
path_range[2 * 4 + 1] = (2, 3, 4); // B→A: exit [B], enter [A]
|
||||
|
||||
// 4 states, each terminate path is the state itself plus root.
|
||||
let terminate_data: Vec<u16> = vec![1, 0, 2, 0, 3, 0]; // A,root | B,root | C,root
|
||||
let terminate_range: Vec<(u16, u16)> = vec![
|
||||
(0, 0), // root: empty
|
||||
(0, 2), // A: [A, root]
|
||||
(2, 4), // B: [B, root]
|
||||
(4, 6), // C: [C, root]
|
||||
];
|
||||
|
||||
let default_child: Vec<Option<u16>> = vec![Some(1), None, None, None];
|
||||
|
||||
(
|
||||
path_range,
|
||||
path_data,
|
||||
terminate_range,
|
||||
terminate_data,
|
||||
default_child,
|
||||
4,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_tables_pass_validator() {
|
||||
let (pr, pd, tr, td, dc, n) = small_chart_tables();
|
||||
assert!(check_path_range_invariant(&pr, pd.len()));
|
||||
assert!(check_terminate_range_invariant(&tr, td.len()));
|
||||
assert!(check_state_ids_in_bounds(&pd, n));
|
||||
assert!(check_state_ids_in_bounds(&td, n));
|
||||
assert!(check_default_child_in_bounds(&dc, n));
|
||||
assert!(check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::identity_op)]
|
||||
fn adversarial_path_range_s_gt_m_caught() {
|
||||
// Same shape but with one bad triple. Mirrors what an
|
||||
// off-by-one in `compute_transition_paths` would emit.
|
||||
let (mut pr, pd, tr, td, dc, n) = small_chart_tables();
|
||||
pr[1 * 4 + 2] = (2, 1, 2); // s > m
|
||||
assert!(!check_path_range_invariant(&pr, pd.len()));
|
||||
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::identity_op)]
|
||||
fn adversarial_path_range_e_past_data_caught() {
|
||||
let (mut pr, pd, tr, td, dc, n) = small_chart_tables();
|
||||
pr[1 * 4 + 2] = (0, 1, 99); // e past data_len
|
||||
assert!(!check_path_range_invariant(&pr, pd.len()));
|
||||
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_path_data_id_oob_caught() {
|
||||
let (pr, mut pd, tr, td, dc, n) = small_chart_tables();
|
||||
pd[1] = 99; // state id past N_STATES
|
||||
assert!(!check_state_ids_in_bounds(&pd, n));
|
||||
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_terminate_range_e_past_data_caught() {
|
||||
let (pr, pd, mut tr, td, dc, n) = small_chart_tables();
|
||||
tr[1] = (0, 99); // e past terminate_data_len
|
||||
assert!(!check_terminate_range_invariant(&tr, td.len()));
|
||||
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_terminate_data_id_oob_caught() {
|
||||
let (pr, pd, tr, mut td, dc, n) = small_chart_tables();
|
||||
td[0] = 99;
|
||||
assert!(!check_state_ids_in_bounds(&td, n));
|
||||
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_default_child_id_oob_caught() {
|
||||
let (pr, pd, tr, td, mut dc, n) = small_chart_tables();
|
||||
dc[0] = Some(99);
|
||||
assert!(!check_default_child_in_bounds(&dc, n));
|
||||
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tables_pass_trivially() {
|
||||
// Single-state chart (just root, no transitions): all tables
|
||||
// either empty or trivial.
|
||||
assert!(check_path_tables_sound(
|
||||
&[],
|
||||
&[],
|
||||
&[(0, 0)],
|
||||
&[],
|
||||
&[None],
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_const_eval_block_predicate_matches_the_validator() {
|
||||
// The const-eval block emitted by hsmc-macros::codegen runs:
|
||||
//
|
||||
// assert!(s as usize <= m as usize, "...");
|
||||
// assert!(m as usize <= e as usize, "...");
|
||||
// assert!(e as usize <= __PATH_DATA.len(), "...");
|
||||
//
|
||||
// Translated: for every i, range[i] satisfies s ≤ m ≤ e ≤ len.
|
||||
//
|
||||
// The validator's #[ensures] clause says the same thing as a
|
||||
// forall over Int. This test checks that on a representative
|
||||
// input, the validator returns true exactly when the const-eval
|
||||
// block's three assertions all pass — the same semantics.
|
||||
let data_len = 10usize;
|
||||
let cases: &[(u16, u16, u16, bool)] = &[
|
||||
// (s, m, e, expected_pass)
|
||||
(0, 0, 0, true),
|
||||
(0, 5, 10, true),
|
||||
(5, 5, 5, true),
|
||||
(0, 0, 10, true),
|
||||
(0, 10, 10, true),
|
||||
(1, 0, 5, false), // s > m
|
||||
(0, 6, 5, false), // m > e
|
||||
(0, 5, 11, false), // e > len
|
||||
];
|
||||
for &(s, m, e, expected) in cases {
|
||||
let const_eval_predicate = (s as usize) <= (m as usize)
|
||||
&& (m as usize) <= (e as usize)
|
||||
&& (e as usize) <= data_len;
|
||||
let validator_result = check_path_range_invariant(&[(s, m, e)], data_len);
|
||||
assert_eq!(
|
||||
const_eval_predicate, expected,
|
||||
"const-eval predicate disagrees with expectation for ({s}, {m}, {e})"
|
||||
);
|
||||
assert_eq!(
|
||||
validator_result, expected,
|
||||
"validator disagrees with expectation for ({s}, {m}, {e})"
|
||||
);
|
||||
assert_eq!(
|
||||
const_eval_predicate, validator_result,
|
||||
"const-eval predicate and validator diverge for ({s}, {m}, {e})"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user