feat(observe)!: unify journal and trace into single observation pipeline

The codegen now emits one `__chart_observe!` invocation per observable
atom. The macro fans out at compile time to whichever sinks are enabled:
the in-memory journal (`feature = "journal"`), `defmt` (`trace-defmt`),
`log` (`trace-log`), and/or `tracing` (`trace-tracing`). Trace and
journal share the same vocabulary by construction — they cannot
diverge. With no sink feature on, every site expands to `()` (zero
overhead). Multiple trace backends now compose; previously they were
mutually exclusive.

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

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

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

Verification: 159 integration tests pass, 0 missed mutants, miri 68
tests clean, Creusot 14/14 VCs discharged. Zero overhead confirmed via
`cargo expand` — every observation site expands to `{}` with no sinks
enabled.
This commit is contained in:
swaits committed 2026-04-28 15:24:14 -06:00
1 parent a6d6cd2674
commit 4ea7463089
21 files changed
+2026 -488

No files matched your search

+92
View File
@@ -4,6 +4,98 @@ All notable changes to this workspace are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/). this project adheres to [Semantic Versioning](https://semver.org/).
## 0.4.0 — 2026-04-28
### Changed — observability (breaking)
- **Unified observation pipeline.** The codegen now emits a single
`__chart_observe!` invocation per observable atom, dispatched at
compile time to whichever sinks are enabled: the in-memory journal
(`feature = "journal"`), `defmt` (`trace-defmt`), `log`
(`trace-log`), and/or `tracing` (`trace-tracing`). All sinks share
the same vocabulary by construction — the journal IS the observation
stream; trace backends are textual renderings of it. With NO sink
feature on, every call site expands to `()` (zero overhead). Multiple
trace sinks may now be enabled simultaneously (previously they were
mutually exclusive).
- **Trace prefix changed.** `[ChartName] verb …` →
`[statechart:ChartName] verb …`. The new form is greppable and
unambiguous in mixed log streams.
- **Trace format is now logfmt-style.** Every line is
`[statechart:Name] <verb> key=value key=value …` (or native structured
fields under `tracing`). Twenty verbs cover every TraceEvent variant
— entries, exits (with begin/end pairs), actions (with kind),
durings, transitions (with **reason** showing what triggered them),
events (received/delivered/dropped/queued/failed), timers, terminate.
- **`trace;` keyword deprecated.** Still parsed for backwards
compatibility, but no longer gates emission. Trace output is now
controlled purely by which `trace-*` cargo feature you enable.
### Changed — journal (breaking)
- **`TraceEvent::TransitionFired` carries `reason: TransitionReason`.**
Replay consumers now see whether a transition was driven by an event
(`Event { event: u16 }`), a timer (`Timer { state, timer }`), or
internal logic (`Internal`).
- **New TraceEvent variants for phase begin/end pairing.**
- `EnterBegan { state }` fires before a state's entry actions /
timers / durings; `Entered` keeps its place as the end marker.
- `ExitBegan { state }` fires before a state's during cancellations
/ timer cancellations / exit actions; `Exited` keeps its place as
the end marker.
- `TransitionComplete { from, to }` fires after a transition's full
exit-then-enter sequence finishes; pairs with `TransitionFired`.
- `EventReceived { event }` fires when an event is popped from the
queue, before handler search; pairs with `EventDelivered` /
`EventDropped` / `TerminateRequested`.
### Added
- **`hsmc/tests/trace_format.rs`** — 11 tests pinning every verb's
exact textual rendering, including a journal/trace atom-count
equivalence test.
- **`TransitionReason`** enum — re-exported from the crate root when
`feature = "journal"` is on. Carried on `TraceEvent::TransitionFired`.
- **Multiple trace backends compose.** Previously enabling more than
one of `trace-defmt` / `trace-log` / `trace-tracing` was a compile
error. Now they fan out independently.
### Migration
Chart syntax is unchanged: every v0.1 / v0.2 / v0.3 statechart compiles
under v0.4 without modification. Breakage is confined to the
observability surface area:
1. **Trace line prefix.** Update grep patterns and log filters from
`[ChartName]` to `[statechart:ChartName]`. The verb after the
prefix is now a single hyphenated token followed by `key=value`
pairs.
2. **`TraceEvent::TransitionFired`** has a new `reason` field. Update
pattern matches:
```rust
// before:
TraceEvent::TransitionFired { from, to } => …
// after:
TraceEvent::TransitionFired { from, to, reason } => …
// or, if you don't care:
TraceEvent::TransitionFired { from, to, .. } => …
```
3. **New `TraceEvent` variants.** If you `match` exhaustively, add
arms for `EnterBegan`, `ExitBegan`, `TransitionComplete`,
`EventReceived`. Otherwise nothing changes.
4. **`trace;` keyword is now a no-op.** Charts that declared it still
compile. Trace output is now controlled purely by which `trace-*`
cargo feature you enable.
5. **`__chart_trace!` and `__chart_journal!` macros are removed.**
These were doc-hidden and not part of the public API; mentioned
here for codebases that grepped through the macro emission for
patches/forks.
## 0.3.0 — 2026-04-27 ## 0.3.0 — 2026-04-27
### Added ### Added
Generated
+3 -3
View File
@@ -465,7 +465,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hsmc" name = "hsmc"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"crossterm", "crossterm",
"defmt 0.3.100", "defmt 0.3.100",
@@ -484,7 +484,7 @@ dependencies = [
[[package]] [[package]]
name = "hsmc-macros" name = "hsmc-macros"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"hsmc", "hsmc",
"proc-macro2", "proc-macro2",
@@ -495,7 +495,7 @@ dependencies = [
[[package]] [[package]]
name = "hsmc-verification" name = "hsmc-verification"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"creusot-std", "creusot-std",
"hsmc", "hsmc",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["hsmc", "hsmc-macros", "verification"] members = ["hsmc", "hsmc-macros", "verification"]
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.4.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
+195 -56
View File
@@ -40,14 +40,22 @@ statechart! {
- **`m.run().await` is the whole task body.** Races active durings, the - **`m.run().await` is the whole task body.** Races active durings, the
external event channel, and timer deadlines. The MCU sleeps between external event channel, and timer deadlines. The MCU sleeps between
events (embassy's cooperative executor parks all futures). events (embassy's cooperative executor parks all futures).
- **Unified observability.** One observation per atom of execution
fans out at compile time to a journal (`Vec<TraceEvent>`) and/or
textual trace backends (`defmt`, `log`, `tracing`) — they share a
single vocabulary, so they cannot diverge. With no sink feature on,
every site expands to `()` (zero overhead). See
[Observability](#observability--one-journal-multiple-outputs).
- **Deterministic + mechanically verified.** The `journal` feature - **Deterministic + mechanically verified.** The `journal` feature
records every action, transition, timer, and event in order — same records every action, transition, timer, and event in order — same
inputs, byte-identical journal. The runtime queue and timer table inputs, byte-identical journal. The runtime queue and timer table
carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge
mechanically (`just verify`). 113 byte-equal expected-vs-actual mechanically (`just verify`). 159 tests pin every spec rule
journal tests anchor every spec rule. (journal sequences + trace-format regression + integration).
- **Backward-compatible.** Every v0.1 / v0.2 statechart compiles under - **Backward-compatible chart syntax.** Every v0.1 / v0.2 / v0.3
v0.3 unchanged. statechart compiles under v0.4 unchanged. The 0.4 breaks are in
observability surface area only — see
[Migration](#migration-from-v03).
## How a chart behaves — the building blocks ## How a chart behaves — the building blocks
@@ -86,6 +94,7 @@ walk up to the parent and check there. Repeat. If you reach the root
with no handler, the event is silently discarded. with no handler, the event is silently discarded.
Three corollaries: Three corollaries:
- Leaf shadows ancestor for the same trigger. - Leaf shadows ancestor for the same trigger.
- Multiple handlers in one state on the same trigger fire in - Multiple handlers in one state on the same trigger fire in
declaration order; the transition (if any) fires last. declaration order; the transition (if any) fires last.
@@ -143,7 +152,7 @@ in.
A timer (`on(after D) => …` or `on(every D) => …`) is **armed** when A timer (`on(after D) => …` or `on(every D) => …`) is **armed** when
its declaring state is entered, **cancelled** when that state is its declaring state is entered, **cancelled** when that state is
exited. Descending into a child is *not* an exit, so the parent's exited. Descending into a child is _not_ an exit, so the parent's
timers keep running while you're in a child. Re-entering a state timers keep running while you're in a child. Re-entering a state
restarts its timers from zero. restarts its timers from zero.
@@ -174,16 +183,16 @@ overwhelming majority of charts.
The bet is on **simple, consistent semantics**. These features are The bet is on **simple, consistent semantics**. These features are
omitted on purpose; for each, what to do instead: omitted on purpose; for each, what to do instead:
| Missing feature | Use instead | | Missing feature | Use instead |
|---|---| | ------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Guard conditions on transitions | Split source into two states, or have an action `emit()` a follow-up event for the chart to branch on | | Guard conditions on transitions | Split source into two states, or have an action `emit()` a follow-up event for the chart to branch on |
| Orthogonal / parallel regions | Two charts as two tasks connected by channels, or multiple `during:` activities in one state | | Orthogonal / parallel regions | Two charts as two tasks connected by channels, or multiple `during:` activities in one state |
| History states | Store last child explicitly in your context; transition to it on re-entry | | History states | Store last child explicitly in your context; transition to it on re-entry |
| Deferred events | Handle where they arrive (drop/log if not relevant), or have producer check state | | Deferred events | Handle where they arrive (drop/log if not relevant), or have producer check state |
| Internal transitions | `action(Trig) => fn;` (no transition) — exactly that | | Internal transitions | `action(Trig) => fn;` (no transition) — exactly that |
| State-local context | All state in the root context; durings borrow disjoint fields | | State-local context | All state in the root context; durings borrow disjoint fields |
| Event priorities beyond FIFO | Higher-priority events on a separate channel into a separate, faster machine | | Event priorities beyond FIFO | Higher-priority events on a separate channel into a separate, faster machine |
| Runtime statechart modification | Design every variation up front as states | | Runtime statechart modification | Design every variation up front as states |
## Quickstart ## Quickstart
@@ -191,7 +200,7 @@ Add to `Cargo.toml`:
```toml ```toml
[dependencies] [dependencies]
hsmc = { version = "0.3", features = ["embassy"] } # or ["tokio"] hsmc = { version = "0.4", features = ["embassy"] } # or ["tokio"]
``` ```
Minimal timer-only machine: Minimal timer-only machine:
@@ -327,45 +336,100 @@ into a single during that uses `select` internally.
For `statechart! Foo { ... }` the macro emits: For `statechart! Foo { ... }` the macro emits:
| Item | What it is | | Item | What it is |
|---------------------------------|----------------------------------------------------------------------| | -------------------------- | --------------------------------------------------------------------- |
| `FooState` | Enum of every user-declared state. | | `FooState` | Enum of every user-declared state. |
| `FooActions` | Trait with one async fn per unique action name. User implements. | | `FooActions` | Trait with one async fn per unique action name. User implements. |
| `FooActionContext<'a>` | Wrapper passed to actions. `Deref`s to the root context. `emit()`. | | `FooActionContext<'a>` | Wrapper passed to actions. `Deref`s to the root context. `emit()`. |
| `FooCtx<'a>` | Type alias for `FooActionContext<'a>`. | | `FooCtx<'a>` | Type alias for `FooActionContext<'a>`. |
| `Foo<const QN: usize = 8>` | The machine itself. | | `Foo<const QN: usize = 8>` | The machine itself. |
| `FooSender` | Cloneable handle for cross-task / ISR event injection. | | `FooSender` | Cloneable handle for cross-task / ISR event injection. |
| `Foo::<8>::STATE_CHART` | `&'static str` ASCII tree of the hierarchy (useful for `defmt`/docs).| | `Foo::<8>::STATE_CHART` | `&'static str` ASCII tree of the hierarchy (useful for `defmt`/docs). |
### Machine methods ### Machine methods
| Method | Notes | | Method | Notes |
|-------------------------------------|------------------------------------------------------------------| | -------------------------------------- | ------------------------------------------------------ |
| `new(ctx)` / `new(ctx, channel)` | Construct. Under embassy, pass an `&'static Channel`. | | `new(ctx)` / `new(ctx, channel)` | Construct. Under embassy, pass an `&'static Channel`. |
| `with_queue_capacity::<N>(ctx,...)` | Custom event-queue capacity. | | `with_queue_capacity::<N>(ctx,...)` | Custom event-queue capacity. |
| `async fn run()` | Drive the machine to completion (async features). | | `async fn run()` | Drive the machine to completion (async features). |
| `async fn dispatch(ev)` | Push event + drain to quiescence. Same task injection. | | `async fn dispatch(ev)` | Push event + drain to quiescence. Same task injection. |
| `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). | | `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). |
| `fn send(ev)` | Raw push, non-draining. For tests. | | `fn send(ev)` | Raw push, non-draining. For tests. |
| `sender() -> Sender` | Clone handle for other tasks / ISRs. | | `sender() -> Sender` | Clone handle for other tasks / ISRs. |
| `current_state()` | The active leaf state. | | `current_state()` | The active leaf state. |
| `is_terminated()` | True after `terminate` event. | | `is_terminated()` | True after `terminate` event. |
| `has_pending_events()` | Queue non-empty. | | `has_pending_events()` | Queue non-empty. |
| `context()` / `context_mut()` | Borrow the root context. | | `context()` / `context_mut()` | Borrow the root context. |
| `into_context(self)` | Consume, return ctx. | | `into_context(self)` | Consume, return ctx. |
| `STATE_CHART` | Const ASCII diagram. | | `STATE_CHART` | Const ASCII diagram. |
## Feature flags ## Feature flags
| Feature | Effect | | Feature | Effect |
|-----------|-------------------------------------------------------------------------| | --------- | -------------------------------------------------------------------------- |
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. | | (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
| `tokio` | Async actions + `run()`. Targets single-thread via `LocalSet`. | | `tokio` | Async actions + `run()`. Targets single-thread via `LocalSet`. |
| `embassy` | Async actions + `run()`. Targets embassy's cooperative executor. `no_std`.| | `embassy` | Async actions + `run()`. Targets embassy's cooperative executor. `no_std`. |
`embassy` and `tokio` are mutually exclusive — enabling both is a compile `embassy` and `tokio` are mutually exclusive — enabling both is a compile
error. error.
## Migration from v0.3
The 0.4 breaks are in the observability surface area only; chart
syntax and runtime API are unchanged.
### 1. Trace prefix changed
Trace lines (when `trace-defmt` / `trace-log` / `trace-tracing` is
enabled) now use `[statechart:Name]` instead of `[Name]`. Update any
log filters or grep patterns. The verb after the prefix is now a
single hyphenated token followed by `key=value` pairs (logfmt).
### 2. `TraceEvent::TransitionFired` carries `reason`
Add `reason` to any pattern matches:
```rust
// before:
TraceEvent::TransitionFired { from, to } => …
// after:
TraceEvent::TransitionFired { from, to, reason } => …
// or, if you don't need it:
TraceEvent::TransitionFired { from, to, .. } => …
```
The new field carries `TransitionReason::Event { event }`,
`Timer { state, timer }`, or `Internal` so consumers can see what
triggered the transition.
### 3. New `TraceEvent` variants
If you `match` exhaustively on `TraceEvent`, add arms for the new
variants. Otherwise nothing changes:
- `EnterBegan { state }` — phase begin marker pairs with `Entered`.
- `ExitBegan { state }` — phase begin marker pairs with `Exited`.
- `TransitionComplete { from, to }` — pairs with `TransitionFired`.
- `EventReceived { event }` — fires when an event is popped from the
queue, before handler search.
These are pure observation — no runtime semantic change.
### 4. `trace;` keyword is a no-op
Charts that declared `trace;` continue to compile. The keyword no
longer gates emission — observation is now controlled purely by
which `trace-*` cargo feature you enable. You can leave `trace;` in
or remove it; both work.
### 5. Trace backends are no longer mutually exclusive
Previously, enabling more than one of `trace-defmt` / `trace-log` /
`trace-tracing` was a compile error. Now they fan out independently:
turn on whichever you want.
## Migration from v0.1 ## Migration from v0.1
v0.1 statecharts continue to compile. New features are purely additive: v0.1 statecharts continue to compile. New features are purely additive:
@@ -442,12 +506,12 @@ async fn radio_task(lora: LoRaDriver, ch: &'static RadioChan) {
## Examples ## Examples
| Example | Purpose | | Example | Purpose |
|--------------------|---------------------------------------------------------------------| | --------------- | --------------------------------------------------------------------- |
| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate.| | `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate. |
| `microwave_tui` | TUI variant of microwave. | | `microwave_tui` | TUI variant of microwave. |
| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. | | `during_radio` | `during:` activities simulating a radio task with RX + TX loops. |
| `embassy_full` | Embassy feature demonstration. | | `embassy_full` | Embassy feature demonstration. |
Run `cargo run --example during_radio --features tokio` to see the Run `cargo run --example during_radio --features tokio` to see the
`during:` pattern end-to-end. `during:` pattern end-to-end.
@@ -469,12 +533,81 @@ gets to a green proof in one command. See
[`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the [`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the
spec-rule → proof mapping. spec-rule → proof mapping.
The `journal` Cargo feature gives you a deterministic execution Beyond Creusot, the suite includes:
trace of every action, transition, timer arm/cancel/fire, queued
emit, event delivery, and termination. Same chart + same events = - **159 integration tests** — full chart behavior, byte-equal expected-
byte-identical journal. 113 byte-equal expected-vs-actual journal vs-actual journals for ~113 deterministic-flow tests, plus 11 trace-
tests anchor every spec rule. `cargo mutants` runs cleanly with format regression tests pinning every observation verb's exact
0 missed mutants under `--features tokio,journal`. textual rendering.
- **0 missed mutants** under `cargo mutants --features
tokio,journal,trace-log`.
- **Miri**: 68 tests, no UB.
## Observability — one journal, multiple outputs
Every chart emits a single observation per atom of execution. That
observation fans out to whichever sinks you compile in:
| Feature | Sink |
| --------------- | -------------------------------------------------------- |
| `journal` | In-memory `Vec<TraceEvent>` (replay, byte-deterministic) |
| `trace-defmt` | `defmt::*` log lines |
| `trace-log` | `log::*` log lines |
| `trace-tracing` | `tracing::*` events with structured fields |
With **no** sink feature on, observation is fully elided at compile
time — zero overhead. Multiple sinks can be enabled simultaneously;
the journal IS the observation stream and trace backends are textual
renderings of it, so they cannot diverge.
The textual format is logfmt-style — greppable and regex-parseable:
```
[statechart:Microwave] started chart_hash=0xa1b2c3d4
[statechart:Microwave] entering state=Idle
[statechart:Microwave] action state=Idle action=clear_display kind=entry
[statechart:Microwave] timer-armed state=Idle timer=t0 duration_ns=5000000000
[statechart:Microwave] during-started state=Idle during=poll_sensor
[statechart:Microwave] entered state=Idle
[statechart:Microwave] event-received name=StartButton
[statechart:Microwave] event-delivered name=StartButton handler=Idle
[statechart:Microwave] transition-begin from=Idle to=Heating reason=event:StartButton
[statechart:Microwave] exiting state=Idle
[statechart:Microwave] timer-cancelled state=Idle timer=t0
[statechart:Microwave] during-cancelled state=Idle during=poll_sensor
[statechart:Microwave] action state=Idle action=stop_motor kind=exit
[statechart:Microwave] exited state=Idle
[statechart:Microwave] entering state=Heating
[statechart:Microwave] entered state=Heating
[statechart:Microwave] transition-complete from=Idle to=Heating
```
### The verb vocabulary
Twenty verbs cover every `TraceEvent` variant. Begin/end markers
bracket entry, exit, and transition phases so the actions, timers,
and durings running inside each phase are visible.
| Verb | Fires when |
|---|---|
| `started` | First step of a chart's life — carries `chart_hash` |
| `entering` / `entered` | Begin / end of a state's entry phase |
| `exiting` / `exited` | Begin / end of a state's exit phase |
| `action` | An action fn was called — `kind=entry\|exit\|handler` |
| `during-started` / `during-cancelled` | A `during:` activity started / was dropped |
| `transition-begin` / `transition-complete` | Begin / end of a transition's exit-then-enter sequence |
| `event-received` | An event was popped from the queue |
| `event-delivered` | A handler ran for the event |
| `event-dropped` | The event reached root with no handler |
| `emit-queued` / `emit-failed` | `emit()` from inside an action — queued or rejected |
| `timer-armed` / `timer-cancelled` / `timer-fired` | Timer lifecycle |
| `terminate-requested` | `terminate(...)` event matched, exit chain about to run |
| `terminated` | Chart finished (always last) |
The `reason=` field on `transition-begin` records what triggered the
transition: an event (`event:VariantName`), a timer
(`timer:State/timer-id`), or `internal`. Real-life captures can be
diffed against expected behavior the same way the journal can.
## Status ## Status
@@ -484,6 +617,12 @@ tests anchor every spec rule. `cargo mutants` runs cleanly with
- v0.3: `journal` feature + deterministic trace + replay; root - v0.3: `journal` feature + deterministic trace + replay; root
state targetable by chart name; Creusot verification of the state targetable by chart name; Creusot verification of the
runtime data structures; 113-test deterministic-flow suite. runtime data structures; 113-test deterministic-flow suite.
- v0.4: unified observation pipeline (journal + trace share one
vocabulary, fan out to multiple sinks at compile time); logfmt-
style trace format with `[statechart:Name]` prefix; transition
reasons; entry/exit/transition begin/end markers; multiple trace
backends now compose; chart-level `trace;` keyword deprecated.
Breaking on the `TraceEvent` enum shape; chart syntax unchanged.
- Out of scope (deliberate): guard conditions, orthogonal/parallel - Out of scope (deliberate): guard conditions, orthogonal/parallel
regions, history states, deferred events, internal transitions, regions, history states, deferred events, internal transitions,
state-local context, event priorities beyond FIFO, runtime state-local context, event priorities beyond FIFO, runtime
+10
View File
@@ -217,6 +217,16 @@ So a full transition reads top-to-bottom as: cancel durings on the way
out → run exits on the way out → run entries on the way in → start out → run exits on the way out → run entries on the way in → start
durings on the way in. durings on the way in.
Observers (the journal and any active trace backend) see this as
phase **begin** and **end** markers bracketing the work: an
`EnterBegan { state }` fires before any of that state's entry actions /
timer arms / during starts, and an `Entered { state }` fires after
all of them finish. The same for `ExitBegan` / `Exited`, and
`TransitionFired` / `TransitionComplete` bracket the whole exit-then-
enter sequence of a transition. This is purely a visibility change —
the operational rules above are unchanged — but it lets you tell from
a captured trace which atoms ran inside which phase.
--- ---
## `emit()` — sending events from inside an action ## `emit()` — sending events from inside an action
+413 -175
View File
@@ -88,22 +88,6 @@ fn compute_chart_hash(ir: &Ir) -> u64 {
h h
} }
/// Emit a single trace call routed through the hsmc-internal dispatcher
/// (`::hsmc::__chart_trace!`), or nothing if the chart didn't declare
/// `trace;`. The dispatcher picks the actual backend (defmt / log /
/// tracing / no-op) based on which `hsmc/trace-*` cargo feature the
/// user enabled in their `Cargo.toml` — no extra wiring required.
///
/// Zero-overhead when `trace_enabled` is false: generates an empty
/// TokenStream so the compiler sees no extra work at the call site.
fn emit_trace_call(trace_enabled: bool, args: TokenStream) -> TokenStream {
if trace_enabled {
quote! { ::hsmc::__chart_trace!(#args); }
} else {
TokenStream::new()
}
}
/// For each user-declared leaf state that has one or more `during:` activities /// For each user-declared leaf state that has one or more `during:` activities
/// active (either declared on it, on any ancestor, or on root), return its /// active (either declared on it, on any ancestor, or on root), return its
/// state id and the flattened list of durings in leaf-first order. Durings /// state id and the flattened list of durings in leaf-first order. Durings
@@ -533,15 +517,29 @@ pub fn generate(ir: &Ir) -> TokenStream {
}) })
.collect(); .collect();
// Per-id name lookups, only emitted when the chart opted into tracing. // ── Per-id name lookup tables ─────────────────────────────────────
// Chart-name string is reused at every trace site as a literal. //
// Generated unconditionally at chart scope so any code in the
// emitted module (machine impl, action context, etc.) can resolve
// a u16 id to a `&'static str`. The tables are dead-code-eliminated
// when no observation feature is on: with neither `journal` nor any
// `trace-*` feature enabled, the `__chart_observe!` macro arms are
// empty, so the call sites never reference these helpers and the
// linker drops them. Marked `dead_code` to silence rustc when off.
let chart_name_lit = ir.name.to_string(); let chart_name_lit = ir.name.to_string();
let state_name_arms: Vec<_> = ir let state_name_arms: Vec<_> = ir
.states .states
.iter() .iter()
.map(|s| { .map(|s| {
let sid = s.id; let sid = s.id;
let nm = s.name.to_string(); let nm = if s.parent.is_none() {
// Synthesized root: render as the chart name (per spec
// the root state IS the chart).
ir.name.to_string()
} else {
s.name.to_string()
};
quote! { #sid => #nm, } quote! { #sid => #nm, }
}) })
.collect(); .collect();
@@ -555,174 +553,357 @@ pub fn generate(ir: &Ir) -> TokenStream {
quote! { #aid => #nm, } quote! { #aid => #nm, }
}) })
.collect(); .collect();
let trace_helpers = if ir.trace_enabled { let event_name_arms: Vec<_> = ir
quote! { .event_variants
#[inline(always)] .iter()
#[doc(hidden)] .enumerate()
fn __state_name(sid: u16) -> &'static str { .map(|(i, v)| {
match sid { let eid = i as u16;
#(#state_name_arms)* let nm = v.name.to_string();
_ => "<unknown>", quote! { #eid => #nm, }
} })
} .collect();
#[inline(always)] let timer_name_arms: Vec<_> = ir
#[doc(hidden)] .duration_triggers
fn __action_name(aid: u16) -> &'static str { .iter()
match aid { .enumerate()
#(#action_name_arms)* .map(|(i, d)| {
_ => "<unknown>", let tid = i as u16;
} // The intern key looks like "<state>::<expr>:<repeat>". For
// human readability render the expr text — fall back to
// numeric id if the key parses oddly.
let label = format!("t{}", tid);
// Try to extract the expression body so the trace shows
// something like `Duration::from_secs(5)`. Using `key` as
// a stable label is OK for now; the expr token stream isn't
// round-tripped through `to_string()` cleanly across rustc
// versions, so prefer a synthesized short name.
let _ = &d.key; // mark used for non-warn
quote! { #tid => #label, }
})
.collect();
// Per-(state, during-index) name lookup. The during's fn-name is
// the closest thing to a stable identifier the user wrote.
let during_name_arms: Vec<_> = {
let mut arms = Vec::new();
for s in &ir.states {
let sid = s.id;
for (di, d) in s.durings.iter().enumerate() {
let did = di as u16;
let nm = d.fn_name.to_string();
arms.push(quote! { (#sid, #did) => #nm, });
} }
} }
} else { arms
TokenStream::new()
}; };
// Pre-rendered transition reason strings for trace output.
// `event:<VariantName>` for event-driven transitions.
let event_reason_str_arms: Vec<_> = ir
.event_variants
.iter()
.enumerate()
.map(|(i, v)| {
let eid = i as u16;
let s = format!("event:{}", v.name);
quote! { #eid => #s, }
})
.collect();
// `timer:<state>/<timer>` for timer-driven transitions. We
// enumerate (state, timer) pairs that actually occur.
let timer_reason_str_arms: Vec<_> = {
let mut arms = Vec::new();
for s in &ir.states {
for &tid in &s.owned_timers {
let sid = s.id;
let sname = if s.parent.is_none() {
ir.name.to_string()
} else {
s.name.to_string()
};
let tlabel = format!("t{}", tid);
let s_str = format!("timer:{}/{}", sname, tlabel);
arms.push(quote! { (#sid, #tid) => #s_str, });
}
}
arms
};
let name_helpers = quote! {
#[allow(dead_code)]
#[doc(hidden)]
#[inline]
fn __state_name(sid: u16) -> &'static str {
match sid {
#(#state_name_arms)*
_ => "<unknown>",
}
}
#[allow(dead_code)]
#[doc(hidden)]
#[inline]
fn __action_name(aid: u16) -> &'static str {
match aid {
#(#action_name_arms)*
_ => "<unknown>",
}
}
#[allow(dead_code, unreachable_patterns)]
#[doc(hidden)]
#[inline]
fn __event_name(eid: u16) -> &'static str {
match eid {
#(#event_name_arms)*
_ => "<unknown>",
}
}
#[allow(dead_code, unreachable_patterns)]
#[doc(hidden)]
#[inline]
fn __timer_name(tid: u16) -> &'static str {
match tid {
#(#timer_name_arms)*
_ => "<unknown>",
}
}
#[allow(dead_code, unreachable_patterns, unused_variables)]
#[doc(hidden)]
#[inline]
fn __during_name(sid: u16, did: u16) -> &'static str {
match (sid, did) {
#(#during_name_arms)*
_ => "<unknown>",
}
}
#[allow(dead_code, unreachable_patterns)]
#[doc(hidden)]
#[inline]
fn __event_reason_str(eid: u16) -> &'static str {
match eid {
#(#event_reason_str_arms)*
_ => "event:?",
}
}
#[allow(dead_code, unreachable_patterns, unused_variables)]
#[doc(hidden)]
#[inline]
fn __timer_reason_str(sid: u16, tid: u16) -> &'static str {
match (sid, tid) {
#(#timer_reason_str_arms)*
_ => "timer:?",
}
}
#[allow(dead_code)]
#[doc(hidden)]
#[inline]
fn __from_name(opt: Option<u16>) -> &'static str {
match opt {
Some(id) => __state_name(id),
None => "<none>",
}
}
};
// ── Observation hook snippets ───────────────────────────────────── // ── Observation hook snippets ─────────────────────────────────────
// //
// Trace and journal want the same call sites: enter, exit, action, // Each snippet emits ONE `__chart_observe!` invocation. The macro
// transition. We emit a single `observe_*` snippet per site that // dispatches per-variant to whichever sinks (journal + trace-*) are
// contains BOTH the trace call (gated on `ir.trace_enabled` at // enabled at compile time. Trace and journal share the same call
// parse time, may also be no-op'd by the trace-* feature gate) and // sites by construction — they cannot diverge.
// the journal call (gated on the `journal` cargo feature inside the
// macro). Either gate independently produces zero overhead when off.
// //
// Journal-only sites (timers, durings, emits, event dispatch, // Names (state/action/event/timer/during) are passed as runtime
// termination) get plain `journal_*` snippets — trace doesn't have // lookups against the small static tables generated above. With no
// a string format for them. // sink feature on, the macro arm body is empty and the lookup is
// never expanded — zero overhead at the call site.
// Trace-side primitives, recreated here so each observe_* snippet
// can splice them in.
let trace_enter_call = emit_trace_call(
ir.trace_enabled,
quote! { enter #chart_name_lit, Self::__state_name(sid) },
);
let trace_exit_call = emit_trace_call(
ir.trace_enabled,
quote! { exit #chart_name_lit, Self::__state_name(sid) },
);
let trace_action_call = emit_trace_call(
ir.trace_enabled,
quote! { action #chart_name_lit, Self::__action_name(aid) },
);
let trace_transition_call = emit_trace_call(
ir.trace_enabled,
quote! {
transition #chart_name_lit,
self.current.map(Self::__state_name).unwrap_or("<none>"),
Self::__state_name(target)
},
);
let observe_started = quote! { let observe_started = quote! {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, Started,
::hsmc::TraceEvent::Started { chart_hash: Self::CHART_HASH } &mut self.__journal,
#chart_name_lit,
Self::CHART_HASH
);
};
let observe_enter_began = quote! {
::hsmc::__chart_observe!(
EnterBegan,
&mut self.__journal,
#chart_name_lit,
sid,
__state_name(sid)
); );
}; };
let observe_entered = quote! { let observe_entered = quote! {
#trace_enter_call ::hsmc::__chart_observe!(
::hsmc::__chart_journal!( Entered,
self.__journal, &mut self.__journal,
::hsmc::TraceEvent::Entered { state: sid } #chart_name_lit,
sid,
__state_name(sid)
);
};
let observe_exit_began = quote! {
::hsmc::__chart_observe!(
ExitBegan,
&mut self.__journal,
#chart_name_lit,
sid,
__state_name(sid)
); );
}; };
let observe_exited = quote! { let observe_exited = quote! {
#trace_exit_call ::hsmc::__chart_observe!(
::hsmc::__chart_journal!( Exited,
self.__journal, &mut self.__journal,
::hsmc::TraceEvent::Exited { state: sid } #chart_name_lit,
sid,
__state_name(sid)
); );
}; };
let observe_action_entry = quote! { let observe_action_entry = quote! {
#trace_action_call ::hsmc::__chart_observe!(
::hsmc::__chart_journal!( ActionInvoked,
self.__journal, &mut self.__journal,
::hsmc::TraceEvent::ActionInvoked { #chart_name_lit,
state: sid, sid, __state_name(sid),
action: aid, aid, __action_name(aid),
kind: ::hsmc::ActionKind::Entry, Entry
}
); );
}; };
let observe_action_exit = quote! { let observe_action_exit = quote! {
#trace_action_call ::hsmc::__chart_observe!(
::hsmc::__chart_journal!( ActionInvoked,
self.__journal, &mut self.__journal,
::hsmc::TraceEvent::ActionInvoked { #chart_name_lit,
state: sid, sid, __state_name(sid),
action: aid, aid, __action_name(aid),
kind: ::hsmc::ActionKind::Exit, Exit
}
); );
}; };
let observe_action_handler = quote! { let observe_action_handler = quote! {
#trace_action_call ::hsmc::__chart_observe!(
::hsmc::__chart_journal!( ActionInvoked,
self.__journal, &mut self.__journal,
::hsmc::TraceEvent::ActionInvoked { #chart_name_lit,
state: __handler_state, __handler_state, __state_name(__handler_state),
action: aid, aid, __action_name(aid),
kind: ::hsmc::ActionKind::Handler, Handler
}
); );
}; };
let observe_transition = quote! { // TransitionFired — three call-site flavours. Codegen splices in
#trace_transition_call // the appropriate one based on what triggered the transition.
::hsmc::__chart_journal!( let observe_transition_fired_event = quote! {
self.__journal, ::hsmc::__chart_observe!(
::hsmc::TraceEvent::TransitionFired { TransitionFired,
from: self.current, &mut self.__journal,
to: target, #chart_name_lit,
} self.current, __from_name(self.current),
target, __state_name(target),
::hsmc::TransitionReason::Event { event: __ev_id },
__event_reason_str(__ev_id)
);
};
let observe_transition_fired_timer = quote! {
::hsmc::__chart_observe!(
TransitionFired,
&mut self.__journal,
#chart_name_lit,
self.current, __from_name(self.current),
target, __state_name(target),
::hsmc::TransitionReason::Timer {
state: __t_state,
timer: __t_trigger,
},
__timer_reason_str(__t_state, __t_trigger)
);
};
#[allow(dead_code)]
let _observe_transition_fired_internal = quote! {
::hsmc::__chart_observe!(
TransitionFired,
&mut self.__journal,
#chart_name_lit,
self.current, __from_name(self.current),
target, __state_name(target),
::hsmc::TransitionReason::Internal,
"internal"
);
};
let observe_transition_complete = quote! {
::hsmc::__chart_observe!(
TransitionComplete,
&mut self.__journal,
#chart_name_lit,
__t_complete_from, __from_name(__t_complete_from),
target, __state_name(target)
);
};
let observe_event_received = quote! {
::hsmc::__chart_observe!(
EventReceived,
&mut self.__journal,
#chart_name_lit,
__ev_id,
__event_name(__ev_id)
); );
}; };
let observe_event_delivered = quote! { let observe_event_delivered = quote! {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, EventDelivered,
::hsmc::TraceEvent::EventDelivered { &mut self.__journal,
handler_state: __handler_state, #chart_name_lit,
event: __ev_id, __ev_id, __event_name(__ev_id),
} __handler_state, __state_name(__handler_state)
); );
}; };
let observe_event_dropped = quote! { let observe_event_dropped = quote! {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, EventDropped,
::hsmc::TraceEvent::EventDropped { event: __ev_id } &mut self.__journal,
#chart_name_lit,
__ev_id, __event_name(__ev_id)
); );
}; };
let observe_timer_armed = quote! { let observe_timer_armed = quote! {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, TimerArmed,
::hsmc::TraceEvent::TimerArmed { &mut self.__journal,
state: sid, #chart_name_lit,
timer: tid, sid, __state_name(sid),
ns: __duration_for(tid).as_nanos() as u64, tid, __timer_name(tid),
} __duration_for(tid).as_nanos() as u64
); );
}; };
let observe_timer_cancelled = quote! { let observe_timer_cancelled = quote! {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, TimerCancelled,
::hsmc::TraceEvent::TimerCancelled { state: sid, timer: tid } &mut self.__journal,
#chart_name_lit,
sid, __state_name(sid),
tid, __timer_name(tid)
); );
}; };
let observe_timer_fired = quote! { let observe_timer_fired = quote! {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, TimerFired,
::hsmc::TraceEvent::TimerFired { state: __t_state, timer: __t_trigger } &mut self.__journal,
#chart_name_lit,
__t_state, __state_name(__t_state),
__t_trigger, __timer_name(__t_trigger)
); );
}; };
let observe_terminated = quote! { let observe_terminated = quote! {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, Terminated,
::hsmc::TraceEvent::Terminated &mut self.__journal,
#chart_name_lit
); );
}; };
let observe_terminate_requested = quote! { let observe_terminate_requested = quote! {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, TerminateRequested,
::hsmc::TraceEvent::TerminateRequested { event: __ev_id } &mut self.__journal,
#chart_name_lit,
__ev_id, __event_name(__ev_id)
); );
}; };
@@ -741,9 +922,12 @@ pub fn generate(ir: &Ir) -> TokenStream {
let __dn = Self::__durings_count(sid); let __dn = Self::__durings_count(sid);
let mut __di: u16 = 0; let mut __di: u16 = 0;
while __di < __dn { while __di < __dn {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, DuringStarted,
::hsmc::TraceEvent::DuringStarted { state: sid, during: __di } &mut self.__journal,
#chart_name_lit,
sid, __state_name(sid),
__di, __during_name(sid, __di)
); );
__di += 1; __di += 1;
} }
@@ -752,9 +936,12 @@ pub fn generate(ir: &Ir) -> TokenStream {
let __dn = Self::__durings_count(sid); let __dn = Self::__durings_count(sid);
let mut __di: u16 = 0; let mut __di: u16 = 0;
while __di < __dn { while __di < __dn {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, DuringCancelled,
::hsmc::TraceEvent::DuringCancelled { state: sid, during: __di } &mut self.__journal,
#chart_name_lit,
sid, __state_name(sid),
__di, __during_name(sid, __di)
); );
__di += 1; __di += 1;
} }
@@ -1766,14 +1953,18 @@ pub fn generate(ir: &Ir) -> TokenStream {
let __ev_id = __event_to_id(&event); let __ev_id = __event_to_id(&event);
let __result = self.__queue.push(event); let __result = self.__queue.push(event);
if __result.is_ok() { if __result.is_ok() {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, EmitQueued,
::hsmc::TraceEvent::EmitQueued { event: __ev_id } &mut *self.__journal,
#chart_name_lit,
__ev_id, __event_name(__ev_id)
); );
} else { } else {
::hsmc::__chart_journal!( ::hsmc::__chart_observe!(
self.__journal, EmitFailed,
::hsmc::TraceEvent::EmitFailed { event: __ev_id } &mut *self.__journal,
#chart_name_lit,
__ev_id, __event_name(__ev_id)
); );
} }
__result __result
@@ -1807,6 +1998,8 @@ pub fn generate(ir: &Ir) -> TokenStream {
const __DEPTH: [u8; #n_states] = [#(#depth_table),*]; const __DEPTH: [u8; #n_states] = [#(#depth_table),*];
const __DEFAULT_CHILD: [Option<u16>; #n_states] = [#(#default_child_table),*]; const __DEFAULT_CHILD: [Option<u16>; #n_states] = [#(#default_child_table),*];
#name_helpers
#[allow(dead_code, unreachable_patterns)] #[allow(dead_code, unreachable_patterns)]
fn __state_id_to_public(id: u16) -> #state_enum_name { fn __state_id_to_public(id: u16) -> #state_enum_name {
match id { match id {
@@ -2016,13 +2209,13 @@ pub fn generate(ir: &Ir) -> TokenStream {
} }
if let Some(ev) = self.queue.pop_front() { if let Some(ev) = self.queue.pop_front() {
let __ev_id = __event_to_id(&ev);
#observe_event_received
if matches!(__check_terminate(&ev), TerminateCheck::Yes) { if matches!(__check_terminate(&ev), TerminateCheck::Yes) {
let __ev_id = __event_to_id(&ev);
#observe_terminate_requested #observe_terminate_requested
self.do_terminate(); self.do_terminate();
return None; return None;
} }
let __ev_id = __event_to_id(&ev);
if __ev_id != u16::MAX { if __ev_id != u16::MAX {
let mut __cur = self.current; let mut __cur = self.current;
let mut __delivered = false; let mut __delivered = false;
@@ -2030,7 +2223,10 @@ pub fn generate(ir: &Ir) -> TokenStream {
if let Some((actions, target)) = __handler_lookup(s, 0, __ev_id) { if let Some((actions, target)) = __handler_lookup(s, 0, __ev_id) {
let __handler_state = s; let __handler_state = s;
#observe_event_delivered #observe_event_delivered
self.run_handlers(s, actions, target, Some(&ev)); self.run_handlers(
s, actions, target, Some(&ev),
1u8, __ev_id, 0u16,
);
__delivered = true; __delivered = true;
break; break;
} }
@@ -2077,13 +2273,13 @@ pub fn generate(ir: &Ir) -> TokenStream {
} }
if let Some(ev) = self.queue.pop_front() { if let Some(ev) = self.queue.pop_front() {
let __ev_id = __event_to_id(&ev);
#observe_event_received
if matches!(__check_terminate(&ev), TerminateCheck::Yes) { if matches!(__check_terminate(&ev), TerminateCheck::Yes) {
let __ev_id = __event_to_id(&ev);
#observe_terminate_requested #observe_terminate_requested
self.do_terminate().await; self.do_terminate().await;
return None; return None;
} }
let __ev_id = __event_to_id(&ev);
if __ev_id != u16::MAX { if __ev_id != u16::MAX {
let mut __cur = self.current; let mut __cur = self.current;
let mut __delivered = false; let mut __delivered = false;
@@ -2091,7 +2287,10 @@ pub fn generate(ir: &Ir) -> TokenStream {
if let Some((actions, target)) = __handler_lookup(s, 0, __ev_id) { if let Some((actions, target)) = __handler_lookup(s, 0, __ev_id) {
let __handler_state = s; let __handler_state = s;
#observe_event_delivered #observe_event_delivered
self.run_handlers(s, actions, target, Some(&ev)).await; self.run_handlers(
s, actions, target, Some(&ev),
1u8, __ev_id, 0u16,
).await;
__delivered = true; __delivered = true;
break; break;
} }
@@ -2145,14 +2344,21 @@ pub fn generate(ir: &Ir) -> TokenStream {
Ok(()) Ok(())
} }
// Reason kind passed by callers to `run_handlers`:
// 1 → event-driven (reason_a = event id)
// 2 → timer-driven (reason_a = state id, reason_b = timer id)
// 0 → internal/other (unused today; reserved)
#[cfg(not(any(feature = "tokio", feature = "embassy")))] #[cfg(not(any(feature = "tokio", feature = "embassy")))]
#[allow(unused_variables)] #[allow(unused_variables, clippy::too_many_arguments)]
fn run_handlers( fn run_handlers(
&mut self, &mut self,
source_state: u16, source_state: u16,
actions: &'static [u16], actions: &'static [u16],
target: Option<u16>, target: Option<u16>,
event: Option<&#ev_ty>, event: Option<&#ev_ty>,
__reason_kind: u8,
__reason_a: u16,
__reason_b: u16,
) { ) {
let __handler_state = source_state; let __handler_state = source_state;
for &aid in actions { for &aid in actions {
@@ -2160,19 +2366,36 @@ pub fn generate(ir: &Ir) -> TokenStream {
self.run_action(aid, event); self.run_action(aid, event);
if self.terminated { return; } if self.terminated { return; }
} }
if let Some(tgt) = target { if let Some(target) = target {
self.transition(source_state, tgt); match __reason_kind {
1 => {
let __ev_id = __reason_a;
#observe_transition_fired_event
}
2 => {
let __t_state = __reason_a;
let __t_trigger = __reason_b;
#observe_transition_fired_timer
}
_ => {}
}
let __t_complete_from = self.current;
self.transition(source_state, target);
#observe_transition_complete
} }
} }
#[cfg(any(feature = "tokio", feature = "embassy"))] #[cfg(any(feature = "tokio", feature = "embassy"))]
#[allow(unused_variables)] #[allow(unused_variables, clippy::too_many_arguments)]
async fn run_handlers( async fn run_handlers(
&mut self, &mut self,
source_state: u16, source_state: u16,
actions: &'static [u16], actions: &'static [u16],
target: Option<u16>, target: Option<u16>,
event: Option<&#ev_ty>, event: Option<&#ev_ty>,
__reason_kind: u8,
__reason_a: u16,
__reason_b: u16,
) { ) {
let __handler_state = source_state; let __handler_state = source_state;
for &aid in actions { for &aid in actions {
@@ -2180,8 +2403,22 @@ pub fn generate(ir: &Ir) -> TokenStream {
self.run_action(aid, event).await; self.run_action(aid, event).await;
if self.terminated { return; } if self.terminated { return; }
} }
if let Some(tgt) = target { if let Some(target) = target {
self.transition(source_state, tgt).await; match __reason_kind {
1 => {
let __ev_id = __reason_a;
#observe_transition_fired_event
}
2 => {
let __t_state = __reason_a;
let __t_trigger = __reason_b;
#observe_transition_fired_timer
}
_ => {}
}
let __t_complete_from = self.current;
self.transition(source_state, target).await;
#observe_transition_complete
} }
} }
@@ -2272,7 +2509,6 @@ pub fn generate(ir: &Ir) -> TokenStream {
#[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 transition(&mut self, _source_matched_state: u16, target: u16) {
#observe_transition
if Self::is_up_transition(self.current, target) { if Self::is_up_transition(self.current, target) {
if let Some(mut cur) = self.current { if let Some(mut cur) = self.current {
while cur != target { while cur != target {
@@ -2319,7 +2555,6 @@ pub fn generate(ir: &Ir) -> TokenStream {
#[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) {
#observe_transition
if Self::is_up_transition(self.current, target) { if Self::is_up_transition(self.current, target) {
if let Some(mut cur) = self.current { if let Some(mut cur) = self.current {
while cur != target { while cur != target {
@@ -2394,7 +2629,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
#[cfg(not(any(feature = "tokio", feature = "embassy")))] #[cfg(not(any(feature = "tokio", feature = "embassy")))]
#[allow(unused_variables)] #[allow(unused_variables)]
fn enter_state_no_descent(&mut self, sid: u16) { fn enter_state_no_descent(&mut self, sid: u16) {
#observe_entered #observe_enter_began
let entries = Self::entries_of(sid); let entries = Self::entries_of(sid);
for &aid in entries { for &aid in entries {
#observe_action_entry #observe_action_entry
@@ -2408,12 +2643,13 @@ pub fn generate(ir: &Ir) -> TokenStream {
} }
self.current = Some(sid); self.current = Some(sid);
#observe_durings_started #observe_durings_started
#observe_entered
} }
#[cfg(any(feature = "tokio", feature = "embassy"))] #[cfg(any(feature = "tokio", feature = "embassy"))]
#[allow(unused_variables)] #[allow(unused_variables)]
async fn enter_state_no_descent(&mut self, sid: u16) { async fn enter_state_no_descent(&mut self, sid: u16) {
#observe_entered #observe_enter_began
let entries = Self::entries_of(sid); let entries = Self::entries_of(sid);
for &aid in entries { for &aid in entries {
#observe_action_entry #observe_action_entry
@@ -2427,6 +2663,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
} }
self.current = Some(sid); self.current = Some(sid);
#observe_durings_started #observe_durings_started
#observe_entered
} }
#[cfg(not(any(feature = "tokio", feature = "embassy")))] #[cfg(not(any(feature = "tokio", feature = "embassy")))]
@@ -2451,7 +2688,9 @@ pub fn generate(ir: &Ir) -> TokenStream {
#[allow(unused_variables)] #[allow(unused_variables)]
fn exit_state(&mut self, sid: u16) { fn exit_state(&mut self, sid: u16) {
// Per spec §"Entry and exit ordering": // Per spec §"Entry and exit ordering":
// 1. cancel durings 2. cancel timers 3. exit actions 4. Exited // 1. ExitBegan 2. cancel durings 3. cancel timers
// 4. exit actions 5. Exited
#observe_exit_began
#observe_durings_cancelled #observe_durings_cancelled
for &tid in __owned_timers(sid) { for &tid in __owned_timers(sid) {
#observe_timer_cancelled #observe_timer_cancelled
@@ -2469,6 +2708,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
#[cfg(any(feature = "tokio", feature = "embassy"))] #[cfg(any(feature = "tokio", feature = "embassy"))]
#[allow(unused_variables)] #[allow(unused_variables)]
async fn exit_state(&mut self, sid: u16) { async fn exit_state(&mut self, sid: u16) {
#observe_exit_began
#observe_durings_cancelled #observe_durings_cancelled
for &tid in __owned_timers(sid) { for &tid in __owned_timers(sid) {
#observe_timer_cancelled #observe_timer_cancelled
@@ -2504,8 +2744,6 @@ pub fn generate(ir: &Ir) -> TokenStream {
} }
} }
#trace_helpers
#[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(mut cur) = self.current { if let Some(mut cur) = self.current {
@@ -2543,15 +2781,15 @@ pub fn generate(ir: &Ir) -> TokenStream {
#[cfg(not(any(feature = "tokio", feature = "embassy")))] #[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn dispatch_trigger(&mut self, state: u16, kind: u8, trig: u16) { fn dispatch_trigger(&mut self, state: u16, kind: u8, trig: u16) {
if let Some((actions, target)) = __handler_lookup(state, kind, trig) { if let Some((actions, target)) = __handler_lookup(state, kind, trig) {
// Timer-driven dispatch: no event payload in scope. // Timer-driven dispatch: no event payload, reason = timer.
self.run_handlers(state, actions, target, None); self.run_handlers(state, actions, target, None, 2u8, state, trig);
} }
} }
#[cfg(any(feature = "tokio", feature = "embassy"))] #[cfg(any(feature = "tokio", feature = "embassy"))]
async fn dispatch_trigger(&mut self, state: u16, kind: u8, trig: u16) { async fn dispatch_trigger(&mut self, state: u16, kind: u8, trig: u16) {
if let Some((actions, target)) = __handler_lookup(state, kind, trig) { if let Some((actions, target)) = __handler_lookup(state, kind, trig) {
self.run_handlers(state, actions, target, None).await; self.run_handlers(state, actions, target, None, 2u8, state, trig).await;
} }
} }
} }
+6 -4
View File
@@ -26,10 +26,12 @@ pub struct Ir {
/// Duration triggers referenced anywhere, in first-seen order. /// Duration triggers referenced anywhere, in first-seen order.
/// Each one gets a trigger id used by the timer table. /// Each one gets a trigger id used by the timer table.
pub duration_triggers: Vec<DurationTrigger>, pub duration_triggers: Vec<DurationTrigger>,
/// True when the chart declared `trace;` at root. Codegen routes /// Set when the chart declared `trace;` at root. Retained for
/// trace calls to `::hsmc::__chart_trace!`, which dispatches to a /// backwards-compatible parsing — the keyword no longer gates
/// backend (defmt / log / tracing / no-op) based on the user's /// observation emission. As of 0.4 every chart unconditionally
/// `hsmc/trace-*` feature selection in their `Cargo.toml`. /// emits `__chart_observe!` calls, and the actual trace fan-out is
/// chosen by the user's `hsmc/trace-*` cargo feature selection.
#[allow(dead_code)]
pub trace_enabled: bool, pub trace_enabled: bool,
} }
@@ -1,3 +1,5 @@
#![allow(unexpected_cfgs)]
use hsmc::statechart; use hsmc::statechart;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -1,68 +1,13 @@
warning: unexpected `cfg` condition value: `tokio`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: `#[warn(unexpected_cfgs)]` on by default
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
warning: unexpected `cfg` condition value: `embassy`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
warning: unexpected `cfg` condition value: `journal`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0046]: not all trait items implemented, missing: `NonExistent` error[E0046]: not all trait items implemented, missing: `NonExistent`
--> tests/ui/t9_8_unknown_target.rs:19:1 --> tests/ui/t9_8_unknown_target.rs:21:1
| |
8 | / statechart! { 10 | / statechart! {
9 | | M { 11 | | M {
10 | | context: Ctx; 12 | | context: Ctx;
11 | | events: Ev; 13 | | events: Ev;
... | ... |
17 | | } 19 | | }
| |_- `NonExistent` from trait | |_- `NonExistent` from trait
18 | 20 |
19 | impl MActions for MActionContext<'_> {} 21 | impl MActions for MActionContext<'_> {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `NonExistent` in implementation | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `NonExistent` in implementation
+16 -11
View File
@@ -17,25 +17,30 @@ exclude = ["examples/microwave_tui.rs"]
default = [] default = []
tokio = ["dep:tokio"] tokio = ["dep:tokio"]
embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"] embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"]
# Auto-tracing backends — pick at most one. When the user opts a chart # Auto-tracing sinks — every chart emits `__chart_observe!` calls
# into `trace;`, the macro emits calls to an internal dispatcher that # unconditionally; whichever of these sinks you enable is what gets
# routes to whichever backend is enabled here. With NO backend feature # textual log lines. Pick zero, one, or any combination — the
# enabled, `trace;` is a no-op (zero overhead). Enabling more than one # observation pipeline fans out to all enabled sinks and the in-memory
# backend produces a duplicate-macro compile error — that's intentional. # journal (below). With NO sink feature enabled, observation is fully
# elided at compile time (zero overhead).
#
# Format: `[statechart:<Name>] <verb> key=value …` (logfmt-style,
# greppable, regex-parseable). The `tracing` backend uses native
# structured fields instead of stringly key=value pairs.
trace-defmt = ["dep:defmt"] trace-defmt = ["dep:defmt"]
trace-log = ["dep:log"] trace-log = ["dep:log"]
trace-tracing = ["dep:tracing"] trace-tracing = ["dep:tracing"]
# Journaling backend. When enabled, the macro emits calls into an # In-memory journal sink. When enabled, every `__chart_observe!` site
# in-memory journal recording every observable atom: entries, exits, # also records a `TraceEvent` into the per-machine `JournalSink`. The
# action invocations, during start/cancel, timer arm/cancel/fire, emits, # journal is the authoritative byte-deterministic atom-by-atom record
# event delivery, transitions, and termination. Used to make charts # of chart execution; trace backends are textual renderings of the
# replay-deterministic and to catch semantic regressions in CI. # same vocabulary.
# Requires `alloc` (the journal grows dynamically). Off by default — # Requires `alloc` (the journal grows dynamically). Off by default —
# zero overhead and no `alloc` requirement when not enabled. # zero overhead and no `alloc` requirement when not enabled.
journal = [] journal = []
[dependencies] [dependencies]
hsmc-macros = { path = "../hsmc-macros", version = "0.3.0" } hsmc-macros = { path = "../hsmc-macros", version = "0.4.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 }
+55 -14
View File
@@ -45,6 +45,22 @@ pub enum ActionKind {
Handler, Handler,
} }
/// Why a transition fired. Carried on `TransitionFired` so observers can
/// see *what triggered* the state change, not just that one happened.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TransitionReason {
/// External or queued event with the given index dispatched into a
/// handler that fired this transition.
Event { event: u16 },
/// Timer on the given (state, timer) slot expired and its handler
/// fired this transition.
Timer { state: u16, timer: u16 },
/// Driven from inside the runtime without an originating event or
/// timer (e.g. default-descent into a child after entering a
/// composite state). Reserved; not currently emitted.
Internal,
}
/// One observable atom of chart execution. The macro emits these at every /// One observable atom of chart execution. The macro emits these at every
/// hook point so the full sequence can be journaled and compared. /// hook point so the full sequence can be journaled and compared.
/// ///
@@ -54,14 +70,23 @@ pub enum ActionKind {
pub enum TraceEvent { pub enum TraceEvent {
/// First event in any journal. Identifies the chart definition. /// First event in any journal. Identifies the chart definition.
Started { chart_hash: u64 }, Started { chart_hash: u64 },
/// A state's entry begins. Followed by `ActionInvoked{Entry}` for each /// A state's entry began. The marker is emitted before any of the
/// of that state's entry actions, then `DuringStarted` for each during /// state's entry actions, timers, or durings have run. Followed by
/// declared on the state, then potentially the next state's `Entered` /// `ActionInvoked{Entry}`, `TimerArmed`, `DuringStarted`, then the
/// (default-descent). /// matching `Entered` end marker.
EnterBegan { state: u16 },
/// A state's entry completed. Emitted after every entry action,
/// timer arm, and during start has run for that state. Pairs with
/// the prior `EnterBegan { state }`.
Entered { state: u16 }, Entered { state: u16 },
/// A state's exit completed. Preceded by `DuringCancelled` for each /// A state's exit began. The marker is emitted before any of the
/// during on the state, followed by `ActionInvoked{Exit}` for each /// state's during cancellations, timer cancellations, or exit
/// exit action. /// actions have run. Followed by `DuringCancelled`, `TimerCancelled`,
/// `ActionInvoked{Exit}`, then the matching `Exited` end marker.
ExitBegan { state: u16 },
/// A state's exit completed. Emitted after every during cancel,
/// timer cancel, and exit action has run for that state. Pairs with
/// the prior `ExitBegan { state }`.
Exited { state: u16 }, Exited { state: u16 },
/// An action function was called. /// An action function was called.
ActionInvoked { ActionInvoked {
@@ -75,14 +100,30 @@ pub enum TraceEvent {
/// A `during:` activity was cancelled because its owning state is /// A `during:` activity was cancelled because its owning state is
/// being exited. /// being exited.
DuringCancelled { state: u16, during: u16 }, DuringCancelled { state: u16, during: u16 },
/// A transition is about to fire. `from` is the innermost active state /// A transition is about to fire. `from` is the innermost active
/// at the moment the transition was triggered; `to` is the target. /// state at the moment the transition was triggered; `to` is the
/// Followed by the actual `Exited` / `Entered` / etc. events that /// target; `reason` records what caused the transition (event,
/// implement the transition. /// timer, or internal). Followed by the actual `ExitBegan`/`Exited`/
TransitionFired { from: Option<u16>, to: u16 }, /// `EnterBegan`/`Entered` events that implement the transition,
/// then the matching `TransitionComplete` end marker.
TransitionFired {
from: Option<u16>,
to: u16,
reason: TransitionReason,
},
/// A transition completed. Emitted after the transition's exit and
/// entry sequences are fully done. Pairs with the prior
/// `TransitionFired { from, to, .. }`.
TransitionComplete { from: Option<u16>, to: u16 },
/// An event was popped from the internal queue and is about to be
/// dispatched. Emitted before the handler search; followed by either
/// `EventDelivered` (handler found and ran) or `EventDropped`
/// (no handler on the active path), or `TerminateRequested` if the
/// event matches the chart's terminate declaration.
EventReceived { event: u16 },
/// An event reached a state's handler. `handler_state` is the state /// An event reached a state's handler. `handler_state` is the state
/// whose handler ran (which may be an ancestor of the innermost active /// whose handler ran (which may be an ancestor of the innermost
/// state if the event bubbled). /// active state if the event bubbled).
EventDelivered { handler_state: u16, event: u16 }, EventDelivered { handler_state: u16, event: u16 },
/// An event was dispatched but no state on the active path had a /// An event was dispatched but no state on the active path had a
/// handler. Silently dropped per spec. /// handler. Silently dropped per spec.
+590 -116
View File
@@ -102,140 +102,614 @@ pub use hsmc_macros::statechart;
mod error; mod error;
pub use error::HsmcError; pub use error::HsmcError;
// ── Journal (deterministic execution trace) ───────────────────────── // ── Unified observation pipeline ────────────────────────────────────
// //
// When `feature = "journal"` is enabled, the macro emits one // The `statechart!` macro emits ONE `__chart_observe!` call per
// [`TraceEvent`] per observable atom: entries, exits, action calls, // observable atom of execution — entry/exit phase begin/end markers,
// during start/cancel, timer arm/cancel/fire, queued emits, event // action invocations, during start/cancel, timer arm/cancel/fire,
// dispatch, transitions, termination. The events are appended to the // emit queue push/fail, event receive/deliver/drop, transition
// machine's internal `JournalSink` so callers can compare against an // begin/complete, terminate-requested, and terminated.
// expected sequence (see `tests/replay.rs`).
// //
// When the feature is OFF, the codegen still emits `__chart_journal!` // `__chart_observe!` is the single dispatcher: each arm fans the
// invocations at every hook site, but the macro expands to nothing // observation out to whichever sinks are enabled at compile time:
// (zero overhead) and `JournalSink` is a ZST so the field on the //
// machine takes 0 bytes. // - `feature = "journal"` → push a `TraceEvent` into the
// per-machine `JournalSink`.
// - `feature = "trace-defmt"` → emit a `defmt::*` log line.
// - `feature = "trace-log"` → emit a `log::*` log line.
// - `feature = "trace-tracing"` → emit a `tracing::*` event with
// structured fields.
//
// With NO sink feature enabled, every arm body is empty and the
// macro's args are never expanded — zero runtime cost, zero binary
// growth. There is intentionally no catch-all arm: an unrecognized
// invocation produces a compile error, so codegen mismatches are
// caught loudly rather than silently dropped.
//
// Trace and journal share the same vocabulary by construction. The
// trace's textual rendering is machine-parseable (logfmt-style:
// `[statechart:Name] verb key=value …`), so a captured log can be
// diff'd against expected behavior the same way the journal can.
#[cfg(feature = "journal")] #[cfg(feature = "journal")]
mod journal; mod journal;
#[cfg(feature = "journal")] #[cfg(feature = "journal")]
pub use journal::{ActionKind, Journal, TraceEvent}; pub use journal::{ActionKind, Journal, TraceEvent, TransitionReason};
/// Journal call dispatcher invoked by `statechart!`-generated code. /// Internal observation dispatcher invoked by `statechart!`-generated
/// Not part of the public API. /// code. Not part of the public API.
/// ///
/// Call form: `__chart_journal!(<sink-expr>, <TraceEvent expr>)`. /// Call form (one arm per `TraceEvent` variant):
/// The first argument is a place expression for the sink (typically /// `__chart_observe!(<Variant>, <sink-expr>, <chart-literal>, <variant args…>)`
/// `self.__journal`); the second is the full `TraceEvent` value to
/// push.
/// ///
/// When the `journal` feature is off, the macro discards both arguments /// `sink-expr` is the place expression for the journal sink (typically
/// and expands to nothing — the codegen can refer to `TraceEvent::*` /// `&mut self.__journal`); `chart-literal` is the chart name as a
/// without the type having to exist. /// string literal. Variant args are passed positionally — see each arm
#[cfg(feature = "journal")] /// for the order. Names (state, action, event, timer, during) are
/// passed as `&'static str` literals at each call site so the trace
/// formatters never do a runtime lookup.
///
/// All sink-feature gates apply *inside* each arm. With every sink
/// feature off, the arm body is empty; the macro's positional args are
/// never expanded; the call site contributes zero code.
#[doc(hidden)] #[doc(hidden)]
#[macro_export] #[macro_export]
macro_rules! __chart_journal { macro_rules! __chart_observe {
($sink:expr, $event:expr) => { // ── Started ────────────────────────────────────────────────────
$sink.push($event); (Started, $sink:expr, $chart:literal, $hash:expr) => {{
}; #[cfg(feature = "journal")]
} {
$sink.push($crate::TraceEvent::Started { chart_hash: $hash });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] started chart_hash={=u64:#x}",
$chart,
$hash
);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] started chart_hash={:#x}", $chart, $hash);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, chart_hash = $hash, "started");
}};
/// No-op fallback for when `feature = "journal"` is disabled. // ── EnterBegan / Entered ───────────────────────────────────────
#[cfg(not(feature = "journal"))] (EnterBegan, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
#[doc(hidden)] #[cfg(feature = "journal")]
#[macro_export] {
macro_rules! __chart_journal { $sink.push($crate::TraceEvent::EnterBegan { state: $sid });
($($_:tt)*) => {}; }
} #[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] entering state={}", $chart, $sname);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] entering state={}", $chart, $sname);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "entering");
}};
(Entered, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::Entered { state: $sid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] entered state={}", $chart, $sname);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] entered state={}", $chart, $sname);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "entered");
}};
// ── Auto-tracing dispatcher ────────────────────────────────────────── // ── ExitBegan / Exited ─────────────────────────────────────────
// (ExitBegan, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
// When a chart opts in via `trace;`, the proc-macro emits calls to #[cfg(feature = "journal")]
// `::hsmc::__chart_trace!(<kind> <chart-name>, <state-or-action>);` at {
// every state entry, exit, action invocation, and transition. This $sink.push($crate::TraceEvent::ExitBegan { state: $sid });
// crate-level macro picks the backend based on which `trace-*` cargo }
// feature is enabled — exactly one of `trace-defmt` / `trace-log` / #[cfg(feature = "trace-defmt")]
// `trace-tracing`. With none enabled it expands to nothing. ::defmt::info!("[statechart:{}] exiting state={}", $chart, $sname);
// #[cfg(feature = "trace-log")]
// Multiple `trace-*` features enabled simultaneously produce a ::log::info!("[statechart:{}] exiting state={}", $chart, $sname);
// duplicate-definition compile error from rustc. Intentional. #[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "exiting");
}};
(Exited, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::Exited { state: $sid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] exited state={}", $chart, $sname);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] exited state={}", $chart, $sname);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "exited");
}};
/// Internal trace dispatcher invoked by `statechart!`-generated code. // ── ActionInvoked (Entry / Exit / Handler) ─────────────────────
/// Not part of the public API. (ActionInvoked, $sink:expr, $chart:literal,
#[cfg(feature = "trace-defmt")] $sid:expr, $sname:expr,
#[doc(hidden)] $aid:expr, $aname:expr,
#[macro_export] Entry) => {{
macro_rules! __chart_trace { #[cfg(feature = "journal")]
(enter $chart:literal, $state:expr) => { {
::defmt::info!("[{}] enter {}", $chart, $state); $sink.push($crate::TraceEvent::ActionInvoked {
}; state: $sid,
(exit $chart:literal, $state:expr) => { action: $aid,
::defmt::info!("[{}] exit {}", $chart, $state); kind: $crate::ActionKind::Entry,
}; });
(action $chart:literal, $action:expr) => { }
::defmt::trace!("[{}] action {}", $chart, $action); #[cfg(feature = "trace-defmt")]
}; ::defmt::trace!(
(transition $chart:literal, $from:expr, $to:expr) => { "[statechart:{}] action state={} action={} kind=entry",
::defmt::info!("[{}] {} -> {}", $chart, $from, $to); $chart,
}; $sname,
($($_:tt)*) => {}; $aname
} );
#[cfg(feature = "trace-log")]
::log::trace!(
"[statechart:{}] action state={} action={} kind=entry",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-tracing")]
::tracing::trace!(
chart = $chart,
state = $sname,
action = $aname,
kind = "entry",
"action"
);
}};
(ActionInvoked, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$aid:expr, $aname:expr,
Exit) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::ActionInvoked {
state: $sid,
action: $aid,
kind: $crate::ActionKind::Exit,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::trace!(
"[statechart:{}] action state={} action={} kind=exit",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-log")]
::log::trace!(
"[statechart:{}] action state={} action={} kind=exit",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-tracing")]
::tracing::trace!(
chart = $chart,
state = $sname,
action = $aname,
kind = "exit",
"action"
);
}};
(ActionInvoked, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$aid:expr, $aname:expr,
Handler) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::ActionInvoked {
state: $sid,
action: $aid,
kind: $crate::ActionKind::Handler,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::trace!(
"[statechart:{}] action state={} action={} kind=handler",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-log")]
::log::trace!(
"[statechart:{}] action state={} action={} kind=handler",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-tracing")]
::tracing::trace!(
chart = $chart,
state = $sname,
action = $aname,
kind = "handler",
"action"
);
}};
/// Internal trace dispatcher invoked by `statechart!`-generated code. // ── DuringStarted / DuringCancelled ────────────────────────────
/// Not part of the public API. (DuringStarted, $sink:expr, $chart:literal,
#[cfg(feature = "trace-log")] $sid:expr, $sname:expr,
#[doc(hidden)] $did:expr, $dname:expr) => {{
#[macro_export] #[cfg(feature = "journal")]
macro_rules! __chart_trace { {
(enter $chart:literal, $state:expr) => { $sink.push($crate::TraceEvent::DuringStarted {
::log::info!("[{}] enter {}", $chart, $state); state: $sid,
}; during: $did,
(exit $chart:literal, $state:expr) => { });
::log::info!("[{}] exit {}", $chart, $state); }
}; #[cfg(feature = "trace-defmt")]
(action $chart:literal, $action:expr) => { ::defmt::debug!(
::log::trace!("[{}] action {}", $chart, $action); "[statechart:{}] during-started state={} during={}",
}; $chart,
(transition $chart:literal, $from:expr, $to:expr) => { $sname,
::log::info!("[{}] {} -> {}", $chart, $from, $to); $dname
}; );
($($_:tt)*) => {}; #[cfg(feature = "trace-log")]
} ::log::debug!(
"[statechart:{}] during-started state={} during={}",
$chart,
$sname,
$dname
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
during = $dname,
"during-started"
);
}};
(DuringCancelled, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$did:expr, $dname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::DuringCancelled {
state: $sid,
during: $did,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!(
"[statechart:{}] during-cancelled state={} during={}",
$chart,
$sname,
$dname
);
#[cfg(feature = "trace-log")]
::log::debug!(
"[statechart:{}] during-cancelled state={} during={}",
$chart,
$sname,
$dname
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
during = $dname,
"during-cancelled"
);
}};
/// Internal trace dispatcher invoked by `statechart!`-generated code. // ── TransitionFired (begin marker) ─────────────────────────────
/// Not part of the public API. //
#[cfg(feature = "trace-tracing")] // The reason is rendered three ways (event:Name, timer:State/Timer,
#[doc(hidden)] // internal). The journal stores the structured reason and the trace
#[macro_export] // gets a pre-rendered `&'static str` (`$rname`) so log lines stay
macro_rules! __chart_trace { // simple to parse.
(enter $chart:literal, $state:expr) => { (TransitionFired, $sink:expr, $chart:literal,
::tracing::info!(chart = $chart, state = $state, "enter"); $from:expr, $fname:expr,
}; $to:expr, $tname:expr,
(exit $chart:literal, $state:expr) => { $reason:expr, $rname:expr) => {{
::tracing::info!(chart = $chart, state = $state, "exit"); #[cfg(feature = "journal")]
}; {
(action $chart:literal, $action:expr) => { $sink.push($crate::TraceEvent::TransitionFired {
::tracing::trace!(chart = $chart, action = $action, "action"); from: $from,
}; to: $to,
(transition $chart:literal, $from:expr, $to:expr) => { reason: $reason,
::tracing::info!(chart = $chart, from = $from, to = $to, "transition"); });
}; }
($($_:tt)*) => {}; #[cfg(feature = "trace-defmt")]
} ::defmt::info!(
"[statechart:{}] transition-begin from={} to={} reason={}",
$chart,
$fname,
$tname,
$rname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] transition-begin from={} to={} reason={}",
$chart,
$fname,
$tname,
$rname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
from = $fname,
to = $tname,
reason = $rname,
"transition-begin"
);
}};
/// No-op fallback when no `trace-*` backend feature is enabled. The // ── TransitionComplete (end marker) ────────────────────────────
/// chart's `trace;` keyword still parses; calls just expand to nothing. (TransitionComplete, $sink:expr, $chart:literal,
#[cfg(not(any( $from:expr, $fname:expr,
feature = "trace-defmt", $to:expr, $tname:expr) => {{
feature = "trace-log", #[cfg(feature = "journal")]
feature = "trace-tracing", {
)))] $sink.push($crate::TraceEvent::TransitionComplete {
#[doc(hidden)] from: $from,
#[macro_export] to: $to,
macro_rules! __chart_trace { });
($($_:tt)*) => {}; }
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] transition-complete from={} to={}",
$chart,
$fname,
$tname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] transition-complete from={} to={}",
$chart,
$fname,
$tname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
from = $fname,
to = $tname,
"transition-complete"
);
}};
// ── Event lifecycle ────────────────────────────────────────────
(EventReceived, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EventReceived { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!("[statechart:{}] event-received name={}", $chart, $ename);
#[cfg(feature = "trace-log")]
::log::debug!("[statechart:{}] event-received name={}", $chart, $ename);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(chart = $chart, name = $ename, "event-received");
}};
(EventDelivered, $sink:expr, $chart:literal,
$eid:expr, $ename:expr,
$sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EventDelivered {
handler_state: $sid,
event: $eid,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] event-delivered name={} handler={}",
$chart,
$ename,
$sname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] event-delivered name={} handler={}",
$chart,
$ename,
$sname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
name = $ename,
handler = $sname,
"event-delivered"
);
}};
(EventDropped, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EventDropped { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!("[statechart:{}] event-dropped name={}", $chart, $ename);
#[cfg(feature = "trace-log")]
::log::debug!("[statechart:{}] event-dropped name={}", $chart, $ename);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(chart = $chart, name = $ename, "event-dropped");
}};
// ── emit() outcomes ────────────────────────────────────────────
(EmitQueued, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EmitQueued { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!("[statechart:{}] emit-queued event={}", $chart, $ename);
#[cfg(feature = "trace-log")]
::log::debug!("[statechart:{}] emit-queued event={}", $chart, $ename);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(chart = $chart, event = $ename, "emit-queued");
}};
(EmitFailed, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EmitFailed { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::warn!(
"[statechart:{}] emit-failed event={} reason=queue-full",
$chart,
$ename
);
#[cfg(feature = "trace-log")]
::log::warn!(
"[statechart:{}] emit-failed event={} reason=queue-full",
$chart,
$ename
);
#[cfg(feature = "trace-tracing")]
::tracing::warn!(
chart = $chart,
event = $ename,
reason = "queue-full",
"emit-failed"
);
}};
// ── Timer lifecycle ────────────────────────────────────────────
(TimerArmed, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$tid:expr, $tname:expr,
$ns:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TimerArmed {
state: $sid,
timer: $tid,
ns: $ns,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!(
"[statechart:{}] timer-armed state={} timer={} duration_ns={=u64}",
$chart,
$sname,
$tname,
$ns
);
#[cfg(feature = "trace-log")]
::log::debug!(
"[statechart:{}] timer-armed state={} timer={} duration_ns={}",
$chart,
$sname,
$tname,
$ns
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
timer = $tname,
duration_ns = $ns,
"timer-armed"
);
}};
(TimerCancelled, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$tid:expr, $tname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TimerCancelled {
state: $sid,
timer: $tid,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!(
"[statechart:{}] timer-cancelled state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-log")]
::log::debug!(
"[statechart:{}] timer-cancelled state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
timer = $tname,
"timer-cancelled"
);
}};
(TimerFired, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$tid:expr, $tname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TimerFired {
state: $sid,
timer: $tid,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] timer-fired state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] timer-fired state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
state = $sname,
timer = $tname,
"timer-fired"
);
}};
// ── Termination ────────────────────────────────────────────────
(TerminateRequested, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TerminateRequested { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] terminate-requested event={}",
$chart,
$ename
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] terminate-requested event={}",
$chart,
$ename
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, event = $ename, "terminate-requested");
}};
(Terminated, $sink:expr, $chart:literal) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::Terminated);
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] terminated", $chart);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] terminated", $chart);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, "terminated");
}};
} }
#[doc(hidden)] #[doc(hidden)]
+19 -6
View File
@@ -12,7 +12,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -117,9 +117,12 @@ async fn det_descent_target_with_three_default_levels() {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Desc::<8>::CHART_HASH, chart_hash: Desc::<8>::CHART_HASH,
}, },
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
TraceEvent::Entered { state: SI }, TraceEvent::EnterBegan { state: SI },
entry(SI, A_IDLE_IN), entry(SI, A_IDLE_IN),
TraceEvent::Entered { state: SI },
TraceEvent::EventReceived { event: E_GO },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SI, handler_state: SI,
event: E_GO, event: E_GO,
@@ -127,17 +130,27 @@ async fn det_descent_target_with_three_default_levels() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SI), from: Some(SI),
to: ST, to: ST,
reason: TransitionReason::Event { event: E_GO },
}, },
TraceEvent::ExitBegan { state: SI },
exit_(SI, A_IDLE_OUT), exit_(SI, A_IDLE_OUT),
TraceEvent::Exited { state: SI }, TraceEvent::Exited { state: SI },
TraceEvent::Entered { state: ST }, TraceEvent::EnterBegan { state: ST },
entry(ST, A_TOWER_IN), entry(ST, A_TOWER_IN),
TraceEvent::Entered { state: SL1 }, TraceEvent::Entered { state: ST },
TraceEvent::EnterBegan { state: SL1 },
entry(SL1, A_L1_IN), entry(SL1, A_L1_IN),
TraceEvent::Entered { state: SL2 }, TraceEvent::Entered { state: SL1 },
TraceEvent::EnterBegan { state: SL2 },
entry(SL2, A_L2_IN), entry(SL2, A_L2_IN),
TraceEvent::Entered { state: SL3 }, TraceEvent::Entered { state: SL2 },
TraceEvent::EnterBegan { state: SL3 },
entry(SL3, A_L3_IN), entry(SL3, A_L3_IN),
TraceEvent::Entered { state: SL3 },
TraceEvent::TransitionComplete {
from: Some(SI),
to: ST,
},
]; ];
assert_eq!(actual, expected); assert_eq!(actual, expected);
+16 -5
View File
@@ -127,27 +127,38 @@ async fn det_initial_descent_records_full_path() {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Init4::<8>::CHART_HASH, chart_hash: Init4::<8>::CHART_HASH,
}, },
TraceEvent::Entered { state: SR }, TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN), entry(SR, A_R_IN),
TraceEvent::Entered { state: SA }, TraceEvent::Entered { state: SR },
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SB }, TraceEvent::Entered { state: SA },
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SC }, TraceEvent::Entered { state: SB },
TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN), entry(SC, A_C_IN),
TraceEvent::Entered { state: SD }, TraceEvent::Entered { state: SC },
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SD },
// Halt. // Halt.
TraceEvent::EventReceived { event: E_HALT },
TraceEvent::TerminateRequested { event: E_HALT }, TraceEvent::TerminateRequested { event: E_HALT },
// Bottom-up exit chain. // Bottom-up exit chain.
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT), exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD }, TraceEvent::Exited { state: SD },
TraceEvent::ExitBegan { state: SC },
exit_(SC, A_C_OUT), exit_(SC, A_C_OUT),
TraceEvent::Exited { state: SC }, TraceEvent::Exited { state: SC },
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT), exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB }, TraceEvent::Exited { state: SB },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SR },
exit_(SR, A_R_OUT), exit_(SR, A_R_OUT),
TraceEvent::Exited { state: SR }, TraceEvent::Exited { state: SR },
TraceEvent::Terminated, TraceEvent::Terminated,
+73 -12
View File
@@ -11,7 +11,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -146,11 +146,14 @@ fn initial_descent() -> Vec<TraceEvent> {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Lat::<8>::CHART_HASH, chart_hash: Lat::<8>::CHART_HASH,
}, },
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
TraceEvent::Entered { state: SP }, TraceEvent::EnterBegan { state: SP },
entry(SP, A_P_IN), entry(SP, A_P_IN),
TraceEvent::Entered { state: SA }, TraceEvent::Entered { state: SP },
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SA },
] ]
} }
@@ -185,6 +188,7 @@ async fn det_lateral_a_to_b_lca_is_parent() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOB },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SA, handler_state: SA,
event: E_GOB, event: E_GOB,
@@ -192,11 +196,18 @@ async fn det_lateral_a_to_b_lca_is_parent() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SA), from: Some(SA),
to: SB, to: SB,
reason: TransitionReason::Event { event: E_GOB },
}, },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::Entered { state: SB }, TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -215,6 +226,7 @@ async fn det_lateral_a_to_c_exits_parent() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOC },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SA, handler_state: SA,
event: E_GOC, event: E_GOC,
@@ -222,13 +234,21 @@ async fn det_lateral_a_to_c_exits_parent() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SA), from: Some(SA),
to: SC, to: SC,
reason: TransitionReason::Event { event: E_GOC },
}, },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT), exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP }, TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SC }, TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN), entry(SC, A_C_IN),
TraceEvent::Entered { state: SC },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SC,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -246,6 +266,7 @@ async fn det_lateral_target_with_children_default_descends() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOD },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SA, handler_state: SA,
event: E_GOD, event: E_GOD,
@@ -253,15 +274,24 @@ async fn det_lateral_target_with_children_default_descends() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SA), from: Some(SA),
to: SD, to: SD,
reason: TransitionReason::Event { event: E_GOD },
}, },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT), exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP }, TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SD }, TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SE }, TraceEvent::Entered { state: SD },
TraceEvent::EnterBegan { state: SE },
entry(SE, A_E_IN), entry(SE, A_E_IN),
TraceEvent::Entered { state: SE },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SD,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -280,6 +310,7 @@ async fn det_lateral_to_deeply_nested_target() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOE },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SA, handler_state: SA,
event: E_GOE, event: E_GOE,
@@ -287,15 +318,24 @@ async fn det_lateral_to_deeply_nested_target() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SA), from: Some(SA),
to: SE, to: SE,
reason: TransitionReason::Event { event: E_GOE },
}, },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT), exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP }, TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SD }, TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SE }, TraceEvent::Entered { state: SD },
TraceEvent::EnterBegan { state: SE },
entry(SE, A_E_IN), entry(SE, A_E_IN),
TraceEvent::Entered { state: SE },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SE,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -316,6 +356,7 @@ async fn det_lateral_chained_transitions() {
let mut expected = initial_descent(); let mut expected = initial_descent();
// A → B // A → B
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOB },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SA, handler_state: SA,
event: E_GOB, event: E_GOB,
@@ -323,14 +364,22 @@ async fn det_lateral_chained_transitions() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SA), from: Some(SA),
to: SB, to: SB,
reason: TransitionReason::Event { event: E_GOB },
}, },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::Entered { state: SB }, TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
]); ]);
// B → C (LCA = Root, so Parent exits too). // B → C (LCA = Root, so Parent exits too).
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_GOC },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SB, handler_state: SB,
event: E_GOC, event: E_GOC,
@@ -338,13 +387,21 @@ async fn det_lateral_chained_transitions() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SB), from: Some(SB),
to: SC, to: SC,
reason: TransitionReason::Event { event: E_GOC },
}, },
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT), exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB }, TraceEvent::Exited { state: SB },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT), exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP }, TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SC }, TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN), entry(SC, A_C_IN),
TraceEvent::Entered { state: SC },
TraceEvent::TransitionComplete {
from: Some(SB),
to: SC,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -360,9 +417,13 @@ async fn det_lateral_lca_stays_active() {
let _ = m.dispatch(Ev::GoB).await; let _ = m.dispatch(Ev::GoB).await;
let j = m.take_journal(); let j = m.take_journal();
// After initial Entered(Parent), Parent must not appear again. // After initial Entered(Parent), Parent must not appear again.
let initial_p_idx = j
.iter()
.position(|e| matches!(e, TraceEvent::Entered { state: SP }))
.unwrap();
let parent_events: Vec<&TraceEvent> = j let parent_events: Vec<&TraceEvent> = j
.iter() .iter()
.skip(3) .skip(initial_p_idx + 1)
.filter(|e| { .filter(|e| {
matches!( matches!(
e, e,
+15 -4
View File
@@ -17,7 +17,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -101,12 +101,16 @@ async fn det_root_targetable_by_chart_name() {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Sub::<8>::CHART_HASH, chart_hash: Sub::<8>::CHART_HASH,
}, },
TraceEvent::Entered { state: SR }, TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN), entry(SR, A_R_IN),
TraceEvent::Entered { state: SA }, TraceEvent::Entered { state: SR },
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SB }, TraceEvent::Entered { state: SA },
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::EventReceived { event: E_UPTOROOT },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SB, handler_state: SB,
event: E_UPTOROOT, event: E_UPTOROOT,
@@ -114,11 +118,18 @@ async fn det_root_targetable_by_chart_name() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SB), from: Some(SB),
to: SR, to: SR,
reason: TransitionReason::Event { event: E_UPTOROOT },
}, },
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT), exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB }, TraceEvent::Exited { state: SB },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT), exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA }, TraceEvent::Exited { state: SA },
TraceEvent::TransitionComplete {
from: Some(SB),
to: SR,
},
]; ];
assert_eq!( assert_eq!(
+24 -5
View File
@@ -13,7 +13,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -86,11 +86,14 @@ fn initial_descent() -> Vec<TraceEvent> {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Self_::<8>::CHART_HASH, chart_hash: Self_::<8>::CHART_HASH,
}, },
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
TraceEvent::Entered { state: SM }, TraceEvent::EnterBegan { state: SM },
entry(SM, A_M_IN), entry(SM, A_M_IN),
TraceEvent::Entered { state: SL }, TraceEvent::Entered { state: SM },
TraceEvent::EnterBegan { state: SL },
entry(SL, A_L_IN), entry(SL, A_L_IN),
TraceEvent::Entered { state: SL },
] ]
} }
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) { fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
@@ -123,6 +126,7 @@ async fn det_self_leaf_exits_and_reenters_only_leaf() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_SELFLEAF },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SL, handler_state: SL,
event: E_SELFLEAF, event: E_SELFLEAF,
@@ -130,11 +134,18 @@ async fn det_self_leaf_exits_and_reenters_only_leaf() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SL), from: Some(SL),
to: SL, to: SL,
reason: TransitionReason::Event { event: E_SELFLEAF },
}, },
TraceEvent::ExitBegan { state: SL },
exit_(SL, A_L_OUT), exit_(SL, A_L_OUT),
TraceEvent::Exited { state: SL }, TraceEvent::Exited { state: SL },
TraceEvent::Entered { state: SL }, TraceEvent::EnterBegan { state: SL },
entry(SL, A_L_IN), entry(SL, A_L_IN),
TraceEvent::Entered { state: SL },
TraceEvent::TransitionComplete {
from: Some(SL),
to: SL,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -209,6 +220,7 @@ async fn det_self_composite_default_descends() {
// Mid's entry actions don't fire. No default-descent. // Mid's entry actions don't fire. No default-descent.
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_SELFMID },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SM, handler_state: SM,
event: E_SELFMID, event: E_SELFMID,
@@ -216,9 +228,15 @@ async fn det_self_composite_default_descends() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SL), from: Some(SL),
to: SM, to: SM,
reason: TransitionReason::Event { event: E_SELFMID },
}, },
TraceEvent::ExitBegan { state: SL },
exit_(SL, A_L_OUT), exit_(SL, A_L_OUT),
TraceEvent::Exited { state: SL }, TraceEvent::Exited { state: SL },
TraceEvent::TransitionComplete {
from: Some(SL),
to: SM,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -257,7 +275,8 @@ async fn det_self_leaf_repeated_is_deterministic() {
e, e,
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SL), from: Some(SL),
to: SL to: SL,
..
} }
) )
}) })
+4 -2
View File
@@ -85,7 +85,8 @@ async fn det_self_composite_default_descends_after_re_entry() {
e, e,
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SP), from: Some(SP),
to: SP to: SP,
..
} }
) )
}) })
@@ -133,7 +134,8 @@ async fn det_self_composite_action_kinds_in_order() {
e, e,
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SP), from: Some(SP),
to: SP to: SP,
..
} }
) )
}) })
+35 -6
View File
@@ -15,7 +15,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -117,16 +117,21 @@ fn initial_descent() -> Vec<TraceEvent> {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Up::<8>::CHART_HASH, chart_hash: Up::<8>::CHART_HASH,
}, },
TraceEvent::Entered { state: SR }, TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN), entry(SR, A_R_IN),
TraceEvent::Entered { state: SA }, TraceEvent::Entered { state: SR },
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SB }, TraceEvent::Entered { state: SA },
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SC }, TraceEvent::Entered { state: SB },
TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN), entry(SC, A_C_IN),
TraceEvent::Entered { state: SD }, TraceEvent::Entered { state: SC },
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SD },
] ]
} }
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) { fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
@@ -159,6 +164,7 @@ async fn det_up_to_immediate_grandparent() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_UPTOC },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SD, handler_state: SD,
event: E_UPTOC, event: E_UPTOC,
@@ -166,9 +172,15 @@ async fn det_up_to_immediate_grandparent() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SD), from: Some(SD),
to: SC, to: SC,
reason: TransitionReason::Event { event: E_UPTOC },
}, },
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT), exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD }, TraceEvent::Exited { state: SD },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SC,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -186,6 +198,7 @@ async fn det_up_two_levels() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_UPTOB },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SD, handler_state: SD,
event: E_UPTOB, event: E_UPTOB,
@@ -193,11 +206,18 @@ async fn det_up_two_levels() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SD), from: Some(SD),
to: SB, to: SB,
reason: TransitionReason::Event { event: E_UPTOB },
}, },
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT), exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD }, TraceEvent::Exited { state: SD },
TraceEvent::ExitBegan { state: SC },
exit_(SC, A_C_OUT), exit_(SC, A_C_OUT),
TraceEvent::Exited { state: SC }, TraceEvent::Exited { state: SC },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SB,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
@@ -215,6 +235,7 @@ async fn det_up_three_levels_to_a() {
let mut expected = initial_descent(); let mut expected = initial_descent();
expected.extend(vec![ expected.extend(vec![
TraceEvent::EventReceived { event: E_UPTOA },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SD, handler_state: SD,
event: E_UPTOA, event: E_UPTOA,
@@ -222,13 +243,21 @@ async fn det_up_three_levels_to_a() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(SD), from: Some(SD),
to: SA, to: SA,
reason: TransitionReason::Event { event: E_UPTOA },
}, },
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT), exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD }, TraceEvent::Exited { state: SD },
TraceEvent::ExitBegan { state: SC },
exit_(SC, A_C_OUT), exit_(SC, A_C_OUT),
TraceEvent::Exited { state: SC }, TraceEvent::Exited { state: SC },
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT), exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB }, TraceEvent::Exited { state: SB },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SA,
},
]); ]);
assert_journal(&actual, &expected); assert_journal(&actual, &expected);
}) })
+20 -5
View File
@@ -12,7 +12,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -97,19 +97,22 @@ async fn replay_full_lifecycle_matches_expected() {
TraceEvent::Started { TraceEvent::Started {
chart_hash: Replay::<8>::CHART_HASH, chart_hash: Replay::<8>::CHART_HASH,
}, },
TraceEvent::Entered { state: ST_ROOT }, TraceEvent::EnterBegan { state: ST_ROOT },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_ROOT, state: ST_ROOT,
action: A_ROOT_IN, action: A_ROOT_IN,
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_IDLE }, TraceEvent::Entered { state: ST_ROOT },
TraceEvent::EnterBegan { state: ST_IDLE },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_IDLE, state: ST_IDLE,
action: A_IDLE_IN, action: A_IDLE_IN,
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_IDLE },
// ── Go event delivered to Idle, transition to Active → Sub ── // ── Go event delivered to Idle, transition to Active → Sub ──
TraceEvent::EventReceived { event: E_GO },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: ST_IDLE, handler_state: ST_IDLE,
event: E_GO, event: E_GO,
@@ -117,39 +120,51 @@ async fn replay_full_lifecycle_matches_expected() {
TraceEvent::TransitionFired { TraceEvent::TransitionFired {
from: Some(ST_IDLE), from: Some(ST_IDLE),
to: ST_ACTIVE, to: ST_ACTIVE,
reason: TransitionReason::Event { event: E_GO },
}, },
TraceEvent::ExitBegan { state: ST_IDLE },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_IDLE, state: ST_IDLE,
action: A_IDLE_OUT, action: A_IDLE_OUT,
kind: ActionKind::Exit, kind: ActionKind::Exit,
}, },
TraceEvent::Exited { state: ST_IDLE }, TraceEvent::Exited { state: ST_IDLE },
TraceEvent::Entered { state: ST_ACTIVE }, TraceEvent::EnterBegan { state: ST_ACTIVE },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_ACTIVE, state: ST_ACTIVE,
action: A_ACTIVE_IN, action: A_ACTIVE_IN,
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_SUB }, TraceEvent::Entered { state: ST_ACTIVE },
TraceEvent::EnterBegan { state: ST_SUB },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_SUB, state: ST_SUB,
action: A_SUB_IN, action: A_SUB_IN,
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_SUB },
TraceEvent::TransitionComplete {
from: Some(ST_IDLE),
to: ST_ACTIVE,
},
// ── Halt event triggers termination: exit Sub → Active → root, then Terminated ── // ── Halt event triggers termination: exit Sub → Active → root, then Terminated ──
TraceEvent::EventReceived { event: E_HALT },
TraceEvent::TerminateRequested { event: E_HALT }, TraceEvent::TerminateRequested { event: E_HALT },
TraceEvent::ExitBegan { state: ST_SUB },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_SUB, state: ST_SUB,
action: A_SUB_OUT, action: A_SUB_OUT,
kind: ActionKind::Exit, kind: ActionKind::Exit,
}, },
TraceEvent::Exited { state: ST_SUB }, TraceEvent::Exited { state: ST_SUB },
TraceEvent::ExitBegan { state: ST_ACTIVE },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_ACTIVE, state: ST_ACTIVE,
action: A_ACTIVE_OUT, action: A_ACTIVE_OUT,
kind: ActionKind::Exit, kind: ActionKind::Exit,
}, },
TraceEvent::Exited { state: ST_ACTIVE }, TraceEvent::Exited { state: ST_ACTIVE },
TraceEvent::ExitBegan { state: ST_ROOT },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_ROOT, state: ST_ROOT,
action: A_ROOT_OUT, action: A_ROOT_OUT,
+429
View File
@@ -0,0 +1,429 @@
//! Verifies the textual format of the unified observation pipeline's
//! `trace-log` sink line-for-line.
//!
//! Each `__chart_observe!` arm has a stable logfmt-style format:
//!
//! `[statechart:<Name>] <verb> key=value key=value …`
//!
//! These tests pin every verb's exact rendering so a real-life log capture
//! can be diffed against expected behavior. The chart is exercised through
//! both the journal sink (so we can assert atom-by-atom equivalence) and
//! the trace-log sink (so we can assert the rendered strings).
#![cfg(all(feature = "tokio", feature = "journal", feature = "trace-log"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, Duration, TraceEvent, TransitionReason};
use std::sync::{Mutex, MutexGuard, OnceLock};
// ── Capture sink ─────────────────────────────────────────────────────
//
// log::set_logger can only be installed once per process. The capture
// holds a single Vec<String> shared across all tests, plus a global
// `TEST_LOCK` that each test holds across its drive-then-assert
// section so concurrent tests don't interleave their captured lines.
struct Capture {
lines: Mutex<Vec<String>>,
}
impl log::Log for Capture {
fn enabled(&self, _: &log::Metadata<'_>) -> bool {
true
}
fn log(&self, record: &log::Record<'_>) {
if let Ok(mut g) = self.lines.lock() {
g.push(format!("{}", record.args()));
}
}
fn flush(&self) {}
}
static CAPTURE: OnceLock<&'static Capture> = OnceLock::new();
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn install_capture() -> &'static Capture {
*CAPTURE.get_or_init(|| {
let leaked: &'static Capture = Box::leak(Box::new(Capture {
lines: Mutex::new(Vec::new()),
}));
let _ = log::set_logger(leaked);
log::set_max_level(log::LevelFilter::Trace);
leaked
})
}
/// RAII helper. Acquire before driving the chart, drop after assertions
/// — keeps `tests::*` from interleaving captured log lines.
fn lock_for_test() -> MutexGuard<'static, ()> {
install_capture();
let g = TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
// Clear any lines left by a previous test that paniced before
// draining (poison case).
let cap = CAPTURE.get().unwrap();
cap.lines.lock().unwrap().clear();
g
}
fn current_lines() -> Vec<String> {
let cap = install_capture();
cap.lines.lock().unwrap().clone()
}
// ── Test chart ───────────────────────────────────────────────────────
//
// Covers every observation verb the macro can emit. Two states with
// entry/exit actions, a handler that emits and transitions, a timer,
// a during, and a terminate event.
#[derive(Default)]
pub struct Ctx {
pub buf: u32,
}
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Step,
Halt,
}
statechart! {
TraceFmt {
context: Ctx;
events: Ev;
default(Idle);
terminate(Halt);
state Idle {
entry: idle_in;
exit: idle_out;
during: tick(buf);
on(after Duration::from_secs(60)) => Idle;
on(Go) => Active;
}
state Active {
entry: active_in;
exit: active_out;
on(Step) => bump;
on(Halt) => Idle;
}
}
}
async fn tick(_buf: &mut u32) -> Ev {
Ev::Step
}
impl TraceFmtActions for TraceFmtActionContext<'_> {
async fn idle_in(&mut self) {}
async fn idle_out(&mut self) {}
async fn active_in(&mut self) {}
async fn active_out(&mut self) {}
async fn bump(&mut self) {
let _ = self.emit(Ev::Halt);
}
}
// ── Helpers ──────────────────────────────────────────────────────────
/// Returns true iff `line` matches the expected `[statechart:TraceFmt] verb …`
/// shape. Every line emitted by the dispatcher must.
fn well_formed(line: &str) -> bool {
line.starts_with("[statechart:TraceFmt] ")
}
/// Filter `lines` to only those whose verb (the first token after the
/// prefix) equals `verb`. Useful for asserting on a single verb's lines.
fn lines_with_verb<'a>(lines: &'a [String], verb: &str) -> Vec<&'a str> {
let prefix = "[statechart:TraceFmt] ";
lines
.iter()
.filter_map(|l| l.strip_prefix(prefix))
.filter(|rest| rest.split_whitespace().next() == Some(verb))
.map(|s| s.as_ref())
.collect()
}
// ── Tests ────────────────────────────────────────────────────────────
#[tokio::test(flavor = "current_thread")]
async fn every_line_uses_statechart_prefix() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
let _ = m.dispatch(Ev::Halt).await;
})
.await;
let lines = current_lines();
assert!(!lines.is_empty(), "no log lines captured");
for l in &lines {
assert!(
well_formed(l),
"line missing [statechart:TraceFmt] prefix: {l:?}"
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn enter_and_exit_emit_begin_end_pairs() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let entering = lines_with_verb(&lines, "entering");
let entered = lines_with_verb(&lines, "entered");
assert_eq!(
entering.len(),
entered.len(),
"every `entering` must have a matching `entered`"
);
assert!(entering.contains(&"entering state=TraceFmt"));
assert!(entered.contains(&"entered state=TraceFmt"));
assert!(entering.contains(&"entering state=Idle"));
assert!(entered.contains(&"entered state=Idle"));
}
#[tokio::test(flavor = "current_thread")]
async fn transition_records_event_reason() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let begins = lines_with_verb(&lines, "transition-begin");
assert!(!begins.is_empty(), "no transition-begin lines captured");
let line = begins[0];
assert!(
line.contains("from=Idle"),
"transition-begin missing from=Idle: {line:?}"
);
assert!(
line.contains("to=Active"),
"transition-begin missing to=Active: {line:?}"
);
assert!(
line.contains("reason=event:Go"),
"transition-begin missing reason=event:Go: {line:?}"
);
let completes = lines_with_verb(&lines, "transition-complete");
assert!(
!completes.is_empty(),
"transition-complete must follow transition-begin"
);
assert!(completes[0].contains("from=Idle") && completes[0].contains("to=Active"));
}
#[tokio::test(flavor = "current_thread")]
async fn event_lifecycle_received_then_outcome() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let received = lines_with_verb(&lines, "event-received");
let delivered = lines_with_verb(&lines, "event-delivered");
assert!(received.iter().any(|l| l.contains("name=Go")));
assert!(delivered
.iter()
.any(|l| l.contains("name=Go") && l.contains("handler=Idle")));
}
#[tokio::test(flavor = "current_thread")]
async fn during_emits_started_and_cancelled() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
// Go enters Active, which causes Idle's during to be cancelled.
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let started = lines_with_verb(&lines, "during-started");
let cancelled = lines_with_verb(&lines, "during-cancelled");
assert!(started.iter().any(|l| l.contains("during=tick")));
assert!(cancelled.iter().any(|l| l.contains("during=tick")));
}
#[tokio::test(flavor = "current_thread")]
async fn timer_arm_then_cancel_lines() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let armed = lines_with_verb(&lines, "timer-armed");
let cancelled = lines_with_verb(&lines, "timer-cancelled");
assert!(
armed.iter().any(|l| l.contains("state=Idle")),
"timer should arm when Idle is entered"
);
assert!(
cancelled.iter().any(|l| l.contains("state=Idle")),
"timer should be cancelled when Idle is exited (Go transition)"
);
}
#[tokio::test(flavor = "current_thread")]
async fn emit_queued_appears_for_handler_emit() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
// Step's handler calls emit(Halt).
let _ = m.dispatch(Ev::Step).await;
})
.await;
let lines = current_lines();
let queued = lines_with_verb(&lines, "emit-queued");
assert!(
queued.iter().any(|l| l.contains("event=Halt")),
"expected emit-queued event=Halt line: {lines:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn terminate_records_request_then_terminated() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
// Halt from Active is a transition to Idle (no terminate);
// dispatch Halt twice via Idle to actually terminate.
// First Go landed us in Active; Halt → Idle (handler).
let _ = m.dispatch(Ev::Halt).await; // back to Idle
let _ = m.dispatch(Ev::Halt).await; // now terminate
})
.await;
let lines = current_lines();
let req = lines_with_verb(&lines, "terminate-requested");
let term = lines_with_verb(&lines, "terminated");
assert!(
req.iter().any(|l| l.contains("event=Halt")),
"expected terminate-requested event=Halt: {lines:?}"
);
assert!(
!term.is_empty(),
"expected a final `terminated` line: {lines:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn started_records_chart_hash() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let started = lines_with_verb(&lines, "started");
assert!(!started.is_empty(), "expected one `started` line");
assert!(
started[0].contains("chart_hash=0x"),
"started must include chart_hash in hex: {:?}",
started[0]
);
}
#[tokio::test(flavor = "current_thread")]
async fn trace_and_journal_have_matching_atom_count() {
// The "one journal, multiple outputs" contract: every TraceEvent
// recorded in the journal corresponds to exactly one log line in
// the trace, modulo verb-name differences.
let _g = lock_for_test();
let actual = tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
m.take_journal()
})
.await;
let lines = current_lines();
assert_eq!(
actual.len(),
lines.len(),
"journal/trace atom count diverged ({} vs {} lines)",
actual.len(),
lines.len()
);
}
#[tokio::test(flavor = "current_thread")]
async fn transition_reason_in_journal_matches_trace() {
// The journal records the structured `TransitionReason::Event { event }`
// and the trace renders it as `event:<VariantName>`. They must agree.
let _g = lock_for_test();
let actual = tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
m.take_journal()
})
.await;
let lines = current_lines();
let journal_reason = actual.iter().find_map(|e| match e {
TraceEvent::TransitionFired { reason, .. } => Some(*reason),
_ => None,
});
let trace_reason_line = lines_with_verb(&lines, "transition-begin")
.first()
.copied()
.map(|s| s.to_string());
assert_eq!(
journal_reason,
Some(TransitionReason::Event { event: 0 }),
"expected first transition driven by Ev::Go (event id 0)"
);
assert!(
trace_reason_line
.as_deref()
.is_some_and(|l| l.contains("reason=event:Go")),
"trace reason should match journal reason: {trace_reason_line:?}"
);
}