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:
12 files changed
+774
-32
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Reference in new issue
Block a user