fix!: up-transitions no longer exit and re-enter the target ancestor

A transition whose target is already active (an ancestor of the
innermost active state) now unwinds only the subtree strictly below
the target. The target's entry/exit actions do not fire and its
default() does not re-descend — you cannot re-enter a state you never
left. The old behavior could loop infinitely when a child transitioned
to its own parent.

Self-transitions (target == innermost) and lateral transitions are
unchanged. See spec §2.6 and tests §T2.8 / T2.8b / T2.8c.

Also in this release:

* Full workspace justfile (check, test, coverage, mutants, pre-release)
* Unit tests covering all __private internals; `cargo mutants` now exits
  clean (38 caught, 178 unviable, 0 missed, 0 timeouts).
* .config/nextest.toml with slow-timeout/terminate-after so hung tests
  fail fast rather than wedging cargo-mutants runs.

BREAKING CHANGE: transition-to-ancestor semantics. Machines that relied
on the old re-entry behavior of up-transitions will observe different
exit/entry action sequences.
This commit is contained in:
swaits committed 2026-04-22 18:22:04 -06:00
1 parent bc7e17e58e
commit 4d7baf7236
12 files changed
+774 -32

No files matched your search

+60 -13
View File
@@ -41,9 +41,9 @@ A state is a named node in the state hierarchy. States may be nested arbitrarily
### 2.3 State Configuration
At any point in time, the machine is in exactly one "state configuration," which is a path from the root state down through the hierarchy to a leaf (or to a state with no children). If state `A` contains state `B` which contains state `C`, and `C` is the active leaf, then the machine is simultaneously "in" states `A`, `B`, and `C`.
At any point in time, the machine is in exactly one "state configuration," which is a path from the root state down through the hierarchy to the **innermost active state**. The innermost active state may be a state with no declared children, or a composite state that has become the innermost active state via a transition to an already-active ancestor (§2.6) and therefore has no active child. If state `A` contains state `B` which contains state `C`, and `C` is the innermost active state, then the machine is simultaneously "in" states `A`, `B`, and `C`.
When entering a state that has children, the machine automatically descends into the `default` child, recursively, until it reaches a state with no children or no default.
When a state becomes newly active — that is, it was not in the previous configuration — and it has children, the machine automatically descends into the `default` child, recursively, until it reaches a state with no children or no default. A transition that targets a state which is already active (see §2.6) does NOT trigger default descent, because the target is not newly entered.
### 2.4 Events
@@ -60,12 +60,23 @@ A trigger is either:
A transition specifies: when trigger `T` occurs in state `S`, move to state `Target`.
Transitions are classified by the relationship between `Target` and the **innermost active state** `I` at the time the transition fires:
- **Up-transition**: `Target` is already active — that is, `Target` lies on the path from the root down to `I`, and `Target ≠ I`. You cannot enter a state you never left. An up-transition therefore only unwinds the subtree strictly below `Target`.
- **Self-transition**: `Target = I`. The innermost active state is exited and re-entered (timers restart; entry/exit actions fire).
- **Lateral transition**: `Target` is not on the active path. Standard LCA semantics apply.
The transition algorithm:
1. Find the least common ancestor (LCA) of the source state and the target state.
2. Exit states from the current active leaf up to (but not including) the LCA, calling exit actions in inner-to-outer order.
3. Enter states from the LCA down to the target state, calling entry actions in outer-to-inner order.
4. If the target state has children, descend into defaults recursively, calling entry actions along the way.
1. Classify the transition as up, self, or lateral (see above).
2. Compute the effective LCA:
- **Up**: effective LCA = `Target`.
- **Self**: effective LCA = parent of `Target` (so `Target` is exited and re-entered).
- **Lateral**: effective LCA = LCA(`I`, `Target`).
3. Exit states from `I` upward, calling exit actions inner-to-outer, stopping **before** the effective LCA. (The effective LCA is never exited.)
4. Entry behavior:
- **Up**: no entry actions fire. `Target` was already active and remains so; it is now the innermost active state, with no active child.
- **Self** and **Lateral**: enter states from the effective LCA down to `Target` (outer-to-inner), then if `Target` has children, descend into defaults recursively, calling entry actions along the way.
Transitions may target any state in the entire machine, not just siblings.
@@ -79,7 +90,7 @@ If both actions and a transition are registered on the same trigger in the same
### 2.8 Event Dispatch
When an event is received (or a timer fires), the machine searches for a matching transition or action starting from the current active leaf state and walking up toward the root. The first state that has a handler for the trigger processes it. If no state handles the trigger, the event is silently discarded.
When an event is received (or a timer fires), the machine searches for a matching transition or action starting from the innermost active state and walking up toward the root. The first state that has a handler for the trigger processes it. If no state handles the trigger, the event is silently discarded.
Timer triggers do NOT bubble. A timer belongs to the state that declared it and only fires in that state's scope. Event triggers DO bubble up the hierarchy.
@@ -91,13 +102,15 @@ Timer triggers do NOT bubble. A timer belongs to the state that declared it and
### 2.10 Default State
`default(ChildState)` specifies the initial child state. It is required in any state that has children. It is required at the root level. The referenced state must be a direct child of the containing state. This is enforced at compile time.
`default(ChildState)` specifies the child state to descend into when the containing state is newly entered (either at initial machine startup, or via a lateral/self-transition that enters it fresh). It is required in any state that has children. It is required at the root level. The referenced state must be a direct child of the containing state. This is enforced at compile time.
`default` does NOT fire when a state becomes the innermost active state via an up-transition (§2.6): the parent was already active, so there is no "new entry" to descend from.
### 2.11 Terminate
`terminate(EventVariant)` may only appear at the root level of the machine. It specifies that when the given event is received, the machine shuts down. Shutdown means:
1. Exit the current active leaf state, calling its exit actions.
1. Exit the innermost active state, calling its exit actions.
2. Exit each ancestor state up to and including the root state, calling exit actions in inner-to-outer order.
3. The machine is now terminated. `run()` returns. `is_terminated()` returns true.
@@ -246,7 +259,9 @@ impl<Name> {
/// Returns true if the machine has terminated.
fn is_terminated(&self) -> bool;
/// Returns the current active state configuration (leaf state).
/// Returns the innermost active state (see §2.3). This is typically a
/// leaf, but may be a composite state that became innermost via an
/// up-transition (§2.6).
fn current_state(&self) -> <Name>State;
/// Consumes the machine, returns the user's context.
@@ -633,15 +648,47 @@ Then: Exit order: [B_exit, A_exit]
current_state() == C
```
#### T2.8: Transition to an ancestor state
#### T2.8: Up-transition to an ancestor state
A transition whose target is already active (an ancestor of the innermost active state) only unwinds the subtree strictly below the target. The target itself is not exited and not re-entered. Its `default` is not followed — it was already active, so there is no "new entry."
```
Given: Machine with:
state A { default(B); state B { transition(Up) => A } }
Machine is in B.
When: Event Up is delivered
Then: Exit order: [B_exit, A_exit]
Entry order: [A_entry, B_entry] (re-enters A, descends to default B)
Then: Exit order: [B_exit]
Entry order: []
current_state() == A
```
#### T2.8b: Up-transition across multiple levels
```
Given: Machine with:
state A { default(B); state B { default(C); state C { transition(Up) => A } } }
Machine is in C.
When: Event Up is delivered
Then: Exit order: [C_exit, B_exit]
Entry order: []
current_state() == A
```
#### T2.8c: Handler on ancestor, target is a mid-ancestor
When the handler bubbles up to an ancestor, classification is based on the innermost active state at the time the transition fires — not on where the handler was found. If the target is on the active path and not the innermost, it is still an up-transition.
```
Given: Machine with:
state A {
default(B);
transition(Up) => B; // handler on A, target B
state B { default(C); state C {} }
}
Machine is in C.
When: Event Up is delivered
Then: Exit order: [C_exit]
Entry order: []
current_state() == B
```