Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f933f98fa
|
||
|
|
d8bda344fd
|
||
|
|
2d38af60f6
|
||
|
|
ad2aa6e04d
|
||
|
|
98352b0e4d
|
||
|
|
b9645f80cb
|
||
|
|
da1f3046b4
|
||
|
|
2f37f261c5
|
||
|
|
8cd1aea646
|
||
|
|
d5ef6a6944
|
||
|
|
915b7ce101
|
||
|
|
1f98a9bf5b
|
||
|
|
9eb1d10b28
|
||
|
|
3fc0d7c57d
|
No files matched your search
+236
@@ -4,6 +4,242 @@ All notable changes to this workspace are documented here. The format
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||||
this project adheres to [Semantic Versioning](https://semver.org/).
|
this project adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## 0.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
|
## 0.4.1 — 2026-04-28
|
||||||
|
|
||||||
### Performance — dispatch hot path
|
### Performance — dispatch hot path
|
||||||
|
|||||||
Generated
+44
-632
@@ -2,12 +2,6 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "allocator-api2"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -29,53 +23,18 @@ version = "1.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitflags"
|
|
||||||
version = "2.11.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
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]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
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]]
|
[[package]]
|
||||||
name = "creusot-std"
|
name = "creusot-std"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -103,66 +62,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
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]]
|
[[package]]
|
||||||
name = "defmt"
|
name = "defmt"
|
||||||
version = "0.3.100"
|
version = "0.3.100"
|
||||||
@@ -178,7 +77,7 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
|
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags",
|
||||||
"defmt-macros",
|
"defmt-macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -234,12 +133,6 @@ dependencies = [
|
|||||||
"litrs",
|
"litrs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "either"
|
|
||||||
version = "1.15.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embassy-futures"
|
name = "embassy-futures"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -331,116 +224,61 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
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]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
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]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
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]]
|
[[package]]
|
||||||
name = "glob"
|
name = "glob"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
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]]
|
[[package]]
|
||||||
name = "hash32"
|
name = "hash32"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -450,17 +288,6 @@ dependencies = [
|
|||||||
"byteorder",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.0"
|
version = "0.17.0"
|
||||||
@@ -487,27 +314,18 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "heck"
|
|
||||||
version = "0.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hsmc"
|
name = "hsmc"
|
||||||
version = "0.4.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm",
|
|
||||||
"defmt 0.3.100",
|
"defmt 0.3.100",
|
||||||
"embassy-futures",
|
"embassy-futures",
|
||||||
"embassy-sync",
|
"embassy-sync",
|
||||||
"embassy-time",
|
"embassy-time",
|
||||||
"futures",
|
"gungraun",
|
||||||
"heapless 0.8.0",
|
"heapless 0.8.0",
|
||||||
"hsmc-macros",
|
"hsmc-macros",
|
||||||
"iai-callgrind",
|
|
||||||
"log",
|
"log",
|
||||||
"ratatui",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"trybuild",
|
"trybuild",
|
||||||
@@ -515,7 +333,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hsmc-macros"
|
name = "hsmc-macros"
|
||||||
version = "0.4.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hsmc",
|
"hsmc",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -526,54 +344,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hsmc-verification"
|
name = "hsmc-verification"
|
||||||
version = "0.4.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"creusot-std",
|
"creusot-std",
|
||||||
"hsmc",
|
"hsmc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iai-callgrind"
|
|
||||||
version = "0.16.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b1e4910d3a9137442723dfb772c32dc10674c4181ca078d2fd227cd5dce9db0"
|
|
||||||
dependencies = [
|
|
||||||
"bincode",
|
|
||||||
"derive_more",
|
|
||||||
"iai-callgrind-macros",
|
|
||||||
"iai-callgrind-runner",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iai-callgrind-macros"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4d03775318d3f9f01b39ac6612b01464006dc397a654a89dd57df2fd34fb68c3"
|
|
||||||
dependencies = [
|
|
||||||
"derive_more",
|
|
||||||
"proc-macro-error2",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iai-callgrind-runner"
|
|
||||||
version = "0.16.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b74c9743c00c3bca4aaffc69c87cae56837796cd362438daf354a3f785788c68"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ident_case"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -581,38 +357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.17.0",
|
"hashbrown",
|
||||||
]
|
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -621,66 +366,24 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
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]]
|
[[package]]
|
||||||
name = "litrs"
|
name = "litrs"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
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]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
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]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
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]]
|
[[package]]
|
||||||
name = "nb"
|
name = "nb"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
@@ -735,35 +438,6 @@ dependencies = [
|
|||||||
"autocfg",
|
"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]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -810,36 +484,6 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ratatui"
|
|
||||||
version = "0.28.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.1",
|
|
||||||
"cassowary",
|
|
||||||
"compact_str",
|
|
||||||
"crossterm",
|
|
||||||
"instability",
|
|
||||||
"itertools",
|
|
||||||
"lru",
|
|
||||||
"paste",
|
|
||||||
"strum",
|
|
||||||
"strum_macros",
|
|
||||||
"unicode-segmentation",
|
|
||||||
"unicode-truncate",
|
|
||||||
"unicode-width",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "redox_syscall"
|
|
||||||
version = "0.5.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -849,37 +493,6 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
@@ -938,89 +551,12 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -1164,87 +700,27 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
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]]
|
[[package]]
|
||||||
name = "void"
|
name = "void"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
|
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]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -1254,70 +730,6 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|||||||
+2
-2
@@ -3,9 +3,9 @@ resolver = "2"
|
|||||||
members = ["hsmc", "hsmc-macros", "verification"]
|
members = ["hsmc", "hsmc-macros", "verification"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.1"
|
version = "0.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT"
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
repository = "https://github.com/swaits/hsmc"
|
repository = "https://github.com/swaits/hsmc"
|
||||||
homepage = "https://github.com/swaits/hsmc"
|
homepage = "https://github.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 — Hierarchical State Machines for Rust
|
||||||
|
|
||||||
`hsmc` is a declarative, proc-macro-based statechart crate for Rust. It targets
|
`hsmc` lets you write the control logic of a device — a microwave, a
|
||||||
embedded Rust (embassy) as the primary runtime and supports `tokio` via
|
LoRa radio, a sensor, a UI flow — as a **statechart**: nested states
|
||||||
`LocalSet`. Machines are compiled from a `statechart!` block into
|
with entry/exit actions, transitions, timers, and async work, all
|
||||||
monomorphized code with no dynamic dispatch, no interior mutability, and no
|
declared in one block. The proc macro turns it into Rust code with no
|
||||||
heap allocations by default.
|
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
|
```rust
|
||||||
statechart! {
|
statechart! {
|
||||||
@@ -25,121 +113,139 @@ statechart! {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## What it does
|
Three things here that the Blinker didn't have:
|
||||||
|
|
||||||
- **Harel-style hierarchical states.** Nested states with `entry:`/`exit:`
|
- **Events.** `events: Ev;` declares the input enum. External code
|
||||||
actions, LCA-aware transitions, default-child descent, `terminate`.
|
drives the chart with `m.sender().send(Ev::StartRx)`; in-chart
|
||||||
- **Event-driven transitions and actions.** Typed payload bindings:
|
actions can `emit(Ev::Whatever)` to queue a follow-up. Handlers
|
||||||
`on(PacketRx { rssi: i16 }) => handler;`.
|
pattern-match the variant and bind its payload directly:
|
||||||
- **Timer triggers.** `on(after Duration::from_secs(5)) => Next;` and
|
`on(PacketRx { rssi, snr }) => record_packet;` makes `rssi` and
|
||||||
`on(every Duration::from_millis(100)) => tick;`.
|
`snr` available to the action.
|
||||||
- **`during:` activities.** Async functions that run while a state is
|
- **`during:` activities.** `next_packet(lora, rx_buf)` is an async
|
||||||
active and produce events. Multiple durings can borrow disjoint fields
|
fn that runs while `Receiving` is active and is dropped when the
|
||||||
of the context concurrently via Rust's native split borrow — no
|
state exits. It gets `&mut` borrows of just the `lora` and
|
||||||
`RefCell`, no `Mutex`, no `UnsafeCell` in user code.
|
`rx_buf` fields of the context — Rust's split-borrow rules let
|
||||||
- **`m.run().await` is the whole task body.** Races active durings, the
|
several `during:`s on the active path run concurrently without a
|
||||||
external event channel, and timer deadlines. The MCU sleeps between
|
`RefCell` or `Mutex` in sight.
|
||||||
events (embassy's cooperative executor parks all futures).
|
- **The runtime races them all.** `m.run()` simultaneously polls the
|
||||||
- **Unified observability.** One observation per atom of execution
|
`during:`, listens on the event channel, and waits for the next
|
||||||
fans out at compile time to a journal (`Vec<TraceEvent>`) and/or
|
timer deadline; whichever fires first drives the next transition.
|
||||||
textual trace backends (`defmt`, `log`, `tracing`) — they share a
|
The MCU sleeps in between.
|
||||||
single vocabulary, so they cannot diverge. With no sink feature on,
|
|
||||||
every site expands to `()` (zero overhead). See
|
|
||||||
[Observability](#observability--one-journal-multiple-outputs).
|
|
||||||
- **Deterministic + mechanically verified.** The `journal` feature
|
|
||||||
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`). 159 tests pin every spec rule
|
|
||||||
(journal sequences + trace-format regression + integration).
|
|
||||||
- **Backward-compatible chart syntax.** Every v0.1 / v0.2 / v0.3
|
|
||||||
statechart compiles under v0.4 unchanged. The 0.4 breaks are in
|
|
||||||
observability surface area only — see
|
|
||||||
[Migration](#migration-from-v03).
|
|
||||||
|
|
||||||
## How a chart behaves — the building blocks
|
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
|
## What you get
|
||||||
edge case below falls out — you don't have to memorize anything else.
|
|
||||||
|
- **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
|
The full canonical reference lives at
|
||||||
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md);
|
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md);
|
||||||
this section is the on-ramp.
|
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
|
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
|
to one innermost active state. If you're in `C` inside `B` inside `A`,
|
||||||
**simultaneously** in `Root`, `A`, `B`, and `C`. The root is always
|
you're **simultaneously** in `Root`, `A`, `B`, and `C`. The root is
|
||||||
active until termination.
|
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.
|
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
|
If a behavior somewhere else seems weird, come back here — it's
|
||||||
probably the resolution.
|
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
|
A transition moves the active path from the current innermost state to
|
||||||
state is entered, its entry actions run first, **then** the default
|
some target state. The algorithm is uniform regardless of what
|
||||||
child is entered, recursively, until a leaf.
|
triggered it (event, timer, or default — see later rules):
|
||||||
|
|
||||||
So `Root` with `default(A)` and `A` with `default(B)` means: the
|
|
||||||
machine starts by entering `Root` → `A` → `B`, all on first step.
|
|
||||||
|
|
||||||
### 3. Event bubbling — leaf first, first handler wins
|
|
||||||
|
|
||||||
An event arrives. Search starts at the **innermost** active state. If
|
|
||||||
that state has a handler, it runs and the event is consumed. If not,
|
|
||||||
walk up to the parent and check there. Repeat. If you reach the root
|
|
||||||
with no handler, the event is silently discarded.
|
|
||||||
|
|
||||||
Three corollaries:
|
|
||||||
|
|
||||||
- Leaf shadows ancestor for the same trigger.
|
|
||||||
- Multiple handlers in one state on the same trigger fire in
|
|
||||||
declaration order; the transition (if any) fires last.
|
|
||||||
- **Timers don't bubble.** A timer belongs to the state that declared
|
|
||||||
it; it fires only in that state's scope.
|
|
||||||
|
|
||||||
### 4. Transitions — exit to LCA, enter to target
|
|
||||||
|
|
||||||
Any transition's algorithm is uniform:
|
|
||||||
|
|
||||||
1. Find the LCA (lowest common ancestor) of current and target.
|
1. Find the LCA (lowest common ancestor) of current and target.
|
||||||
2. Walk **up** from current, exiting each state, until you hit the LCA.
|
2. Walk **up** from current, exiting each state, until you hit the LCA.
|
||||||
The LCA itself is **not** exited.
|
The LCA itself is **not** exited.
|
||||||
3. Walk **down** from the LCA to target, entering each state. The LCA
|
3. Walk **down** from the LCA to target, entering each state. The LCA
|
||||||
is **not** re-entered.
|
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
|
The LCA always exists because root is always an upper bound. State
|
||||||
names are globally unique across the chart, so a transition can target
|
names are globally unique across the chart, so a transition can target
|
||||||
**any** state — siblings, cousins, ancestors, the root itself
|
**any** state — siblings, cousins, ancestors, the root itself
|
||||||
(`on(Trig) => MyChart;`).
|
(`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
|
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
|
an ancestor of the innermost state), then step 3 of the transition
|
||||||
transition algorithm do nothing. You exit the states strictly between
|
algorithm does nothing. You exit the states strictly between current
|
||||||
current and target; the target itself is **not** re-entered.
|
and target; the target itself is **not** re-entered.
|
||||||
|
|
||||||
So from leaf `C` inside `B` inside `A`, an `on(Up) => A;` exits `C`
|
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
|
then `B`. `A`'s entry actions don't fire. `A`'s timers don't restart.
|
||||||
followed. `A`'s timers don't restart. `A`'s durings keep running.
|
`A`'s durings keep running.
|
||||||
|
|
||||||
You cannot enter a state you never left.
|
You cannot enter a state you never left.
|
||||||
|
|
||||||
### 6. Entry / exit ordering
|
### 4. Entry / exit ordering
|
||||||
|
|
||||||
Direction is set by the path:
|
Direction is set by the path:
|
||||||
|
|
||||||
- **Entries** fire **outer-to-inner**: when entering down through a
|
- **Entries** fire **outer-to-inner**: when entering down through a
|
||||||
path, the outermost state's entries fire first, then the next, then
|
path, the outermost state's entries fire first, then the next, then
|
||||||
the leaf's. Within a single state, entry actions fire in declaration
|
the innermost's. Within a single state, entry actions fire in
|
||||||
order. After a state's entries finish, its `during:` activities
|
declaration order. After a state's entries finish, its `during:`
|
||||||
start. Then default-descent (rule 2) applies recursively.
|
activities start.
|
||||||
- **Exits** fire **inner-to-outer**: leaf first, then parent, then
|
- **Exits** fire **inner-to-outer**: innermost first, then parent, then
|
||||||
grandparent. Within a single state, exit actions fire in declaration
|
grandparent. Within a single state, exit actions fire in declaration
|
||||||
order. Before a state's exits begin, its `during:` activities are
|
order. Before a state's exits begin, its `during:` activities are
|
||||||
cancelled.
|
cancelled.
|
||||||
@@ -148,6 +254,58 @@ 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
|
on the way out → run entries on the way in → start durings on the way
|
||||||
in.
|
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
|
### 7. Timers — armed by state lifecycle
|
||||||
|
|
||||||
A timer (`on(after D) => …` or `on(every D) => …`) is **armed** when
|
A timer (`on(after D) => …` or `on(every D) => …`) is **armed** when
|
||||||
@@ -168,7 +326,7 @@ restarts its timers from zero.
|
|||||||
durings on the active path race; each borrows disjoint `&mut` fields
|
durings on the active path race; each borrows disjoint `&mut` fields
|
||||||
of the context (Rust's split-borrow checker enforces this at compile
|
of the context (Rust's split-borrow checker enforces this at compile
|
||||||
time).
|
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.
|
chart": exit the entire active path inner-to-outer, then stop.
|
||||||
Pending events drop. In-flight durings drop at their next `.await`.
|
Pending events drop. In-flight durings drop at their next `.await`.
|
||||||
|
|
||||||
@@ -178,78 +336,6 @@ spells out edge cases (queue overflow surfacing, same-tick timer ties,
|
|||||||
self-transitions on composites) but the rules above cover the
|
self-transitions on composites) but the rules above cover the
|
||||||
overwhelming majority of charts.
|
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.4", 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
|
## `during:` — async activities scoped to a state
|
||||||
|
|
||||||
A `during:` runs while its state is on the active path. It's a plain async
|
A `during:` runs while its state is on the active path. It's a plain async
|
||||||
@@ -357,7 +443,7 @@ For `statechart! Foo { ... }` the macro emits:
|
|||||||
| `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). |
|
| `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). |
|
||||||
| `fn send(ev)` | Raw push, non-draining. For tests. |
|
| `fn send(ev)` | Raw push, non-draining. For tests. |
|
||||||
| `sender() -> Sender` | Clone handle for other tasks / ISRs. |
|
| `sender() -> Sender` | Clone handle for other tasks / ISRs. |
|
||||||
| `current_state()` | The active leaf state. |
|
| `current_state()` | The innermost active state. |
|
||||||
| `is_terminated()` | True after `terminate` event. |
|
| `is_terminated()` | True after `terminate` event. |
|
||||||
| `has_pending_events()` | Queue non-empty. |
|
| `has_pending_events()` | Queue non-empty. |
|
||||||
| `context()` / `context_mut()` | Borrow the root context. |
|
| `context()` / `context_mut()` | Borrow the root context. |
|
||||||
@@ -366,187 +452,59 @@ For `statechart! Foo { ... }` the macro emits:
|
|||||||
|
|
||||||
## Feature flags
|
## Feature flags
|
||||||
|
|
||||||
| Feature | Effect |
|
| Feature | Effect |
|
||||||
| --------- | -------------------------------------------------------------------------- |
|
| --------------- | -------------------------------------------------------------------------- |
|
||||||
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
|
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
|
||||||
| `tokio` | Async actions + `run()`. Targets single-thread via `LocalSet`. |
|
| `tokio` | Async actions + `run()`. Single-thread (`flavor = "current_thread"`). |
|
||||||
| `embassy` | Async actions + `run()`. Targets embassy's cooperative executor. `no_std`. |
|
| `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
|
`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,
|
||||||
## Migration from v0.3
|
the trace points compile to nothing (zero overhead).
|
||||||
|
|
||||||
The 0.4 breaks are in the observability surface area only; chart
|
|
||||||
syntax and runtime API are unchanged.
|
|
||||||
|
|
||||||
### 1. Trace prefix changed
|
|
||||||
|
|
||||||
Trace lines (when `trace-defmt` / `trace-log` / `trace-tracing` is
|
|
||||||
enabled) now use `[statechart:Name]` instead of `[Name]`. Update any
|
|
||||||
log filters or grep patterns. The verb after the prefix is now a
|
|
||||||
single hyphenated token followed by `key=value` pairs (logfmt).
|
|
||||||
|
|
||||||
### 2. `TraceEvent::TransitionFired` carries `reason`
|
|
||||||
|
|
||||||
Add `reason` to any pattern matches:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before:
|
|
||||||
TraceEvent::TransitionFired { from, to } => …
|
|
||||||
// after:
|
|
||||||
TraceEvent::TransitionFired { from, to, reason } => …
|
|
||||||
// or, if you don't need it:
|
|
||||||
TraceEvent::TransitionFired { from, to, .. } => …
|
|
||||||
```
|
|
||||||
|
|
||||||
The new field carries `TransitionReason::Event { event }`,
|
|
||||||
`Timer { state, timer }`, or `Internal` so consumers can see what
|
|
||||||
triggered the transition.
|
|
||||||
|
|
||||||
### 3. New `TraceEvent` variants
|
|
||||||
|
|
||||||
If you `match` exhaustively on `TraceEvent`, add arms for the new
|
|
||||||
variants. Otherwise nothing changes:
|
|
||||||
|
|
||||||
- `EnterBegan { state }` — phase begin marker pairs with `Entered`.
|
|
||||||
- `ExitBegan { state }` — phase begin marker pairs with `Exited`.
|
|
||||||
- `TransitionComplete { from, to }` — pairs with `TransitionFired`.
|
|
||||||
- `EventReceived { event }` — fires when an event is popped from the
|
|
||||||
queue, before handler search.
|
|
||||||
|
|
||||||
These are pure observation — no runtime semantic change.
|
|
||||||
|
|
||||||
### 4. `trace;` keyword is a no-op
|
|
||||||
|
|
||||||
Charts that declared `trace;` continue to compile. The keyword no
|
|
||||||
longer gates emission — observation is now controlled purely by
|
|
||||||
which `trace-*` cargo feature you enable. You can leave `trace;` in
|
|
||||||
or remove it; both work.
|
|
||||||
|
|
||||||
### 5. Trace backends are no longer mutually exclusive
|
|
||||||
|
|
||||||
Previously, enabling more than one of `trace-defmt` / `trace-log` /
|
|
||||||
`trace-tracing` was a compile error. Now they fan out independently:
|
|
||||||
turn on whichever you want.
|
|
||||||
|
|
||||||
## Migration from v0.1
|
|
||||||
|
|
||||||
v0.1 statecharts continue to compile. New features are purely additive:
|
|
||||||
|
|
||||||
### 1. `events:` is now optional
|
|
||||||
|
|
||||||
Previously required; now omit for pure timer-only machines:
|
|
||||||
|
|
||||||
```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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `dispatch(ev).await` replaces the push+drain helper
|
|
||||||
|
|
||||||
Before (v0.1, in the dogfood firmware):
|
|
||||||
|
|
||||||
```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;
|
|
||||||
```
|
|
||||||
|
|
||||||
After (v0.2):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
machine.dispatch(ev).await.unwrap();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. `during:` replaces the imperative I/O outer loop
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
| Example | Purpose |
|
|
||||||
| --------------- | --------------------------------------------------------------------- |
|
|
||||||
| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate. |
|
|
||||||
| `microwave_tui` | TUI variant of microwave. |
|
|
||||||
| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. |
|
|
||||||
| `embassy_full` | Embassy feature demonstration. |
|
|
||||||
|
|
||||||
Run `cargo run --example during_radio --features tokio` to see the
|
|
||||||
`during:` pattern end-to-end.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
`hsmc` ships with a Creusot deductive-verification crate
|
|
||||||
(`verification/`) that proves the runtime's `EventQueue` and
|
|
||||||
`TimerTable` correct against Pearlite specs. Why3 + alt-ergo + z3
|
|
||||||
discharge every VC mechanically. Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
just verify
|
|
||||||
```
|
|
||||||
|
|
||||||
The recipe is self-installing (mise pulls in the pinned nightly,
|
|
||||||
opam, Why3, the SMT solvers, and `cargo-creusot`) so a fresh clone
|
|
||||||
gets to a green proof in one command. See
|
|
||||||
[`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the
|
|
||||||
spec-rule → proof mapping.
|
|
||||||
|
|
||||||
Beyond Creusot, the suite includes:
|
|
||||||
|
|
||||||
- **159 integration tests** — full chart behavior, byte-equal expected-
|
|
||||||
vs-actual journals for ~113 deterministic-flow tests, plus 11 trace-
|
|
||||||
format regression tests pinning every observation verb's exact
|
|
||||||
textual rendering.
|
|
||||||
- **0 missed mutants** under `cargo mutants --features
|
|
||||||
tokio,journal,trace-log`.
|
|
||||||
- **Miri**: 68 tests, no UB.
|
|
||||||
|
|
||||||
## Observability — one journal, multiple outputs
|
## Observability — one journal, multiple outputs
|
||||||
|
|
||||||
Every chart emits a single observation per atom of execution. That
|
On hsmc's side, turning on observability is a single Cargo feature.
|
||||||
observation fans out to whichever sinks you compile in:
|
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).
|
||||||
|
|
||||||
|
If your firmware crate already uses `defmt`, add the feature and
|
||||||
|
you're done — chart events interleave with the rest of your defmt
|
||||||
|
output:
|
||||||
|
|
||||||
|
```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
|
||||||
|
#[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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
| Feature | Sink |
|
| Feature | Sink |
|
||||||
| --------------- | -------------------------------------------------------- |
|
| --------------- | -------------------------------------------------------- |
|
||||||
@@ -555,10 +513,9 @@ observation fans out to whichever sinks you compile in:
|
|||||||
| `trace-log` | `log::*` log lines |
|
| `trace-log` | `log::*` log lines |
|
||||||
| `trace-tracing` | `tracing::*` events with structured fields |
|
| `trace-tracing` | `tracing::*` events with structured fields |
|
||||||
|
|
||||||
With **no** sink feature on, observation is fully elided at compile
|
Multiple sinks can be enabled simultaneously; the journal IS the
|
||||||
time — zero overhead. Multiple sinks can be enabled simultaneously;
|
observation stream and trace backends are textual renderings of it,
|
||||||
the journal IS the observation stream and trace backends are textual
|
so they cannot diverge.
|
||||||
renderings of it, so they cannot diverge.
|
|
||||||
|
|
||||||
The textual format is logfmt-style — greppable and regex-parseable:
|
The textual format is logfmt-style — greppable and regex-parseable:
|
||||||
|
|
||||||
@@ -609,28 +566,70 @@ transition: an event (`event:VariantName`), a timer
|
|||||||
(`timer:State/timer-id`), or `internal`. Real-life captures can be
|
(`timer:State/timer-id`), or `internal`. Real-life captures can be
|
||||||
diffed against expected behavior the same way the journal can.
|
diffed against expected behavior the same way the journal can.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`hsmc` ships with a Creusot deductive-verification crate
|
||||||
|
(`verification/`) that proves the runtime's `EventQueue` and
|
||||||
|
`TimerTable` correct against Pearlite specs. Why3 + alt-ergo + z3
|
||||||
|
discharge every VC mechanically. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just verify
|
||||||
|
```
|
||||||
|
|
||||||
|
The recipe is self-installing (mise pulls in the pinned nightly,
|
||||||
|
opam, Why3, the SMT solvers, and `cargo-creusot`) so a fresh clone
|
||||||
|
gets to a green proof in one command. See
|
||||||
|
[`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the
|
||||||
|
spec-rule → proof mapping.
|
||||||
|
|
||||||
|
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
|
## Status
|
||||||
|
|
||||||
- v0.1 spec: fully implemented.
|
Pre-1.0. Per-release breaking changes are documented in
|
||||||
- v0.2: `during:`, optional `events:`, `dispatch()`, `STATE_CHART`,
|
[`CHANGELOG.md`](CHANGELOG.md).
|
||||||
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.
|
|
||||||
- v0.4: unified observation pipeline (journal + trace share one
|
|
||||||
vocabulary, fan out to multiple sinks at compile time); logfmt-
|
|
||||||
style trace format with `[statechart:Name]` prefix; transition
|
|
||||||
reasons; entry/exit/transition begin/end markers; multiple trace
|
|
||||||
backends now compose; chart-level `trace;` keyword deprecated.
|
|
||||||
Breaking on the `TraceEvent` enum shape; chart syntax unchanged.
|
|
||||||
- Out of scope (deliberate): guard conditions, orthogonal/parallel
|
|
||||||
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.
|
|
||||||
|
|
||||||
## License
|
## 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.
|
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.
|
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.
|
to the target, calling entries along the way.
|
||||||
- **Action (without transition)** — a function call attached to a trigger.
|
- **Action (without transition)** — a function call attached to a trigger.
|
||||||
No state change. That's it.
|
No state change. That's it.
|
||||||
- **Default child** — every non-leaf state declares one default child.
|
- **Default transition** — any state **may** declare one `default(...)`.
|
||||||
Whenever a state with children is entered, the default child is entered
|
It's a transition (same LCA-aware algorithm as rule 4 / event-driven
|
||||||
immediately after that state's entry actions finish — it's effectively
|
transitions) that fires immediately after the declaring state's entry
|
||||||
an automatic transition into the child.
|
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
|
- **Trigger: event vs. duration** — an event is a variant of your event
|
||||||
enum. A duration is a timer (`after Duration` fires once, `every
|
enum. A duration is a timer (`after Duration` fires once, `every
|
||||||
Duration` fires repeatedly). Triggers attach to transitions and to
|
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
|
`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
|
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:
|
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
|
- A handler on the innermost active state shadows a handler on any
|
||||||
event. The leaf wins because the search starts there.
|
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)
|
- If a single state has multiple handlers (actions and/or a transition)
|
||||||
for the same trigger, the actions fire in declaration order, then the
|
for the same trigger, the actions fire in declaration order, then the
|
||||||
transition fires last.
|
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
|
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
|
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
|
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
|
no default firing on root (the root wasn't freshly entered). Use this
|
||||||
back up to the chart's own scope.
|
when you want to wind everything back up to the chart's own scope.
|
||||||
|
|
||||||
To run a transition:
|
To run a transition:
|
||||||
|
|
||||||
@@ -121,8 +129,10 @@ To run a transition:
|
|||||||
itself is **not** exited.
|
itself is **not** exited.
|
||||||
3. Walk **down** from the LCA to the target, entering each state on the
|
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.
|
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
|
4. If the target declares a `default(...)`, fire it as a recursive
|
||||||
leaf, entering each as you go.
|
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
|
The three flavors below are just three relationships between current and
|
||||||
target. The same algorithm covers all of them — they only look different
|
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)
|
### Transitioning to a sibling (or anywhere not on your active path)
|
||||||
|
|
||||||
The standard case. LCA is somewhere strictly above both current and
|
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. Exit up to LCA, enter down to target, then if the target
|
||||||
target has children. Already covered above.
|
declares a `default(...)`, fire it as a chained transition. Already
|
||||||
|
covered above.
|
||||||
|
|
||||||
### Transitioning to yourself
|
### Transitioning to yourself
|
||||||
|
|
||||||
@@ -153,11 +164,12 @@ Timers declared on the target restart, because the target itself was
|
|||||||
exited and re-entered.
|
exited and re-entered.
|
||||||
|
|
||||||
If the target happens to be a composite (it has children, but no child is
|
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
|
active right now because you got there via an up-transition or because
|
||||||
default-child rule still applies on re-entry: exit the composite, re-enter
|
the composite has no `default`), the same rule still applies on re-entry:
|
||||||
it, then descend through its default chain. Self-transitions don't add a
|
exit the composite, re-enter it, then descend through its default chain
|
||||||
new rule — they're just the case where source and target are the same
|
if it declares one. A composite without `default` is exited, re-entered,
|
||||||
state.
|
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
|
Personally I don't find self-transitions all that valuable, but people
|
||||||
talk about them all the time, so they're supported.
|
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
|
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
|
down to the innermost. Within a single state, entry actions fire in
|
||||||
declaration order. After entry actions of a state finish, that state's
|
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
|
- **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
|
innermost state exits first, then its parent, and so on up. Within a
|
||||||
single state, exit actions fire in declaration order. Before exit
|
single state, exit actions fire in declaration order. Before exit
|
||||||
@@ -367,11 +383,13 @@ drafts. Edit freely.)_
|
|||||||
|
|
||||||
| Scenario | What fires |
|
| Scenario | What fires |
|
||||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Initial machine start | Root entries → default-descend down through each child's entries → durings start at each entered state. |
|
| 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) → default-descend if target has children. |
|
| 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 innermost has children, default-descend after re-entry. |
|
| 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. No default-descent. Ancestor's durings and timers continue. |
|
| 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 children | After the target's entries fire, default-descend through each default child's entries down to a leaf. |
|
| 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). |
|
| Timer fires | The declaring state's handler runs (timers don't bubble). |
|
||||||
| Event with no handler | Silently discarded. |
|
| Event with no handler | Silently discarded. |
|
||||||
| Event handled at innermost | Innermost state's handler runs (actions in declaration order, then transition if any). |
|
| Event handled at innermost | Innermost state's handler runs (actions in declaration order, then transition if any). |
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ Cost remaining, in priority order:
|
|||||||
users with all-sync actions skip this entirely. **User-facing API
|
users with all-sync actions skip this entirely. **User-facing API
|
||||||
change** — out of scope for the current optimization pass.
|
change** — out of scope for the current optimization pass.
|
||||||
2. **Bench-wrapper noise floor** — at the smallest dispatch's ~38–80
|
2. **Bench-wrapper noise floor** — at the smallest dispatch's ~38–80
|
||||||
instructions, the iai-callgrind wrapper (`black_box`, `send`,
|
instructions, the gungraun wrapper (`black_box`, `send`,
|
||||||
instrumented call-edge crossing) accounts for ~45 instructions.
|
instrumented call-edge crossing) accounts for ~45 instructions.
|
||||||
Sub-bench-wrapper improvements are below the measurement floor.
|
Sub-bench-wrapper improvements are below the measurement floor.
|
||||||
3. **Memory cost of `__PATH_DATA` / `__PATH_RANGE` / `__DISPATCH` /
|
3. **Memory cost of `__PATH_DATA` / `__PATH_RANGE` / `__DISPATCH` /
|
||||||
|
|||||||
@@ -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.
|
|
||||||
+269
-89
@@ -100,21 +100,27 @@ type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)
|
|||||||
/// in `transition()` with two slice lookups and two straight slice iterations.
|
/// in `transition()` with two slice lookups and two straight slice iterations.
|
||||||
///
|
///
|
||||||
/// The (from, to) lookup matches the runtime LCA call: `from` is the active
|
/// The (from, to) lookup matches the runtime LCA call: `from` is the active
|
||||||
/// leaf (`self.current`), `to` is the transition target. The same three
|
/// innermost state (`self.current`), `to` is the transition target. The
|
||||||
/// transition shapes are encoded uniformly by what the precomputed paths
|
/// same three transition shapes are encoded uniformly by what the
|
||||||
/// contain:
|
/// precomputed paths contain:
|
||||||
///
|
///
|
||||||
/// - **Up-transition** (`to` is a strict ancestor of `from`): exit_path is
|
/// - **Up-transition** (`to` is a strict ancestor of `from`): exit_path is
|
||||||
/// `from..to` (target excluded; `to` is already on the active path and
|
/// `from..to` (target excluded; `to` is already on the active path and
|
||||||
/// must not be re-entered). enter_path is empty — `transition()` sets
|
/// must not be re-entered). enter_path is empty — `transition()` sets
|
||||||
/// `self.current = Some(target)` after the loops.
|
/// `self.current = Some(target)` after the loops.
|
||||||
/// - **Self-transition** (`from == to`): exit_path is `[to]`. enter_path
|
/// - **Self-transition** (`from == to`): exit_path is `[to]`. enter_path
|
||||||
/// is `[to, default_child(to), default_child(default_child(to)), ...]`
|
/// is `[to]` — re-enter the target.
|
||||||
/// — re-enter target plus descend defaults.
|
|
||||||
/// - **Lateral** (LCA is a proper ancestor of both): exit_path walks
|
/// - **Lateral** (LCA is a proper ancestor of both): exit_path walks
|
||||||
/// `from` up to but not including LCA. enter_path walks
|
/// `from` up to but not including LCA. enter_path walks
|
||||||
/// LCA-side child of `to` down to `to`, then continues descending
|
/// LCA-side child of `to` down to `to`.
|
||||||
/// `__DEFAULT_CHILD` from `to`.
|
///
|
||||||
|
/// Default-descent is **not** folded into these paths. After `transition()`
|
||||||
|
/// applies the precomputed exit/enter, it follows the default chain by
|
||||||
|
/// firing further transitions in a loop (`follow_defaults`). This is
|
||||||
|
/// because `default(...)` may target any state in the chart — sibling,
|
||||||
|
/// ancestor, anywhere — so each step of the chain is itself a full
|
||||||
|
/// LCA-aware transition, not a simple child descent. The compile-time
|
||||||
|
/// default-graph cycle check guarantees the loop terminates.
|
||||||
///
|
///
|
||||||
/// Storage: `__EXIT_DATA` and `__ENTER_DATA` are flat `[u16]` blobs;
|
/// Storage: `__EXIT_DATA` and `__ENTER_DATA` are flat `[u16]` blobs;
|
||||||
/// `__EXIT_RANGE[i*N + j]` / `__ENTER_RANGE[i*N + j]` give `(start, end)`
|
/// `__EXIT_RANGE[i*N + j]` / `__ENTER_RANGE[i*N + j]` give `(start, end)`
|
||||||
@@ -126,7 +132,6 @@ type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)
|
|||||||
fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||||
let n = states.len();
|
let n = states.len();
|
||||||
let parent = |s: u16| -> Option<u16> { states[s as usize].parent };
|
let parent = |s: u16| -> Option<u16> { states[s as usize].parent };
|
||||||
let default_child = |s: u16| -> Option<u16> { states[s as usize].default_child };
|
|
||||||
let depth = |s: u16| -> i32 { states[s as usize].depth as i32 };
|
let depth = |s: u16| -> i32 { states[s as usize].depth as i32 };
|
||||||
|
|
||||||
let lca = |mut a: u16, mut b: u16| -> Option<u16> {
|
let lca = |mut a: u16, mut b: u16| -> Option<u16> {
|
||||||
@@ -171,21 +176,15 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
|||||||
return (exit_path, Vec::new());
|
return (exit_path, Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-transition: external semantics — exit and re-enter target,
|
// Self-transition: external semantics — exit and re-enter target.
|
||||||
// then descend defaults.
|
// Default-following (if any) happens in the runtime chain loop.
|
||||||
if from == to {
|
if from == to {
|
||||||
let exit_path = vec![to];
|
return (vec![to], vec![to]);
|
||||||
let mut enter_path = vec![to];
|
|
||||||
let mut cur = default_child(to);
|
|
||||||
while let Some(c) = cur {
|
|
||||||
enter_path.push(c);
|
|
||||||
cur = default_child(c);
|
|
||||||
}
|
|
||||||
return (exit_path, enter_path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lateral: exit `from` up to (not including) LCA; enter from
|
// Lateral: exit `from` up to (not including) LCA; enter from
|
||||||
// LCA-child on `to`'s side down to `to`, then descend defaults.
|
// LCA-child on `to`'s side down to `to`. Default-following
|
||||||
|
// happens in the runtime chain loop.
|
||||||
let lca_id = lca(from, to);
|
let lca_id = lca(from, to);
|
||||||
|
|
||||||
let mut exit_path = Vec::new();
|
let mut exit_path = Vec::new();
|
||||||
@@ -213,13 +212,7 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
|||||||
None => break,
|
None => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut enter_path: Vec<u16> = rev.into_iter().rev().collect();
|
let enter_path: Vec<u16> = rev.into_iter().rev().collect();
|
||||||
|
|
||||||
let mut cur = default_child(to);
|
|
||||||
while let Some(c) = cur {
|
|
||||||
enter_path.push(c);
|
|
||||||
cur = default_child(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
(exit_path, enter_path)
|
(exit_path, enter_path)
|
||||||
};
|
};
|
||||||
@@ -260,8 +253,8 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
|||||||
(exit_data, enter_data, exit_range, enter_range)
|
(exit_data, enter_data, exit_range, enter_range)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Static precompute of `do_terminate()`'s exit walk: for each leaf state,
|
/// Static precompute of `do_terminate()`'s exit walk: for each state, the
|
||||||
/// the chain `[current, parent(current), ..., __Root]` (root inclusive,
|
/// chain `[current, parent(current), ..., __Root]` (root inclusive,
|
||||||
/// since `__Root` may carry root-level `entry:`/`exit:` actions declared
|
/// since `__Root` may carry root-level `entry:`/`exit:` actions declared
|
||||||
/// at the chart top level — see the `det_initial` test for that pattern).
|
/// at the chart top level — see the `det_initial` test for that pattern).
|
||||||
///
|
///
|
||||||
@@ -376,17 +369,21 @@ fn compute_event_dispatch(ir: &Ir) -> Vec<(u16, Vec<u16>, Option<u16>)> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For each user-declared leaf state that has one or more `during:` activities
|
/// For each state that can be the innermost active state, return its state id
|
||||||
/// active (either declared on it, on any ancestor, or on root), return its
|
/// and the flattened list of `during:` activities active there (declared on it
|
||||||
/// state id and the flattened list of durings in leaf-first order. Durings
|
/// or on any ancestor) in leaf-first order. Durings declared on the same state
|
||||||
/// declared on the same state follow their declaration order.
|
/// follow their declaration order.
|
||||||
///
|
///
|
||||||
/// Leaf states with no active durings are omitted — the run loop uses its
|
/// A state can be innermost iff it has no children, OR it has children but no
|
||||||
/// default (channel+timer) path for those.
|
/// `default` child (so default-descent stops at it). Composites with a default
|
||||||
fn active_durings_per_leaf(ir: &Ir) -> Vec<(u16, Vec<During>)> {
|
/// child are always descended past at runtime, so they're skipped here.
|
||||||
|
///
|
||||||
|
/// Innermost-able states with no active durings are omitted — the run loop
|
||||||
|
/// uses its default (channel+timer) path for those.
|
||||||
|
fn active_durings_per_innermost_state(ir: &Ir) -> Vec<(u16, Vec<During>)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for s in &ir.states {
|
for s in &ir.states {
|
||||||
if !s.children.is_empty() {
|
if !s.children.is_empty() && s.default_child.is_some() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if s.parent.is_none() {
|
if s.parent.is_none() {
|
||||||
@@ -425,14 +422,14 @@ fn emit_during_bindings(chain: &[During]) -> (Vec<Ident>, TokenStream) {
|
|||||||
(idents, quote! { #(#stmts)* })
|
(idents, quote! { #(#stmts)* })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit the per-leaf match arms for the tokio run loop. Each arm constructs
|
/// Emit the per-state match arms for the tokio run loop, keyed on the
|
||||||
/// the active during: futures (split-borrowing context fields), then races
|
/// innermost active state id. Each arm constructs the active during: futures
|
||||||
/// them against the event channel and the next-timer sleep via
|
/// (split-borrowing context fields), then races them against the event channel
|
||||||
/// `tokio::select!`. The default arm — used for leaves with no durings — is
|
/// and the next-timer sleep via `tokio::select!`. The default arm — used for
|
||||||
/// emitted by the caller.
|
/// states with no active durings — is emitted by the caller.
|
||||||
fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||||
let mut arms = Vec::new();
|
let mut arms = Vec::new();
|
||||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||||
let (binds, stmts) = emit_during_bindings(&chain);
|
let (binds, stmts) = emit_during_bindings(&chain);
|
||||||
let select_arms = binds.iter().map(|b| {
|
let select_arms = binds.iter().map(|b| {
|
||||||
quote! { ev = #b => __HsmcRace::Event(ev), }
|
quote! { ev = #b => __HsmcRace::Event(ev), }
|
||||||
@@ -462,7 +459,7 @@ fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
|||||||
/// (no `events:` declared, no channel). The default arm races only the timer.
|
/// (no `events:` declared, no channel). The default arm races only the timer.
|
||||||
fn emit_tokio_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
|
fn emit_tokio_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
|
||||||
let mut arms = Vec::new();
|
let mut arms = Vec::new();
|
||||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||||
let (binds, stmts) = emit_during_bindings(&chain);
|
let (binds, stmts) = emit_during_bindings(&chain);
|
||||||
let select_arms = binds.iter().map(|b| {
|
let select_arms = binds.iter().map(|b| {
|
||||||
quote! { ev = #b => __HsmcRace::Event(ev), }
|
quote! { ev = #b => __HsmcRace::Event(ev), }
|
||||||
@@ -639,7 +636,7 @@ fn embassy_select_for_arity(arity: usize) -> (Ident, Vec<Ident>) {
|
|||||||
/// compiles unchanged.
|
/// compiles unchanged.
|
||||||
fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||||
let mut arms = Vec::new();
|
let mut arms = Vec::new();
|
||||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||||
let n = chain.len();
|
let n = chain.len();
|
||||||
if n + 2 > 6 {
|
if n + 2 > 6 {
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
@@ -703,7 +700,7 @@ fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
|||||||
/// durings on a timer-only path.
|
/// durings on a timer-only path.
|
||||||
fn emit_embassy_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
|
fn emit_embassy_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
|
||||||
let mut arms = Vec::new();
|
let mut arms = Vec::new();
|
||||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||||
let n = chain.len();
|
let n = chain.len();
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
continue; // falls through to default (just timer)
|
continue; // falls through to default (just timer)
|
||||||
@@ -1104,8 +1101,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
__timer_reason_str(__t_state, __t_trigger)
|
__timer_reason_str(__t_state, __t_trigger)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
#[allow(dead_code)]
|
let observe_transition_fired_internal = quote! {
|
||||||
let _observe_transition_fired_internal = quote! {
|
|
||||||
::hsmc::__chart_observe!(
|
::hsmc::__chart_observe!(
|
||||||
TransitionFired,
|
TransitionFired,
|
||||||
&mut self.__journal,
|
&mut self.__journal,
|
||||||
@@ -1890,7 +1886,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
// run() bodies to decide whether to exit when no timers are pending: if
|
// run() bodies to decide whether to exit when no timers are pending: if
|
||||||
// the current state has an active during, the run loop should keep
|
// the current state has an active during, the run loop should keep
|
||||||
// awaiting rather than break (the during might yet produce an event).
|
// awaiting rather than break (the during might yet produce an event).
|
||||||
let states_with_active_durings: Vec<u16> = active_durings_per_leaf(ir)
|
let states_with_active_durings: Vec<u16> = active_durings_per_innermost_state(ir)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(sid, _)| sid)
|
.map(|(sid, _)| sid)
|
||||||
.collect();
|
.collect();
|
||||||
@@ -2466,6 +2462,84 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
const __DISPATCH: [(u16, &'static [u16], Option<u16>); #n_dispatch] =
|
const __DISPATCH: [(u16, &'static [u16], Option<u16>); #n_dispatch] =
|
||||||
[#(#dispatch_lit),*];
|
[#(#dispatch_lit),*];
|
||||||
|
|
||||||
|
// ── Compile-time table-invariant checks ─────────────────────
|
||||||
|
//
|
||||||
|
// Every `unsafe { ... .get_unchecked(...) }` in this generated
|
||||||
|
// module relies on bounds the codegen establishes when emitting
|
||||||
|
// the static tables above. Codify those bounds as const-eval
|
||||||
|
// assertions: any future codegen change that produces an
|
||||||
|
// out-of-range index trips this at *build* time rather than
|
||||||
|
// making it to runtime where it would be UB. A const block
|
||||||
|
// that needs to evaluate at type-check time is the strongest
|
||||||
|
// check we can express in stable Rust.
|
||||||
|
const _: () = {
|
||||||
|
// __PATH_RANGE invariants: for every (from, to) pair the
|
||||||
|
// emitted (s, m, e) triple slices __PATH_DATA in two
|
||||||
|
// contiguous, non-overlapping ranges (exit then enter).
|
||||||
|
// Required: 0 <= s <= m <= e <= __PATH_DATA.len(). All
|
||||||
|
// path entries are themselves valid state ids in
|
||||||
|
// 0..#n_states, but enforcing that here would require
|
||||||
|
// unwrapping every entry; it is enforced indirectly via
|
||||||
|
// the per-id checks below and the codegen test suite.
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < __PATH_RANGE.len() {
|
||||||
|
let (s, m, e) = __PATH_RANGE[i];
|
||||||
|
assert!(s as usize <= m as usize, "__PATH_RANGE: s > m");
|
||||||
|
assert!(m as usize <= e as usize, "__PATH_RANGE: m > e");
|
||||||
|
assert!(
|
||||||
|
e as usize <= __PATH_DATA.len(),
|
||||||
|
"__PATH_RANGE: e past __PATH_DATA end"
|
||||||
|
);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// __TERMINATE_RANGE invariants: same shape, one (s, e) per
|
||||||
|
// state — the full exit chain up to and including root.
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < __TERMINATE_RANGE.len() {
|
||||||
|
let (s, e) = __TERMINATE_RANGE[i];
|
||||||
|
assert!(s as usize <= e as usize, "__TERMINATE_RANGE: s > e");
|
||||||
|
assert!(
|
||||||
|
e as usize <= __TERMINATE_DATA.len(),
|
||||||
|
"__TERMINATE_RANGE: e past __TERMINATE_DATA end"
|
||||||
|
);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// __DEFAULT_CHILD invariants: every Some(t) names a state
|
||||||
|
// id < N_STATES. None is the no-default sentinel.
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < __DEFAULT_CHILD.len() {
|
||||||
|
if let Some(t) = __DEFAULT_CHILD[i] {
|
||||||
|
assert!(
|
||||||
|
(t as usize) < __DEFAULT_CHILD.len(),
|
||||||
|
"__DEFAULT_CHILD: target id past N_STATES"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// __PATH_DATA itself: every state id stored is < N_STATES.
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < __PATH_DATA.len() {
|
||||||
|
assert!(
|
||||||
|
(__PATH_DATA[i] as usize) < __DEFAULT_CHILD.len(),
|
||||||
|
"__PATH_DATA: state id past N_STATES"
|
||||||
|
);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// __TERMINATE_DATA: same.
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < __TERMINATE_DATA.len() {
|
||||||
|
assert!(
|
||||||
|
(__TERMINATE_DATA[i] as usize) < __DEFAULT_CHILD.len(),
|
||||||
|
"__TERMINATE_DATA: state id past N_STATES"
|
||||||
|
);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
#name_helpers
|
#name_helpers
|
||||||
|
|
||||||
#[allow(dead_code, unreachable_patterns)]
|
#[allow(dead_code, unreachable_patterns)]
|
||||||
@@ -2595,7 +2669,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
/// Returns `true` once the machine has processed its `terminate` event (§4.4).
|
/// Returns `true` once the machine has processed its `terminate` event (§4.4).
|
||||||
pub fn is_terminated(&self) -> bool { self.terminated }
|
pub fn is_terminated(&self) -> bool { self.terminated }
|
||||||
|
|
||||||
/// Return the current leaf state. Panics if called before the
|
/// Return the innermost active state. Panics if called before the
|
||||||
/// first `step()` (§4.4).
|
/// first `step()` (§4.4).
|
||||||
pub fn current_state(&self) -> #state_enum_name {
|
pub fn current_state(&self) -> #state_enum_name {
|
||||||
__state_id_to_public(self.current.expect("machine not started; call step() first"))
|
__state_id_to_public(self.current.expect("machine not started; call step() first"))
|
||||||
@@ -2690,8 +2764,19 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
if let Some(__cur) = self.current {
|
if let Some(__cur) = self.current {
|
||||||
let __dispatch_idx =
|
let __dispatch_idx =
|
||||||
(__cur as usize) * #n_events + (__ev_id as usize);
|
(__cur as usize) * #n_events + (__ev_id as usize);
|
||||||
let (__handler_state, __actions, __target) =
|
// SAFETY: __cur is set only by codegen-internal
|
||||||
__DISPATCH[__dispatch_idx];
|
// writes (apply_path/enter_state_no_descent) to
|
||||||
|
// values < #n_states. __ev_id was checked != u16::MAX
|
||||||
|
// and __event_to_id returns < #n_events otherwise.
|
||||||
|
// Hence __dispatch_idx < #n_dispatch = __DISPATCH.len().
|
||||||
|
// The debug_assert is the safety obligation
|
||||||
|
// expressed at runtime in debug builds — every
|
||||||
|
// hsmc behavior test exercises this site, so a
|
||||||
|
// codegen invariant break trips it loudly.
|
||||||
|
debug_assert!(__dispatch_idx < __DISPATCH.len());
|
||||||
|
let (__handler_state, __actions, __target) = unsafe {
|
||||||
|
*__DISPATCH.get_unchecked(__dispatch_idx)
|
||||||
|
};
|
||||||
if __handler_state != u16::MAX {
|
if __handler_state != u16::MAX {
|
||||||
#observe_event_delivered
|
#observe_event_delivered
|
||||||
self.run_handlers(
|
self.run_handlers(
|
||||||
@@ -2742,8 +2827,10 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
if let Some(__cur) = self.current {
|
if let Some(__cur) = self.current {
|
||||||
let __dispatch_idx =
|
let __dispatch_idx =
|
||||||
(__cur as usize) * #n_events + (__ev_id as usize);
|
(__cur as usize) * #n_events + (__ev_id as usize);
|
||||||
let (__handler_state, __actions, __target) =
|
// SAFETY: see sync branch — same invariant.
|
||||||
__DISPATCH[__dispatch_idx];
|
let (__handler_state, __actions, __target) = unsafe {
|
||||||
|
*__DISPATCH.get_unchecked(__dispatch_idx)
|
||||||
|
};
|
||||||
if __handler_state != u16::MAX {
|
if __handler_state != u16::MAX {
|
||||||
#observe_event_delivered
|
#observe_event_delivered
|
||||||
self.run_handlers(
|
self.run_handlers(
|
||||||
@@ -2950,55 +3037,107 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
// An empty enter slice (`m == e`) means up-transition: target
|
// An empty enter slice (`m == e`) means up-transition: target
|
||||||
// is already on the active path, must not be re-entered, and
|
// is already on the active path, must not be re-entered, and
|
||||||
// `self.current` is set to `target` after the exit loop. A
|
// `self.current` is set to `target` after the exit loop. A
|
||||||
// non-empty enter slice ends at the deepest default-descended
|
// non-empty enter slice ends at the target itself —
|
||||||
// leaf; `enter_state_no_descent` sets `self.current` as it
|
// `enter_state_no_descent` sets `self.current` as it goes.
|
||||||
// goes, so no fixup is needed on that path.
|
// Default-following runs after the path apply via
|
||||||
|
// `follow_defaults`, which fires further transitions for any
|
||||||
|
// chained `default(...)` declarations on the just-entered
|
||||||
|
// state. The compile-time default-graph cycle check
|
||||||
|
// guarantees the chain terminates.
|
||||||
|
// Returns true if any state was newly entered (i.e., the
|
||||||
|
// enter_path was non-empty). False on an up-transition, where
|
||||||
|
// the target was already on the active path.
|
||||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||||
fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
fn apply_path(&mut self, from: u16, target: u16) -> bool {
|
||||||
let from = self.current.unwrap_or(target);
|
|
||||||
let idx = (from as usize) * #n_states + (target as usize);
|
let idx = (from as usize) * #n_states + (target as usize);
|
||||||
let (s, m, e) = __PATH_RANGE[idx];
|
// SAFETY: from and target are state ids produced by codegen
|
||||||
let exit_path = &__PATH_DATA[s as usize..m as usize];
|
// (current state, __DISPATCH/__DEFAULT_CHILD targets, root=0).
|
||||||
let enter_path = &__PATH_DATA[m as usize..e as usize];
|
// All are < #n_states, so idx < #n_pairs = __PATH_RANGE.len().
|
||||||
|
// The triple (s, m, e) was emitted at codegen with the
|
||||||
|
// invariant 0 <= s <= m <= e <= __PATH_DATA.len(), so the
|
||||||
|
// two slice ranges are in-bounds.
|
||||||
|
debug_assert!(idx < __PATH_RANGE.len());
|
||||||
|
let (s, m, e) = unsafe { *__PATH_RANGE.get_unchecked(idx) };
|
||||||
|
debug_assert!((s as usize) <= (m as usize) && (m as usize) <= (e as usize) && (e as usize) <= __PATH_DATA.len());
|
||||||
|
let exit_path = unsafe {
|
||||||
|
__PATH_DATA.get_unchecked(s as usize..m as usize)
|
||||||
|
};
|
||||||
|
let enter_path = unsafe {
|
||||||
|
__PATH_DATA.get_unchecked(m as usize..e as usize)
|
||||||
|
};
|
||||||
for &sid in exit_path {
|
for &sid in exit_path {
|
||||||
self.exit_state(sid);
|
self.exit_state(sid);
|
||||||
}
|
}
|
||||||
for &sid in enter_path {
|
for &sid in enter_path {
|
||||||
self.enter_state_no_descent(sid);
|
self.enter_state_no_descent(sid);
|
||||||
|
if self.terminated { return !enter_path.is_empty(); }
|
||||||
}
|
}
|
||||||
if enter_path.is_empty() {
|
if enter_path.is_empty() {
|
||||||
self.current = Some(target);
|
self.current = Some(target);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||||
|
async fn apply_path(&mut self, from: u16, target: u16) -> bool {
|
||||||
|
let idx = (from as usize) * #n_states + (target as usize);
|
||||||
|
// SAFETY: see sync branch — same invariant.
|
||||||
|
let (s, m, e) = unsafe { *__PATH_RANGE.get_unchecked(idx) };
|
||||||
|
let exit_path = unsafe {
|
||||||
|
__PATH_DATA.get_unchecked(s as usize..m as usize)
|
||||||
|
};
|
||||||
|
let enter_path = unsafe {
|
||||||
|
__PATH_DATA.get_unchecked(m as usize..e as usize)
|
||||||
|
};
|
||||||
|
for &sid in exit_path {
|
||||||
|
self.exit_state(sid).await;
|
||||||
|
}
|
||||||
|
for &sid in enter_path {
|
||||||
|
self.enter_state_no_descent(sid).await;
|
||||||
|
if self.terminated { return !enter_path.is_empty(); }
|
||||||
|
}
|
||||||
|
if enter_path.is_empty() {
|
||||||
|
self.current = Some(target);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||||
|
fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||||
|
let from = self.current.unwrap_or(target);
|
||||||
|
let entered = self.apply_path(from, target);
|
||||||
|
if self.terminated { return; }
|
||||||
|
if entered {
|
||||||
|
self.follow_defaults();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||||
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||||
let from = self.current.unwrap_or(target);
|
let from = self.current.unwrap_or(target);
|
||||||
let idx = (from as usize) * #n_states + (target as usize);
|
let entered = self.apply_path(from, target).await;
|
||||||
let (s, m, e) = __PATH_RANGE[idx];
|
if self.terminated { return; }
|
||||||
let exit_path = &__PATH_DATA[s as usize..m as usize];
|
if entered {
|
||||||
let enter_path = &__PATH_DATA[m as usize..e as usize];
|
self.follow_defaults().await;
|
||||||
for &sid in exit_path {
|
|
||||||
self.exit_state(sid).await;
|
|
||||||
}
|
|
||||||
for &sid in enter_path {
|
|
||||||
self.enter_state_no_descent(sid).await;
|
|
||||||
}
|
|
||||||
if enter_path.is_empty() {
|
|
||||||
self.current = Some(target);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||||
fn enter_path(&mut self, root: u16) {
|
fn enter_path(&mut self, root: u16) {
|
||||||
self.enter_state_no_descent(root);
|
self.enter_state_no_descent(root);
|
||||||
self.descend_defaults(root);
|
if self.terminated { return; }
|
||||||
|
self.follow_defaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||||
async fn enter_path(&mut self, root: u16) {
|
async fn enter_path(&mut self, root: u16) {
|
||||||
self.enter_state_no_descent(root).await;
|
self.enter_state_no_descent(root).await;
|
||||||
self.descend_defaults(root).await;
|
if self.terminated { return; }
|
||||||
|
self.follow_defaults().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||||
@@ -3041,21 +3180,51 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
#observe_entered
|
#observe_entered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After a state's entries finish (initial entry or any
|
||||||
|
// transition), follow its `default(...)` chain by firing
|
||||||
|
// further transitions. Each step is a full LCA-aware
|
||||||
|
// transition because the default target may be a sibling,
|
||||||
|
// ancestor, or anywhere else — not just a direct child. The
|
||||||
|
// compile-time default-graph cycle check guarantees the loop
|
||||||
|
// terminates.
|
||||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||||
fn descend_defaults(&mut self, sid: u16) {
|
fn follow_defaults(&mut self) {
|
||||||
let mut cur = sid;
|
while let Some(cur) = self.current {
|
||||||
while let Some(child) = __DEFAULT_CHILD[cur as usize] {
|
// SAFETY: cur is a state id < #n_states (codegen
|
||||||
self.enter_state_no_descent(child);
|
// invariant — see __DISPATCH lookup safety note).
|
||||||
cur = child;
|
debug_assert!((cur as usize) < __DEFAULT_CHILD.len());
|
||||||
|
let target = match unsafe { *__DEFAULT_CHILD.get_unchecked(cur as usize) } {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
#observe_transition_fired_internal
|
||||||
|
let __t_complete_from = self.current;
|
||||||
|
let entered = self.apply_path(cur, target);
|
||||||
|
#observe_transition_complete
|
||||||
|
if self.terminated { return; }
|
||||||
|
// If the default fired an up-transition (no new state
|
||||||
|
// entered), don't keep chaining — the ancestor we
|
||||||
|
// landed on was already on the active path; its
|
||||||
|
// default was not "freshly entered" and must not fire.
|
||||||
|
if !entered { return; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||||
async fn descend_defaults(&mut self, sid: u16) {
|
async fn follow_defaults(&mut self) {
|
||||||
let mut cur = sid;
|
while let Some(cur) = self.current {
|
||||||
while let Some(child) = __DEFAULT_CHILD[cur as usize] {
|
// SAFETY: see sync branch — same invariant.
|
||||||
self.enter_state_no_descent(child).await;
|
debug_assert!((cur as usize) < __DEFAULT_CHILD.len());
|
||||||
cur = child;
|
let target = match unsafe { *__DEFAULT_CHILD.get_unchecked(cur as usize) } {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
#observe_transition_fired_internal
|
||||||
|
let __t_complete_from = self.current;
|
||||||
|
let entered = self.apply_path(cur, target).await;
|
||||||
|
#observe_transition_complete
|
||||||
|
if self.terminated { return; }
|
||||||
|
if !entered { return; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3124,8 +3293,14 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||||
fn do_terminate(&mut self) {
|
fn do_terminate(&mut self) {
|
||||||
if let Some(cur) = self.current {
|
if let Some(cur) = self.current {
|
||||||
let (s, e) = __TERMINATE_RANGE[cur as usize];
|
// SAFETY: cur < #n_states (codegen invariant). The
|
||||||
let path = &__TERMINATE_DATA[s as usize..e as usize];
|
// (s, e) tuple was emitted with 0 <= s <= e <= len.
|
||||||
|
debug_assert!((cur as usize) < __TERMINATE_RANGE.len());
|
||||||
|
let (s, e) = unsafe { *__TERMINATE_RANGE.get_unchecked(cur as usize) };
|
||||||
|
debug_assert!((s as usize) <= (e as usize) && (e as usize) <= __TERMINATE_DATA.len());
|
||||||
|
let path = unsafe {
|
||||||
|
__TERMINATE_DATA.get_unchecked(s as usize..e as usize)
|
||||||
|
};
|
||||||
for &sid in path {
|
for &sid in path {
|
||||||
self.exit_state(sid);
|
self.exit_state(sid);
|
||||||
}
|
}
|
||||||
@@ -3139,8 +3314,13 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||||
async fn do_terminate(&mut self) {
|
async fn do_terminate(&mut self) {
|
||||||
if let Some(cur) = self.current {
|
if let Some(cur) = self.current {
|
||||||
let (s, e) = __TERMINATE_RANGE[cur as usize];
|
// SAFETY: see sync branch — same invariant.
|
||||||
let path = &__TERMINATE_DATA[s as usize..e as usize];
|
debug_assert!((cur as usize) < __TERMINATE_RANGE.len());
|
||||||
|
let (s, e) = unsafe { *__TERMINATE_RANGE.get_unchecked(cur as usize) };
|
||||||
|
debug_assert!((s as usize) <= (e as usize) && (e as usize) <= __TERMINATE_DATA.len());
|
||||||
|
let path = unsafe {
|
||||||
|
__TERMINATE_DATA.get_unchecked(s as usize..e as usize)
|
||||||
|
};
|
||||||
for &sid in path {
|
for &sid in path {
|
||||||
self.exit_state(sid).await;
|
self.exit_state(sid).await;
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-6
@@ -79,7 +79,17 @@ pub struct StateIr {
|
|||||||
pub name: Ident,
|
pub name: Ident,
|
||||||
pub parent: Option<u16>,
|
pub parent: Option<u16>,
|
||||||
pub depth: u8,
|
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>,
|
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 entries: Vec<u16>, // action ids
|
||||||
pub exits: Vec<u16>,
|
pub exits: Vec<u16>,
|
||||||
pub handlers: Vec<HandlerIr>,
|
pub handlers: Vec<HandlerIr>,
|
||||||
@@ -192,6 +202,7 @@ fn alloc_state(ir: &mut Ir, name: Ident, parent: Option<u16>, depth: u8, span: S
|
|||||||
parent,
|
parent,
|
||||||
depth,
|
depth,
|
||||||
default_child: None,
|
default_child: None,
|
||||||
|
default_target_ident: None,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
exits: Vec::new(),
|
exits: Vec::new(),
|
||||||
handlers: Vec::new(),
|
handlers: Vec::new(),
|
||||||
@@ -451,12 +462,11 @@ fn lower_body(
|
|||||||
}
|
}
|
||||||
ir.states[state_id as usize].children = child_ids.clone();
|
ir.states[state_id as usize].children = child_ids.clone();
|
||||||
|
|
||||||
if let Some((dc_ident, _)) = &body.default_child {
|
// Stash the unresolved default target. The actual id is filled in by
|
||||||
let resolved = child_ids
|
// `resolve_transitions`, which has the global state-name → id map and
|
||||||
.iter()
|
// can resolve targets that aren't direct children.
|
||||||
.find(|&&cid| ir.states[cid as usize].name == *dc_ident)
|
if let Some((dc_ident, dc_span)) = &body.default_child {
|
||||||
.copied();
|
ir.states[state_id as usize].default_target_ident = Some((dc_ident.clone(), *dc_span));
|
||||||
ir.states[state_id as usize].default_child = resolved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -547,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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
+76
-34
@@ -8,6 +8,7 @@ use crate::ir::{HandlerKindIr, Ir, TriggerIr};
|
|||||||
use crate::parse::{StateBody, StatechartInput};
|
use crate::parse::{StateBody, StatechartInput};
|
||||||
|
|
||||||
pub fn validate(ir: &Ir) -> syn::Result<()> {
|
pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||||
|
validate_default_graph(ir)?;
|
||||||
// Duplicate state names
|
// Duplicate state names
|
||||||
let mut seen = HashMap::new();
|
let mut seen = HashMap::new();
|
||||||
for s in &ir.states {
|
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.
|
// `default` is optional: a composite without a `default(...)` is a
|
||||||
if !s.children.is_empty() && s.default_child.is_none() {
|
// valid resting state — transitions targeting it land on the composite
|
||||||
return Err(syn::Error::new(
|
// itself, with no descent. Substates of such a composite are reachable
|
||||||
s.span,
|
// only via explicit transitions. (`default` must still be a direct
|
||||||
format!(
|
// child when declared; that's checked in `validate_parse_tree`.)
|
||||||
"state `{}` has children but no `default` declaration",
|
|
||||||
s.name
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// duplicate transitions per trigger in this state, AND transition
|
// duplicate transitions per trigger in this state, AND transition
|
||||||
// target (if present) must be declared after every action handler
|
// target (if present) must be declared after every action handler
|
||||||
@@ -184,9 +180,70 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
|
|||||||
Ok(())
|
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
|
/// Additional checks that require the original parse tree (for counting duplicate
|
||||||
/// `default`/`terminate` with their spans, and the "default in childless state" /
|
/// `default`/`terminate` with their spans, and the "terminate in non-root" error).
|
||||||
/// "default not a direct child" / "terminate in non-root" errors).
|
|
||||||
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
|
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
|
||||||
let root_name = input.name.to_string();
|
let root_name = input.name.to_string();
|
||||||
validate_body(&input.body, true, &root_name)
|
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() {
|
// default(...) is a transition that fires immediately after the
|
||||||
return Err(syn::Error::new(
|
// declaring state's entries finish. It may be declared on any state
|
||||||
body.default_span.unwrap(),
|
// (composite or leaf) and may target any state in the chart. A leaf
|
||||||
format!(
|
// with `default(T)` just transitions to T on every entry. The
|
||||||
"`default` in state `{}` which has no child states",
|
// default-graph cycle check in `validate_default_graph` (run from the
|
||||||
state_name
|
// top-level `validate_parse_tree` over the lowered IR) ensures we
|
||||||
),
|
// cannot construct an infinite chain at startup.
|
||||||
));
|
|
||||||
}
|
|
||||||
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
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for c in &body.children {
|
for c in &body.children {
|
||||||
let cname = c.name.to_string();
|
let cname = c.name.to_string();
|
||||||
validate_body(&c.body, false, &cname)?;
|
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;
|
use hsmc::statechart;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -9,9 +12,9 @@ statechart! {
|
|||||||
M {
|
M {
|
||||||
context: Ctx;
|
context: Ctx;
|
||||||
events: Ev;
|
events: Ev;
|
||||||
default(Leaf);
|
default(A);
|
||||||
state Leaf {
|
state A {
|
||||||
default(Ghost);
|
default(A);
|
||||||
entry: e;
|
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);
|
||||||
|
| ^^^^^^^
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+2
-6
@@ -11,7 +11,6 @@ readme = "../README.md"
|
|||||||
keywords = ["statechart", "state-machine", "hsm", "embassy", "embedded"]
|
keywords = ["statechart", "state-machine", "hsm", "embassy", "embedded"]
|
||||||
categories = ["embedded", "no-std", "asynchronous", "rust-patterns"]
|
categories = ["embedded", "no-std", "asynchronous", "rust-patterns"]
|
||||||
autoexamples = false
|
autoexamples = false
|
||||||
exclude = ["examples/microwave_tui.rs"]
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
@@ -40,7 +39,7 @@ trace-tracing = ["dep:tracing"]
|
|||||||
journal = []
|
journal = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hsmc-macros = { path = "../hsmc-macros", version = "0.4.1" }
|
hsmc-macros = { path = "../hsmc-macros", version = "0.6.0" }
|
||||||
heapless = "0.8"
|
heapless = "0.8"
|
||||||
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
|
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
|
||||||
embassy-sync = { version = "0.8", optional = true }
|
embassy-sync = { version = "0.8", optional = true }
|
||||||
@@ -53,12 +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]
|
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dev-dependencies]
|
||||||
trybuild = "1"
|
trybuild = "1"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] }
|
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]
|
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||||
iai-callgrind = "0.16"
|
gungraun = "0.18"
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "microwave"
|
name = "microwave"
|
||||||
|
|||||||
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.
|
|
||||||
+157
-129
@@ -1,176 +1,204 @@
|
|||||||
# hsmc dispatch baseline — instruction counts from iai-callgrind.
|
# hsmc dispatch baseline — instruction counts from gungraun.
|
||||||
#
|
#
|
||||||
# Current numbers reflect the codebase after the terminate-path + u64-ns
|
# ─── Harness migration (iai-callgrind 0.16 → gungraun 0.18) ──────────
|
||||||
# pass. `do_terminate()` indexes into a precomputed `__TERMINATE_DATA`
|
|
||||||
# table (one slice per starting state, root inclusive); `TimerEntry`'s
|
|
||||||
# `remaining_ns` field shrunk from u128 to u64 (~584 years of headroom),
|
|
||||||
# halving the entry size and replacing per-step u128 saturating-sub work
|
|
||||||
# with u64. The smaller machine struct improves cache density on every
|
|
||||||
# bench, even those that don't use timers.
|
|
||||||
#
|
#
|
||||||
# Re-run with:
|
# 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.
|
||||||
#
|
#
|
||||||
# cargo bench --bench dispatch
|
# Re-run: cargo bench --bench dispatch
|
||||||
|
# Diff: cargo bench --bench dispatch -- --baseline=gungraun_initial
|
||||||
|
# Snapshot: cargo bench --bench dispatch -- --save-baseline=before_<change>
|
||||||
#
|
#
|
||||||
# Named baselines under target/iai/ for diffing past changes:
|
# Numbers below are from a 6.19.14-zen kernel x86_64 box, valgrind
|
||||||
# pre_lca_fix — original O(H²) LCA contains() scan
|
# 3.25.1, gungraun 0.18.2, gungraun-runner 0.18.2, --release.
|
||||||
# before_path_precompute — post-LCA-fix, pre-precompute
|
|
||||||
# before_timer_skip — post-precompute, pre-timer-skip (runtime is_empty)
|
|
||||||
# before_dispatch_precompute — post-timer-skip, pre-dispatch-table
|
|
||||||
# before_timer_elide — post-dispatch-table, pre-codegen-elide
|
|
||||||
# before_terminate_and_timer_opts — post-codegen-elide, pre-terminate-path / u64
|
|
||||||
#
|
#
|
||||||
# To diff a future change against the current numbers:
|
# Saved baselines (all under target/gungraun/...):
|
||||||
#
|
# gungraun_initial first gungraun-harness run, post-migration
|
||||||
# cargo bench --bench dispatch -- --save-baseline=before_<your_change>
|
# after_opt2_unchecked get_unchecked for codegen table indexing
|
||||||
# <change>
|
# after_opt4_timer_inline pop_expired_nonempty inline (current numbers)
|
||||||
# cargo bench --bench dispatch -- --baseline=before_<your_change>
|
# before_more_opts alias for after_opt4_timer_inline (sweep base)
|
||||||
#
|
|
||||||
# Numbers below are from a 6.19.14-zen kernel x86_64 box, valgrind 3.25.1,
|
|
||||||
# iai-callgrind 0.16.1, --release.
|
|
||||||
|
|
||||||
dispatch::dispatch::h2_cold_start cold:setup_h2_cold()
|
dispatch::dispatch::h2_cold_start cold:setup_h2_cold()
|
||||||
Instructions: 38
|
Instructions: 24
|
||||||
Estimated Cycles: 201
|
L1 Hits: 39
|
||||||
|
RAM Hits: 3
|
||||||
|
Estimated Cycles: 144
|
||||||
|
|
||||||
dispatch::dispatch::h2_warm_lateral warm:setup_h2_warm()
|
dispatch::dispatch::h2_warm_lateral warm:setup_h2_warm()
|
||||||
Instructions: 84
|
Instructions: 53
|
||||||
Estimated Cycles: 397
|
L1 Hits: 77
|
||||||
|
RAM Hits: 4
|
||||||
|
Estimated Cycles: 217
|
||||||
|
|
||||||
dispatch::dispatch::h4_cross_tree cross:setup_h4_warm()
|
dispatch::dispatch::h4_cross_tree cross:setup_h4_warm()
|
||||||
Instructions: 101
|
Instructions: 93
|
||||||
Estimated Cycles: 456
|
L1 Hits: 130
|
||||||
|
RAM Hits: 10
|
||||||
|
Estimated Cycles: 480
|
||||||
|
|
||||||
dispatch::dispatch::h4_up up:setup_h4_warm()
|
dispatch::dispatch::h4_up up:setup_h4_warm()
|
||||||
Instructions: 98
|
Instructions: 88
|
||||||
Estimated Cycles: 384
|
L1 Hits: 125
|
||||||
|
RAM Hits: 8
|
||||||
|
Estimated Cycles: 405
|
||||||
|
|
||||||
dispatch::dispatch::h4_self self_:setup_h4_warm()
|
dispatch::dispatch::h4_self self_:setup_h4_warm()
|
||||||
Instructions: 101
|
Instructions: 93
|
||||||
Estimated Cycles: 456
|
L1 Hits: 130
|
||||||
|
RAM Hits: 10
|
||||||
|
Estimated Cycles: 480
|
||||||
|
|
||||||
dispatch::dispatch::h8_cross_tree cross:setup_h8_warm()
|
dispatch::dispatch::h8_cross_tree cross:setup_h8_warm()
|
||||||
Instructions: 93
|
Instructions: 53
|
||||||
Estimated Cycles: 443
|
L1 Hits: 76
|
||||||
|
RAM Hits: 5
|
||||||
|
Estimated Cycles: 251
|
||||||
|
|
||||||
dispatch::dispatch::bubble_leaf leaf:setup_bubble_warm()
|
dispatch::dispatch::bubble_leaf leaf:setup_bubble_warm()
|
||||||
Instructions: 99
|
Instructions: 94
|
||||||
Estimated Cycles: 453
|
L1 Hits: 132
|
||||||
|
RAM Hits: 11
|
||||||
|
Estimated Cycles: 517
|
||||||
|
|
||||||
dispatch::dispatch::bubble_mid mid:setup_bubble_warm()
|
dispatch::dispatch::bubble_mid mid:setup_bubble_warm()
|
||||||
Instructions: 99
|
Instructions: 94
|
||||||
Estimated Cycles: 453
|
L1 Hits: 133
|
||||||
|
RAM Hits: 10
|
||||||
|
Estimated Cycles: 483
|
||||||
|
|
||||||
dispatch::dispatch::bubble_top top:setup_bubble_warm()
|
dispatch::dispatch::bubble_top top:setup_bubble_warm()
|
||||||
Instructions: 99
|
Instructions: 93
|
||||||
Estimated Cycles: 453
|
L1 Hits: 132
|
||||||
|
RAM Hits: 10
|
||||||
|
Estimated Cycles: 482
|
||||||
|
|
||||||
dispatch::dispatch::timer_fire_dispatch fire:setup_timer_fire_armed()
|
dispatch::dispatch::timer_fire_dispatch fire:setup_timer_fire_armed()
|
||||||
Instructions: 191
|
Instructions: 175
|
||||||
Estimated Cycles: 706
|
L1 Hits: 237
|
||||||
|
RAM Hits: 7
|
||||||
|
Estimated Cycles: 482
|
||||||
|
|
||||||
dispatch::dispatch::terminate_event quit:setup_term_warm()
|
dispatch::dispatch::terminate_event quit:setup_term_warm()
|
||||||
Instructions: 69
|
Instructions: 53
|
||||||
Estimated Cycles: 311
|
L1 Hits: 81
|
||||||
|
RAM Hits: 5
|
||||||
|
Estimated Cycles: 256
|
||||||
|
|
||||||
dispatch::dispatch::bubble_cross_tree switch:setup_bubble_cross_warm()
|
dispatch::dispatch::bubble_cross_tree switch:setup_bubble_cross_warm()
|
||||||
Instructions: 94
|
Instructions: 85
|
||||||
Estimated Cycles: 444
|
L1 Hits: 116
|
||||||
|
RAM Hits: 11
|
||||||
|
Estimated Cycles: 501
|
||||||
|
|
||||||
dispatch::dispatch::chain_self_emit chain:setup_chain_warm()
|
dispatch::dispatch::chain_self_emit chain:setup_chain_warm()
|
||||||
Instructions: 419
|
Instructions: 439
|
||||||
Estimated Cycles: 1301
|
L1 Hits: 613
|
||||||
|
RAM Hits: 17
|
||||||
|
Estimated Cycles: 1213
|
||||||
|
|
||||||
# ─── LCA O(H) fix delta (pre_lca_fix → before_path_precompute) ──────
|
# ─── Migration deltas (gungraun_initial → current) ──────────────────
|
||||||
#
|
#
|
||||||
# Bench Instr Δ Cycle Δ
|
# Two optimization passes landed under gungraun:
|
||||||
# h2_warm_lateral -22.8% -22.1%
|
|
||||||
# h4_cross_tree -25.9% -10.6%
|
|
||||||
# h4_self -21.5% -11.7%
|
|
||||||
# h8_cross_tree -46.2% -41.0% ← deepest LCA stress
|
|
||||||
# chain_self_emit -15.4% -10.4%
|
|
||||||
#
|
#
|
||||||
# ─── Precompute-paths delta (before_path_precompute → before_timer_skip) ──
|
# 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%
|
||||||
#
|
#
|
||||||
# Bench Instr Δ Cycle Δ
|
# Opt #4: TimerTable::pop_expired_nonempty inline
|
||||||
# h2_warm_lateral -19.5% -4.8%
|
# Drop #[cold], add #[inline]. The cold hint was forcing the body
|
||||||
# h4_cross_tree -45.0% -28.6%
|
# out-of-line at every step()-with-timers call site, paying full
|
||||||
# h8_cross_tree -48.6% -34.8%
|
# x86_64 ABI prologue/epilogue (~10-15 instr) per call. Inlining
|
||||||
# chain_self_emit -28.1% -14.6%
|
# 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)
|
||||||
#
|
#
|
||||||
# ─── Timer empty-skip delta (before_timer_skip → before_dispatch_precompute) ──
|
# Tried but reverted:
|
||||||
#
|
#
|
||||||
# Bench Instr Δ Cycle Δ
|
# Opt #1: current: Option<u16> → u16 with u16::MAX sentinel.
|
||||||
# h2_warm_lateral -30.8% -35.1%
|
# Theory: drop the discriminant byte, single-load is_none-check
|
||||||
# h4_cross_tree -38.4% -35.4%
|
# vs tag-then-value, one-write state-set. In practice net -9 instr
|
||||||
# h8_cross_tree -54.4% -52.9%
|
# across all 13 benches (-0.6%): real wins on terminate_event (-6)
|
||||||
# chain_self_emit -27.9% -29.2%
|
# 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.
|
||||||
#
|
#
|
||||||
# ─── Dispatch-table delta (before_dispatch_precompute → before_timer_elide) ──
|
# 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.
|
||||||
#
|
#
|
||||||
# Bench Instr Δ Cycle Δ
|
# ─── Historical context (iai-callgrind era, NOT comparable) ─────────
|
||||||
# bubble_mid -19.4% -4.7% 3-hop __PARENT walk → table lookup
|
|
||||||
# bubble_top -22.3% -5.8% 4-hop walk → table lookup
|
|
||||||
# h8_cross_tree +4.2% +15.8% cold-cache miss on __DISPATCH (warm-cache neutral)
|
|
||||||
#
|
#
|
||||||
# ─── Timer-elide delta (before_timer_elide → before_terminate_and_timer_opts) ──
|
# 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:
|
||||||
#
|
#
|
||||||
# Bench Instr Δ Cycle Δ
|
# 1. LCA contains() → O(H) ancestor-walk
|
||||||
# h2_warm_lateral -26.9% -9.7%
|
# 2. Per-state precomputed exit/enter path tables
|
||||||
# h4_cross_tree -22.2% -20.1%
|
# 3. Skip empty timer scan in step()
|
||||||
# h8_cross_tree -24.2% -20.8%
|
# 4. Per-event handler dispatch table (was __PARENT bubble walk)
|
||||||
# bubble_top -22.1% -23.2%
|
# 5. Codegen-elide timer machinery for charts with no `after()` triggers
|
||||||
# chain_self_emit -18.0% -8.4%
|
# 6. Precomputed __TERMINATE_DATA + queue.clear() + u128→u64 nanoseconds
|
||||||
#
|
#
|
||||||
# ─── Terminate-path + u64-ns delta (before_terminate_and_timer_opts → current) ──
|
# 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):
|
||||||
#
|
#
|
||||||
# Bench Instr Δ Cycle Δ Notes
|
# h2_cold_start 38 instr 201 cycles
|
||||||
# h2_cold_start -2.6% -14.1% smaller machine struct, tighter cache
|
# h2_warm_lateral 84 instr 397 cycles
|
||||||
# h2_warm_lateral -3.4% -21.2% (RAM hits 11→8)
|
# h4_cross_tree 101 instr 456 cycles
|
||||||
# h4_cross_tree -3.8% -1.7%
|
# h4_up 98 instr 384 cycles
|
||||||
# h4_up -3.9% -22.3% (RAM hits 10→7)
|
# h4_self 101 instr 456 cycles
|
||||||
# h4_self -3.8% -8.4%
|
# h8_cross_tree 93 instr 443 cycles
|
||||||
# h8_cross_tree -1.1% -0.7%
|
# bubble_leaf/mid/top 99 instr 453 cycles
|
||||||
# bubble_leaf -6.6% -9.6%
|
# timer_fire_dispatch 191 instr 706 cycles
|
||||||
# bubble_mid -6.6% -3.0%
|
# terminate_event 69 instr 311 cycles
|
||||||
# bubble_top -6.6% -9.6%
|
# bubble_cross_tree 94 instr 444 cycles
|
||||||
# timer_fire_dispatch +2.7% -12.6% smaller TimerEntry → -3 RAM hits
|
# chain_self_emit 419 instr 1301 cycles
|
||||||
# terminate_event -22.5% -23.4% ← headline: precomputed exit path + queue.clear()
|
|
||||||
# bubble_cross_tree -2.1% -0.9%
|
|
||||||
# chain_self_emit -0.9% -3.1%
|
|
||||||
#
|
#
|
||||||
# Two changes in this pass:
|
# Bench coverage:
|
||||||
#
|
#
|
||||||
# (1) `__TERMINATE_DATA` / `__TERMINATE_RANGE` — per-state precomputed
|
# `timer_fire_dispatch` — only bench that exercises pop_expired's
|
||||||
# exit chain up to and including __Root. `do_terminate()` indexes the
|
# non-empty branch and `dispatch_trigger`.
|
||||||
# table once and iterates the slice, replacing the runtime `__PARENT`
|
# `terminate_event` — only bench that exercises `__check_terminate`'s
|
||||||
# walk + per-iteration match. Also swapped the queue-drain loop
|
# true arm and `do_terminate`.
|
||||||
# (`while self.queue.pop_front().is_some() {}`) for `self.queue.clear()`,
|
# `bubble_cross_tree` — handler on L1 (4 bubble hops) firing a
|
||||||
# which heapless 0.8 implements as O(1) (resets indices, no per-element
|
# 10-state cross-tree transition. Realistic HSM worst case.
|
||||||
# pop). Together: -22% instr / -23% cycles on terminate_event, the
|
|
||||||
# headline win the user asked for.
|
|
||||||
#
|
#
|
||||||
# (2) `TimerEntry.remaining_ns: u128 → u64`. Internal-only field type
|
# ─── Measurement floor ───────────────────────────────────────────────
|
||||||
# change. u64 nanoseconds bounds at ~584 years — past any realistic
|
|
||||||
# Duration. Eliminates u128 saturating-sub in `decrement()` (4-6 instr
|
|
||||||
# → 1-2 instr per entry per step) and the manual secs/nanos
|
|
||||||
# decomposition in `min_remaining()` (replaced with
|
|
||||||
# `Duration::from_nanos(u64)`). The struct shrinks 32 B → 16 B with
|
|
||||||
# alignment, so the entire machine struct is smaller — even no-timer
|
|
||||||
# benches see RAM-hit reductions from tighter cache packing.
|
|
||||||
#
|
#
|
||||||
# Net across the six optimization passes (pre_lca_fix → current):
|
# After Opt #2, the gungraun bench-harness wrapper is the dominant
|
||||||
# h8_cross_tree dispatch is **-82% instructions / -72% cycles** vs the
|
# fixed cost on simple benches (~28-49 instr per bench). On
|
||||||
# original O(H²) LCA scan. terminate_event is now 69 instr / 311 cycles
|
# h2_cold_start and h8_cross_tree, the entire bench body is inlined
|
||||||
# — about 80% of that is the iai-callgrind wrapper, leaving ~14 instr
|
# into the wrapper — at 24 and 53 instructions respectively, you are
|
||||||
# of actual HSM termination work for a 3-deep chart.
|
# 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
|
||||||
# ─── Bench coverage (3 benches added in the previous pass) ──────────
|
# 20% of the HSM-attributable cost, not 5/53 = 9%.
|
||||||
#
|
|
||||||
# `timer_fire_dispatch` (191 instr / 706 cycles) — only bench that
|
|
||||||
# exercises pop_expired's non-empty branch and `dispatch_trigger`.
|
|
||||||
# `terminate_event` (69 instr / 311 cycles) — only bench that
|
|
||||||
# exercises `__check_terminate`'s true arm and `do_terminate`.
|
|
||||||
# `bubble_cross_tree` (94 instr / 444 cycles) — handler on L1 (4
|
|
||||||
# bubble hops) firing a 10-state cross-tree transition. Realistic
|
|
||||||
# HSM worst case.
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Deterministic instruction-count benches for the dispatch hot path.
|
//! Deterministic instruction-count benches for the dispatch hot path.
|
||||||
//!
|
//!
|
||||||
//! Run with `cargo bench --bench dispatch` (Linux only — uses
|
//! Run with `cargo bench --bench dispatch` (Linux only — uses
|
||||||
//! iai-callgrind + valgrind).
|
//! gungraun + valgrind).
|
||||||
//!
|
//!
|
||||||
//! Each bench measures one dispatch kind in isolation:
|
//! Each bench measures one dispatch kind in isolation:
|
||||||
//! - `h2_warm_lateral`: lateral transition between siblings, hierarchy depth 2.
|
//! - `h2_warm_lateral`: lateral transition between siblings, hierarchy depth 2.
|
||||||
@@ -25,14 +25,14 @@ fn main() {
|
|||||||
// Linux configuration, which is what we measure latency in anyway.
|
// Linux configuration, which is what we measure latency in anyway.
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"hsmc dispatch benches require Linux + default features \
|
"hsmc dispatch benches require Linux + default features \
|
||||||
(iai-callgrind + valgrind, sync action impls)."
|
(gungraun + valgrind, sync action impls)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
|
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
|
||||||
use hsmc::{statechart, Duration};
|
use gungraun::{library_benchmark, library_benchmark_group, main};
|
||||||
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
|
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
|
||||||
use iai_callgrind::{library_benchmark, library_benchmark_group, main};
|
use hsmc::{statechart, Duration};
|
||||||
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
|
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
|
||||||
use std::hint::black_box;
|
use std::hint::black_box;
|
||||||
|
|
||||||
@@ -410,7 +410,7 @@ mod bubble_cross {
|
|||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// Setup helpers. Prime each chart to its measurement starting state
|
// Setup helpers. Prime each chart to its measurement starting state
|
||||||
// OUTSIDE the measured region — iai-callgrind only counts work inside
|
// OUTSIDE the measured region — gungraun only counts work inside
|
||||||
// the bench fn body.
|
// the bench fn body.
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-2
@@ -56,8 +56,10 @@ pub enum TransitionReason {
|
|||||||
/// fired this transition.
|
/// fired this transition.
|
||||||
Timer { state: u16, timer: u16 },
|
Timer { state: u16, timer: u16 },
|
||||||
/// Driven from inside the runtime without an originating event or
|
/// Driven from inside the runtime without an originating event or
|
||||||
/// timer (e.g. default-descent into a child after entering a
|
/// timer. Emitted for `default(...)` firings: after a state with a
|
||||||
/// composite state). Reserved; not currently emitted.
|
/// 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,
|
Internal,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -895,7 +895,7 @@ pub mod __private {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cold]
|
#[inline]
|
||||||
fn pop_expired_nonempty(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
|
fn pop_expired_nonempty(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
|
||||||
let mut best: Option<usize> = None;
|
let mut best: Option<usize> = None;
|
||||||
for (i, e) in self.entries.iter().enumerate() {
|
for (i, e) in self.entries.iter().enumerate() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -106,7 +106,9 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
|
|||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn det_descent_target_with_three_default_levels() {
|
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()
|
tokio::task::LocalSet::new()
|
||||||
.run_until(async {
|
.run_until(async {
|
||||||
let mut m = Desc::new(Ctx);
|
let mut m = Desc::new(Ctx);
|
||||||
@@ -119,9 +121,19 @@ async fn det_descent_target_with_three_default_levels() {
|
|||||||
},
|
},
|
||||||
TraceEvent::EnterBegan { state: SR },
|
TraceEvent::EnterBegan { state: SR },
|
||||||
TraceEvent::Entered { state: SR },
|
TraceEvent::Entered { state: SR },
|
||||||
|
// Root → Idle fires as Root's default.
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
from: Some(SR),
|
||||||
|
to: SI,
|
||||||
|
reason: TransitionReason::Internal,
|
||||||
|
},
|
||||||
TraceEvent::EnterBegan { state: SI },
|
TraceEvent::EnterBegan { state: SI },
|
||||||
entry(SI, A_IDLE_IN),
|
entry(SI, A_IDLE_IN),
|
||||||
TraceEvent::Entered { state: SI },
|
TraceEvent::Entered { state: SI },
|
||||||
|
TraceEvent::TransitionComplete {
|
||||||
|
from: Some(SR),
|
||||||
|
to: SI,
|
||||||
|
},
|
||||||
TraceEvent::EventReceived { event: E_GO },
|
TraceEvent::EventReceived { event: E_GO },
|
||||||
TraceEvent::EventDelivered {
|
TraceEvent::EventDelivered {
|
||||||
handler_state: SI,
|
handler_state: SI,
|
||||||
@@ -138,15 +150,45 @@ async fn det_descent_target_with_three_default_levels() {
|
|||||||
TraceEvent::EnterBegan { state: ST },
|
TraceEvent::EnterBegan { state: ST },
|
||||||
entry(ST, A_TOWER_IN),
|
entry(ST, A_TOWER_IN),
|
||||||
TraceEvent::Entered { state: ST },
|
TraceEvent::Entered { state: ST },
|
||||||
|
// Tower's default(L1) fires.
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
from: Some(ST),
|
||||||
|
to: SL1,
|
||||||
|
reason: TransitionReason::Internal,
|
||||||
|
},
|
||||||
TraceEvent::EnterBegan { state: SL1 },
|
TraceEvent::EnterBegan { state: SL1 },
|
||||||
entry(SL1, A_L1_IN),
|
entry(SL1, A_L1_IN),
|
||||||
TraceEvent::Entered { state: SL1 },
|
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 },
|
TraceEvent::EnterBegan { state: SL2 },
|
||||||
entry(SL2, A_L2_IN),
|
entry(SL2, A_L2_IN),
|
||||||
TraceEvent::Entered { state: SL2 },
|
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 },
|
TraceEvent::EnterBegan { state: SL3 },
|
||||||
entry(SL3, A_L3_IN),
|
entry(SL3, A_L3_IN),
|
||||||
TraceEvent::Entered { state: SL3 },
|
TraceEvent::Entered { state: SL3 },
|
||||||
|
TraceEvent::TransitionComplete {
|
||||||
|
from: Some(SL2),
|
||||||
|
to: SL3,
|
||||||
|
},
|
||||||
TraceEvent::TransitionComplete {
|
TraceEvent::TransitionComplete {
|
||||||
from: Some(SI),
|
from: Some(SI),
|
||||||
to: ST,
|
to: ST,
|
||||||
@@ -164,11 +206,22 @@ async fn det_descent_entries_fire_outer_to_inner() {
|
|||||||
.run_until(async {
|
.run_until(async {
|
||||||
let mut m = Desc::new(Ctx);
|
let mut m = Desc::new(Ctx);
|
||||||
let _ = m.dispatch(Ev::Go).await;
|
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
|
let split = m
|
||||||
.journal()
|
.journal()
|
||||||
.iter()
|
.iter()
|
||||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
.position(|e| {
|
||||||
|
matches!(
|
||||||
|
e,
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
reason: TransitionReason::Event { .. },
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let entered: Vec<u16> = m.journal()[split..]
|
let entered: Vec<u16> = m.journal()[split..]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -210,10 +263,21 @@ async fn det_descent_action_kind_sequence() {
|
|||||||
.run_until(async {
|
.run_until(async {
|
||||||
let mut m = Desc::new(Ctx);
|
let mut m = Desc::new(Ctx);
|
||||||
let _ = m.dispatch(Ev::Go).await;
|
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
|
let split = m
|
||||||
.journal()
|
.journal()
|
||||||
.iter()
|
.iter()
|
||||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
.position(|e| {
|
||||||
|
matches!(
|
||||||
|
e,
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
reason: TransitionReason::Event { .. },
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// After the Idle exit, all subsequent ActionInvoked must be Entry kind.
|
// After the Idle exit, all subsequent ActionInvoked must be Entry kind.
|
||||||
let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..]
|
let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||||
#![allow(unexpected_cfgs)]
|
#![allow(unexpected_cfgs)]
|
||||||
|
|
||||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Ctx;
|
pub struct Ctx;
|
||||||
@@ -124,24 +124,63 @@ async fn det_initial_descent_records_full_path() {
|
|||||||
let actual = m.take_journal();
|
let actual = m.take_journal();
|
||||||
let expected = vec![
|
let expected = vec![
|
||||||
// Initial enter (driven by dispatch's prime-state branch).
|
// 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 {
|
TraceEvent::Started {
|
||||||
chart_hash: Init4::<8>::CHART_HASH,
|
chart_hash: Init4::<8>::CHART_HASH,
|
||||||
},
|
},
|
||||||
TraceEvent::EnterBegan { state: SR },
|
TraceEvent::EnterBegan { state: SR },
|
||||||
entry(SR, A_R_IN),
|
entry(SR, A_R_IN),
|
||||||
TraceEvent::Entered { state: SR },
|
TraceEvent::Entered { state: SR },
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
from: Some(SR),
|
||||||
|
to: SA,
|
||||||
|
reason: TransitionReason::Internal,
|
||||||
|
},
|
||||||
TraceEvent::EnterBegan { state: SA },
|
TraceEvent::EnterBegan { state: SA },
|
||||||
entry(SA, A_A_IN),
|
entry(SA, A_A_IN),
|
||||||
TraceEvent::Entered { state: SA },
|
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 },
|
TraceEvent::EnterBegan { state: SB },
|
||||||
entry(SB, A_B_IN),
|
entry(SB, A_B_IN),
|
||||||
TraceEvent::Entered { state: SB },
|
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 },
|
TraceEvent::EnterBegan { state: SC },
|
||||||
entry(SC, A_C_IN),
|
entry(SC, A_C_IN),
|
||||||
TraceEvent::Entered { state: SC },
|
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 },
|
TraceEvent::EnterBegan { state: SD },
|
||||||
entry(SD, A_D_IN),
|
entry(SD, A_D_IN),
|
||||||
TraceEvent::Entered { state: SD },
|
TraceEvent::Entered { state: SD },
|
||||||
|
TraceEvent::TransitionComplete {
|
||||||
|
from: Some(SC),
|
||||||
|
to: SD,
|
||||||
|
},
|
||||||
// Halt.
|
// Halt.
|
||||||
TraceEvent::EventReceived { event: E_HALT },
|
TraceEvent::EventReceived { event: E_HALT },
|
||||||
TraceEvent::TerminateRequested { event: E_HALT },
|
TraceEvent::TerminateRequested { event: E_HALT },
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ 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> {
|
fn initial_descent() -> Vec<TraceEvent> {
|
||||||
vec![
|
vec![
|
||||||
TraceEvent::Started {
|
TraceEvent::Started {
|
||||||
@@ -148,12 +149,30 @@ fn initial_descent() -> Vec<TraceEvent> {
|
|||||||
},
|
},
|
||||||
TraceEvent::EnterBegan { state: SR },
|
TraceEvent::EnterBegan { state: SR },
|
||||||
TraceEvent::Entered { state: SR },
|
TraceEvent::Entered { state: SR },
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
from: Some(SR),
|
||||||
|
to: SP,
|
||||||
|
reason: TransitionReason::Internal,
|
||||||
|
},
|
||||||
TraceEvent::EnterBegan { state: SP },
|
TraceEvent::EnterBegan { state: SP },
|
||||||
entry(SP, A_P_IN),
|
entry(SP, A_P_IN),
|
||||||
TraceEvent::Entered { state: SP },
|
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 },
|
TraceEvent::EnterBegan { state: SA },
|
||||||
entry(SA, A_A_IN),
|
entry(SA, A_A_IN),
|
||||||
TraceEvent::Entered { state: SA },
|
TraceEvent::Entered { state: SA },
|
||||||
|
TraceEvent::TransitionComplete {
|
||||||
|
from: Some(SP),
|
||||||
|
to: SA,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,9 +304,19 @@ async fn det_lateral_target_with_children_default_descends() {
|
|||||||
TraceEvent::EnterBegan { state: SD },
|
TraceEvent::EnterBegan { state: SD },
|
||||||
entry(SD, A_D_IN),
|
entry(SD, A_D_IN),
|
||||||
TraceEvent::Entered { state: SD },
|
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 },
|
TraceEvent::EnterBegan { state: SE },
|
||||||
entry(SE, A_E_IN),
|
entry(SE, A_E_IN),
|
||||||
TraceEvent::Entered { state: SE },
|
TraceEvent::Entered { state: SE },
|
||||||
|
TraceEvent::TransitionComplete {
|
||||||
|
from: Some(SD),
|
||||||
|
to: SE,
|
||||||
|
},
|
||||||
TraceEvent::TransitionComplete {
|
TraceEvent::TransitionComplete {
|
||||||
from: Some(SA),
|
from: Some(SA),
|
||||||
to: SD,
|
to: SD,
|
||||||
@@ -471,7 +500,15 @@ async fn det_lateral_entry_outer_then_inner() {
|
|||||||
let entries: Vec<u16> = m
|
let entries: Vec<u16> = m
|
||||||
.journal()
|
.journal()
|
||||||
.iter()
|
.iter()
|
||||||
.skip_while(|e| !matches!(e, TraceEvent::TransitionFired { .. }))
|
.skip_while(|e| {
|
||||||
|
!matches!(
|
||||||
|
e,
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
reason: TransitionReason::Event { .. },
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
.filter_map(|e| match e {
|
.filter_map(|e| match e {
|
||||||
TraceEvent::Entered { state } => Some(*state),
|
TraceEvent::Entered { state } => Some(*state),
|
||||||
_ => None,
|
_ => 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"))]
|
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||||
#![allow(unexpected_cfgs)]
|
#![allow(unexpected_cfgs)]
|
||||||
|
|
||||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Ctx;
|
pub struct Ctx;
|
||||||
@@ -293,10 +293,19 @@ async fn det_ord_full_transition_canonical_sequence() {
|
|||||||
let mut m = Ord::new(Ctx);
|
let mut m = Ord::new(Ctx);
|
||||||
let _ = m.dispatch(Ev::Go).await;
|
let _ = m.dispatch(Ev::Go).await;
|
||||||
let j = m.take_journal();
|
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
|
let trans_idx = j
|
||||||
.iter()
|
.iter()
|
||||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
.position(|e| {
|
||||||
|
matches!(
|
||||||
|
e,
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
reason: TransitionReason::Event { .. },
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let kinds: Vec<ActionKind> = j[trans_idx..]
|
let kinds: Vec<ActionKind> = j[trans_idx..]
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -104,12 +104,32 @@ async fn det_root_targetable_by_chart_name() {
|
|||||||
TraceEvent::EnterBegan { state: SR },
|
TraceEvent::EnterBegan { state: SR },
|
||||||
entry(SR, A_R_IN),
|
entry(SR, A_R_IN),
|
||||||
TraceEvent::Entered { state: SR },
|
TraceEvent::Entered { state: SR },
|
||||||
|
// Root's `default(A)` fires.
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
from: Some(SR),
|
||||||
|
to: SA,
|
||||||
|
reason: TransitionReason::Internal,
|
||||||
|
},
|
||||||
TraceEvent::EnterBegan { state: SA },
|
TraceEvent::EnterBegan { state: SA },
|
||||||
entry(SA, A_A_IN),
|
entry(SA, A_A_IN),
|
||||||
TraceEvent::Entered { state: SA },
|
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 },
|
TraceEvent::EnterBegan { state: SB },
|
||||||
entry(SB, A_B_IN),
|
entry(SB, A_B_IN),
|
||||||
TraceEvent::Entered { state: SB },
|
TraceEvent::Entered { state: SB },
|
||||||
|
TraceEvent::TransitionComplete {
|
||||||
|
from: Some(SA),
|
||||||
|
to: SB,
|
||||||
|
},
|
||||||
TraceEvent::EventReceived { event: E_UPTOROOT },
|
TraceEvent::EventReceived { event: E_UPTOROOT },
|
||||||
TraceEvent::EventDelivered {
|
TraceEvent::EventDelivered {
|
||||||
handler_state: SB,
|
handler_state: SB,
|
||||||
@@ -148,10 +168,21 @@ async fn det_root_target_does_not_re_enter_root() {
|
|||||||
let mut m = Sub::new(Ctx);
|
let mut m = Sub::new(Ctx);
|
||||||
let _ = m.dispatch(Ev::UpToRoot).await;
|
let _ = m.dispatch(Ev::UpToRoot).await;
|
||||||
let j = m.take_journal();
|
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
|
let split = j
|
||||||
.iter()
|
.iter()
|
||||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
.position(|e| {
|
||||||
|
matches!(
|
||||||
|
e,
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
reason: TransitionReason::Event { .. },
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let entered_after = j[split..]
|
let entered_after = j[split..]
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -88,12 +88,30 @@ fn initial_descent() -> Vec<TraceEvent> {
|
|||||||
},
|
},
|
||||||
TraceEvent::EnterBegan { state: SR },
|
TraceEvent::EnterBegan { state: SR },
|
||||||
TraceEvent::Entered { state: SR },
|
TraceEvent::Entered { state: SR },
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
from: Some(SR),
|
||||||
|
to: SM,
|
||||||
|
reason: TransitionReason::Internal,
|
||||||
|
},
|
||||||
TraceEvent::EnterBegan { state: SM },
|
TraceEvent::EnterBegan { state: SM },
|
||||||
entry(SM, A_M_IN),
|
entry(SM, A_M_IN),
|
||||||
TraceEvent::Entered { state: SM },
|
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 },
|
TraceEvent::EnterBegan { state: SL },
|
||||||
entry(SL, A_L_IN),
|
entry(SL, A_L_IN),
|
||||||
TraceEvent::Entered { state: SL },
|
TraceEvent::Entered { state: SL },
|
||||||
|
TraceEvent::TransitionComplete {
|
||||||
|
from: Some(SM),
|
||||||
|
to: SL,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||||
|
|||||||
+49
-1
@@ -113,6 +113,8 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn initial_descent() -> Vec<TraceEvent> {
|
fn initial_descent() -> Vec<TraceEvent> {
|
||||||
|
// Each `default(...)` fires as a TransitionFired{Internal} /
|
||||||
|
// TransitionComplete pair around the entered state.
|
||||||
vec![
|
vec![
|
||||||
TraceEvent::Started {
|
TraceEvent::Started {
|
||||||
chart_hash: Up::<8>::CHART_HASH,
|
chart_hash: Up::<8>::CHART_HASH,
|
||||||
@@ -120,18 +122,54 @@ fn initial_descent() -> Vec<TraceEvent> {
|
|||||||
TraceEvent::EnterBegan { state: SR },
|
TraceEvent::EnterBegan { state: SR },
|
||||||
entry(SR, A_R_IN),
|
entry(SR, A_R_IN),
|
||||||
TraceEvent::Entered { state: SR },
|
TraceEvent::Entered { state: SR },
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
from: Some(SR),
|
||||||
|
to: SA,
|
||||||
|
reason: TransitionReason::Internal,
|
||||||
|
},
|
||||||
TraceEvent::EnterBegan { state: SA },
|
TraceEvent::EnterBegan { state: SA },
|
||||||
entry(SA, A_A_IN),
|
entry(SA, A_A_IN),
|
||||||
TraceEvent::Entered { state: SA },
|
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 },
|
TraceEvent::EnterBegan { state: SB },
|
||||||
entry(SB, A_B_IN),
|
entry(SB, A_B_IN),
|
||||||
TraceEvent::Entered { state: SB },
|
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 },
|
TraceEvent::EnterBegan { state: SC },
|
||||||
entry(SC, A_C_IN),
|
entry(SC, A_C_IN),
|
||||||
TraceEvent::Entered { state: SC },
|
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 },
|
TraceEvent::EnterBegan { state: SD },
|
||||||
entry(SD, A_D_IN),
|
entry(SD, A_D_IN),
|
||||||
TraceEvent::Entered { state: SD },
|
TraceEvent::Entered { state: SD },
|
||||||
|
TraceEvent::TransitionComplete {
|
||||||
|
from: Some(SC),
|
||||||
|
to: SD,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||||
@@ -361,9 +399,19 @@ async fn det_up_current_state_is_target_after() {
|
|||||||
.rev()
|
.rev()
|
||||||
.find(|(_, e)| matches!(e, TraceEvent::Entered { .. }))
|
.find(|(_, e)| matches!(e, TraceEvent::Entered { .. }))
|
||||||
.map(|(i, _)| i);
|
.map(|(i, _)| i);
|
||||||
|
// Find the EVENT-driven UpToB transition (initial defaults
|
||||||
|
// emit TransitionFired{Internal} too).
|
||||||
let split = j
|
let split = j
|
||||||
.iter()
|
.iter()
|
||||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
.position(|e| {
|
||||||
|
matches!(
|
||||||
|
e,
|
||||||
|
TraceEvent::TransitionFired {
|
||||||
|
reason: TransitionReason::Event { .. },
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// The last Entered event must be BEFORE the transition.
|
// The last Entered event must be BEFORE the transition.
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ async fn replay_full_lifecycle_matches_expected() {
|
|||||||
kind: ActionKind::Entry,
|
kind: ActionKind::Entry,
|
||||||
},
|
},
|
||||||
TraceEvent::Entered { state: ST_ROOT },
|
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::EnterBegan { state: ST_IDLE },
|
||||||
TraceEvent::ActionInvoked {
|
TraceEvent::ActionInvoked {
|
||||||
state: ST_IDLE,
|
state: ST_IDLE,
|
||||||
@@ -111,6 +117,10 @@ async fn replay_full_lifecycle_matches_expected() {
|
|||||||
kind: ActionKind::Entry,
|
kind: ActionKind::Entry,
|
||||||
},
|
},
|
||||||
TraceEvent::Entered { state: ST_IDLE },
|
TraceEvent::Entered { state: ST_IDLE },
|
||||||
|
TraceEvent::TransitionComplete {
|
||||||
|
from: Some(ST_ROOT),
|
||||||
|
to: ST_IDLE,
|
||||||
|
},
|
||||||
// ── Go event delivered to Idle, transition to Active → Sub ──
|
// ── Go event delivered to Idle, transition to Active → Sub ──
|
||||||
TraceEvent::EventReceived { event: E_GO },
|
TraceEvent::EventReceived { event: E_GO },
|
||||||
TraceEvent::EventDelivered {
|
TraceEvent::EventDelivered {
|
||||||
@@ -136,6 +146,13 @@ async fn replay_full_lifecycle_matches_expected() {
|
|||||||
kind: ActionKind::Entry,
|
kind: ActionKind::Entry,
|
||||||
},
|
},
|
||||||
TraceEvent::Entered { state: ST_ACTIVE },
|
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::EnterBegan { state: ST_SUB },
|
||||||
TraceEvent::ActionInvoked {
|
TraceEvent::ActionInvoked {
|
||||||
state: ST_SUB,
|
state: ST_SUB,
|
||||||
@@ -143,6 +160,10 @@ async fn replay_full_lifecycle_matches_expected() {
|
|||||||
kind: ActionKind::Entry,
|
kind: ActionKind::Entry,
|
||||||
},
|
},
|
||||||
TraceEvent::Entered { state: ST_SUB },
|
TraceEvent::Entered { state: ST_SUB },
|
||||||
|
TraceEvent::TransitionComplete {
|
||||||
|
from: Some(ST_ACTIVE),
|
||||||
|
to: ST_SUB,
|
||||||
|
},
|
||||||
TraceEvent::TransitionComplete {
|
TraceEvent::TransitionComplete {
|
||||||
from: Some(ST_IDLE),
|
from: Some(ST_IDLE),
|
||||||
to: ST_ACTIVE,
|
to: ST_ACTIVE,
|
||||||
|
|||||||
@@ -151,6 +151,42 @@ audit:
|
|||||||
cargo audit
|
cargo audit
|
||||||
@echo "✅ Security checks passed"
|
@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).
|
# Run Miri for UB detection (auto-installs nightly + miri component if missing).
|
||||||
[script]
|
[script]
|
||||||
miri:
|
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" }
|
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 subcommands the justfile drives ───────────────────────────
|
||||||
"cargo:cargo-nextest" = "latest" # `just test`, `just dev`
|
"cargo:cargo-nextest" = "latest" # `just test`, `just dev`
|
||||||
"cargo:cargo-llvm-cov" = "latest" # `just coverage`
|
"cargo:cargo-llvm-cov" = "latest" # `just coverage`
|
||||||
"cargo:cargo-mutants" = "latest" # `just mutants`
|
"cargo:cargo-mutants" = "latest" # `just mutants`
|
||||||
"cargo:cargo-audit" = "latest" # `just audit`
|
"cargo:cargo-audit" = "latest" # `just audit`
|
||||||
|
"cargo:gungraun-runner" = "latest" # `cargo bench --bench dispatch` (valgrind harness)
|
||||||
|
|
||||||
# ── Build orchestration ─────────────────────────────────────────────
|
# ── Build orchestration ─────────────────────────────────────────────
|
||||||
just = "latest"
|
just = "latest"
|
||||||
|
|||||||
@@ -22,6 +22,43 @@ columns track Creusot proof state.
|
|||||||
| S6.CLEAR | `clear()` makes the view empty | `EventQueue::clear` | 🟡 |
|
| S6.CLEAR | `clear()` makes the view empty | `EventQueue::clear` | 🟡 |
|
||||||
| S6.LEN | `len()` returns the view length | `EventQueue::len` | 🟡 |
|
| 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`)
|
## TimerTable (`src/timer_table.rs`)
|
||||||
|
|
||||||
| Spec rule | Statement | Function | Status |
|
| Spec rule | Statement | Function | Status |
|
||||||
@@ -41,8 +78,8 @@ columns track Creusot proof state.
|
|||||||
|
|
||||||
| Item | Reason |
|
| Item | Reason |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `transition()` codegen invariants (path validity) | Requires threading Creusot attrs through the proc-macro. 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 the above; do once helpers are green. Phase 4. |
|
| `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. |
|
| Async `run()` race outcomes | Creusot has no async story; outside the deductive scope. |
|
||||||
| User action bodies | User code, not part of the runtime. |
|
| User action bodies | User code, not part of the runtime. |
|
||||||
| `duration_from_secs_f64` | Floating-point not modeled. Use `#[trusted]` + property tests. |
|
| `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.
|
// use `std::vec::Vec` (via creusot-std's specs) directly.
|
||||||
|
|
||||||
pub mod event_queue;
|
pub mod event_queue;
|
||||||
|
pub mod paths;
|
||||||
pub mod timer_table;
|
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