Skip to content

Commit 3cab153

Browse files
committed
feat(scxml): implement #_<invokeid> send target and block-level error catching for transition content
Implement two fixes for SCXML W3C tests 192 and 253: 1. Add #_<invokeid> send target support — the SCXML convention for parent-to-child event routing. Events are forwarded through InvokeManager.send_to_child() to the child handler's on_event(). Unreachable targets queue error.communication. 2. Wrap transition `on` content with on_error so errors are caught per-block (SCXML spec §5.12.1). Previously, errors in transition actions caused full microstep rollback; now the transition completes and error.execution is queued separately. During error.execution processing, on_error is disabled for transition content to prevent infinite loops in self-transition error handlers. Also includes: - Decouple SCXMLInvoker from processor (takes base_dir + register_child) - Add invoke_init callback for invoked child machines - Thread leak detection fixture and interruptible wait fixes - Unit tests for _send_to_invoke and SCXMLInvoker - Documentation updates for invoke, error handling, and release notes
1 parent 63f93fc commit 3cab153

20 files changed

Lines changed: 789 additions & 154 deletions

AGENTS.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,22 @@ The engine follows the SCXML run-to-completion (RTC) model with two processing l
4444
### Error handling (`error_on_execution`)
4545

4646
- `StateChart` has `error_on_execution=True` by default; `StateMachine` has `False`.
47-
- Errors are caught at the **block level** (per onentry/onexit block), not per microstep.
48-
- This means `after` callbacks still run even when an action raises — making `after_<event>()`
49-
a natural **finalize** hook (runs on both success and failure paths).
47+
- Errors are caught at the **block level** (per onentry/onexit/transition `on` block), not per
48+
microstep. This means `after` callbacks still run even when an action raises — making
49+
`after_<event>()` a natural **finalize** hook (runs on both success and failure paths).
5050
- `error.execution` is dispatched as an internal event; define transitions for it to handle
5151
errors within the statechart.
5252
- Error during `error.execution` handling → ignored to prevent infinite loops.
5353

54+
#### `on_error` asymmetry: transition `on` vs onentry/onexit
55+
56+
Transition `on` content uses `on_error` **only for non-`error.execution` events**. During
57+
`error.execution` processing, `on_error` is disabled for transition `on` content — errors
58+
propagate to `microstep()` where `_send_error_execution` ignores them. This prevents infinite
59+
loops in self-transition error handlers (e.g., `error_execution = s1.to(s1, on="handler")`
60+
where `handler` raises). `onentry`/`onexit` blocks always use `on_error` regardless of the
61+
current event.
62+
5463
### Eventless transitions
5564

5665
- Bare transition statements (not assigned to a variable) are **eventless** — they fire
@@ -68,6 +77,21 @@ The engine follows the SCXML run-to-completion (RTC) model with two processing l
6877
- `on_error_execution()` works via naming convention but **only** when a transition for
6978
`error.execution` is declared — it is NOT a generic callback.
7079

80+
### Invoke (`<invoke>`)
81+
82+
- `invoke.py``InvokeManager` on the engine manages the lifecycle: `mark_for_invoke()`,
83+
`cancel_for_state()`, `spawn_pending_sync/async()`, `send_to_child()`.
84+
- `_cleanup_terminated()` only removes invocations that are both terminated **and** cancelled.
85+
A terminated-but-not-cancelled invocation means the handler's `run()` returned but the owning
86+
state is still active — it must stay in `_active` so `send_to_child()` can still route events.
87+
- **Child machine constructor blocks** in the processing loop. Use a listener pattern (e.g.,
88+
`_ChildRefSetter`) to capture the child reference during the first `on_enter_state`, before
89+
the loop spins.
90+
- `#_<invokeid>` send target: routed via `_send_to_invoke()` in `io/scxml/actions.py`
91+
`InvokeManager.send_to_child()` → handler's `on_event()`.
92+
- **Tests with blocking threads**: use `threading.Event.wait(timeout=)` instead of
93+
`time.sleep()` for interruptible waits — avoids thread leak errors in teardown.
94+
7195
## Environment setup
7296

7397
```bash
@@ -77,11 +101,11 @@ pre-commit install
77101

78102
## Running tests
79103

80-
Always use `uv` to run commands:
104+
Always use `uv` to run commands. Also, use a timeout to avoid being stuck in the case of a leaked thread or infinite loop:
81105

82106
```bash
83107
# Run all tests (parallel)
84-
uv run pytest -n auto
108+
timeout 120 uv run pytest -n 4
85109

86110
# Run a specific test file
87111
uv run pytest tests/test_signature.py
@@ -98,9 +122,11 @@ Don't specify the directory `tests/`, because this will exclude doctests from bo
98122
(`--doctest-glob=*.md`) (enabled by default):
99123

100124
```bash
101-
uv run pytest -n auto
125+
timeout 120 uv run pytest -n 4
102126
```
103127

128+
Testes normally run under 60s (~40s on average), so take a closer look if they take longer, it can be a regression.
129+
104130
Coverage is enabled by default.
105131

106132
### Testing both sync and async engines

docs/invoke.md

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -456,24 +456,28 @@ is cancelled.
456456

457457
Pass a `StateChart` subclass to spawn a child machine:
458458

459-
```python
460-
from statemachine import State, StateChart
459+
```py
460+
>>> class ChildMachine(StateChart):
461+
... start = State(initial=True)
462+
... end = State(final=True)
463+
... go = start.to(end)
464+
...
465+
... def on_enter_start(self, **kwargs):
466+
... self.send("go")
467+
468+
>>> class ParentMachine(StateChart):
469+
... loading = State(initial=True, invoke=ChildMachine)
470+
... ready = State(final=True)
471+
... done_invoke_loading = loading.to(ready)
461472

462-
class ChildMachine(StateChart):
463-
start = State(initial=True)
464-
end = State(final=True)
465-
go = start.to(end)
473+
>>> sm = ParentMachine()
474+
>>> time.sleep(0.2)
466475

467-
def on_enter_start(self, **kwargs):
468-
self.send("go")
476+
>>> "ready" in sm.configuration_values
477+
True
469478

470-
class ParentMachine(StateChart):
471-
loading = State(initial=True, invoke=ChildMachine)
472-
ready = State(final=True)
473-
done_invoke_loading = loading.to(ready)
474479
```
475480

476481
The child machine is instantiated and run when the parent's `loading` state is entered.
477482
When the child terminates (reaches a final state), a `done.invoke` event is sent to the
478-
parent, triggering the `done_invoke_loading` transition. See
479-
`tests/test_invoke.py::TestInvokeStateChartChild` for a working example.
483+
parent, triggering the `done_invoke_loading` transition.

docs/processing_model.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,10 @@ and executes them atomically:
111111

112112
If an error occurs during steps 1–4 and `error_on_execution` is enabled, the error is
113113
caught at the **block level** — meaning remaining actions in that block are skipped, but
114-
the microstep continues and `after` callbacks still run (see
115-
{ref}`cleanup / finalize pattern <sphx_glr_auto_examples_statechart_cleanup_machine.py>`).
114+
the microstep continues and `after` callbacks still run. Each phase (exit, `on`, enter)
115+
is an independent block, so an error in the transition `on` action does not prevent target
116+
states from being entered. See {ref}`block-level error catching <error-execution>` and the
117+
{ref}`cleanup / finalize pattern <sphx_glr_auto_examples_statechart_cleanup_machine.py>`.
116118

117119
### Macrostep
118120

docs/releases/3.0.0.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ machines can receive context at creation time:
8383

8484
```
8585

86+
Invoke also supports child state machines (pass a `StateChart` subclass) and SCXML
87+
`<invoke>` with `<finalize>`, autoforward, and `#_<invokeid>` / `#_parent` send targets
88+
for parent-child communication.
89+
8690
See {ref}`invoke` for full documentation.
8791

8892
### Compound states
@@ -336,6 +340,11 @@ True
336340

337341
```
338342

343+
Errors are caught at the **block level**: each microstep phase (exit, transition `on`,
344+
enter) is an independent block. An error in one block does not prevent subsequent blocks
345+
from executing — in particular, `after` callbacks always run, making `after_<event>()` a
346+
natural finalize hook. See {ref}`block-level error catching <error-execution>`.
347+
339348
The error object is available as `error` in handler kwargs. See {ref}`error-execution`
340349
for full details.
341350

@@ -504,11 +513,8 @@ TODO.
504513

505514
The following SCXML features are **not yet implemented** and are deferred to a future release:
506515

507-
- `<invoke>` — invoking external services or sub-machines from within a state
508-
- HTTP and other external communication targets
509-
- `<finalize>` — processing data returned from invoked services
510-
511-
These features are tracked for v3.1+.
516+
- HTTP and other external communication targets (only `#_internal`, `#_parent`, and
517+
`#_<invokeid>` send targets are supported)
512518

513519
```{seealso}
514520
For a step-by-step migration guide with before/after examples, see

docs/statecharts.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,36 @@ If an error occurs while processing the `error.execution` event itself, the engi
213213
ignores the second error (logging a warning) to prevent infinite loops. The state machine
214214
remains in the configuration it was in before the failed error handler.
215215

216+
### Block-level error catching
217+
218+
`StateChart` catches errors at the **block level**, not the microstep level.
219+
Each phase of the microstep — `on_exit`, transition `on` content, `on_enter` — is an
220+
independent block. An error in one block:
221+
222+
- **Stops remaining actions in that block** (per SCXML spec, execution MUST NOT continue
223+
within the same block after an error).
224+
- **Does not affect other blocks** — subsequent phases of the microstep still execute.
225+
In particular, `after` callbacks always run regardless of errors in earlier blocks.
226+
227+
This means that even if a transition's `on` action raises an exception, the transition
228+
completes: target states are entered and `after_<event>()` callbacks still run. The error
229+
is caught and queued as an `error.execution` internal event, which can be handled by a
230+
separate transition.
231+
232+
```{note}
233+
During `error.execution` processing, errors in transition `on` content are **not** caught
234+
at block level — they propagate to the microstep, where they are silently ignored. This
235+
prevents infinite loops when an error handler's own action raises (e.g., a self-transition
236+
`error_execution = s1.to(s1, on="handler")` where `handler` raises). Entry/exit blocks
237+
always use block-level error catching regardless of the current event.
238+
```
239+
216240
### Cleanup / finalize pattern
217241

218242
A common need is to run cleanup code after a transition **regardless of success or failure**
219243
— for example, releasing a lock or closing a resource.
220244

221-
Because `StateChart` catches errors at the **block level** (not the microstep level),
245+
Because `StateChart` catches errors at the **block level** (see above),
222246
`after_<event>()` callbacks still run even when an action raises an exception. This makes
223247
`after_<event>()` a natural **finalize** hook — no need to duplicate cleanup logic in
224248
an error handler.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ python_files = ["tests.py", "test_*.py", "*_tests.py"]
9090
xfail_strict = true
9191
log_cli = true
9292
log_cli_level = "DEBUG"
93+
log_cli_format = "%(relativeCreated)6.0fms %(threadName)-18s %(name)-35s %(message)s"
94+
log_cli_date_format = "%H:%M:%S"
9395
asyncio_default_fixture_loop_scope = "module"
9496

9597
[tool.coverage.run]

statemachine/engines/async_.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from ..exceptions import TransitionNotAllowed
1414
from ..orderedset import OrderedSet
1515
from ..state import State
16+
from .base import _ERROR_EXECUTION
1617
from .base import BaseEngine
1718

1819
if TYPE_CHECKING:
@@ -178,6 +179,7 @@ async def _exit_states( # type: ignore[override]
178179
args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
179180

180181
if info.state is not None: # pragma: no branch
182+
logger.debug("%s Exiting state: %s", self._log_id, info.state)
181183
await self.sm._callbacks.async_call(
182184
info.state.exit.key, *args, on_error=on_error, **kwargs
183185
)
@@ -198,10 +200,24 @@ async def _enter_states( # noqa: C901
198200
self._prepare_entry_states(enabled_transitions, states_to_exit, previous_configuration)
199201
)
200202

203+
# For transition 'on' content, use on_error only for non-error.execution
204+
# events. During error.execution processing, errors in transition content
205+
# must propagate to microstep() where _send_error_execution's guard
206+
# prevents infinite loops (per SCXML spec: errors during error event
207+
# processing are ignored).
208+
on_error_transition = on_error
209+
if (
210+
on_error is not None
211+
and trigger_data.event
212+
and str(trigger_data.event) == _ERROR_EXECUTION
213+
):
214+
on_error_transition = None
215+
201216
result = await self._execute_transition_content(
202217
enabled_transitions,
203218
trigger_data,
204219
lambda t: t.on.key,
220+
on_error=on_error_transition,
205221
previous_configuration=previous_configuration,
206222
new_configuration=new_configuration,
207223
)
@@ -218,7 +234,7 @@ async def _enter_states( # noqa: C901
218234
target=target,
219235
)
220236

221-
logger.debug("Entering state: %s", target)
237+
logger.debug("%s Entering state: %s", self._log_id, target)
222238
self._add_state_to_configuration(target)
223239

224240
on_entry_result = await self.sm._callbacks.async_call(
@@ -257,6 +273,14 @@ async def _enter_states( # noqa: C901
257273
return result
258274

259275
async def microstep(self, transitions: "List[Transition]", trigger_data: TriggerData):
276+
self._microstep_count += 1
277+
logger.debug(
278+
"%s macro:%d micro:%d transitions: %s",
279+
self._log_id,
280+
self._macrostep_count,
281+
self._microstep_count,
282+
transitions,
283+
)
260284
previous_configuration = self.sm.configuration
261285
try:
262286
result = await self._execute_transition_content(
@@ -342,7 +366,7 @@ async def processing_loop( # noqa: C901
342366
return None
343367

344368
_ctx_token = _in_processing_loop.set(True)
345-
logger.debug("Processing loop started: %s", self.sm.current_state_value)
369+
logger.debug("%s Processing loop started: %s", self._log_id, self.sm.current_state_value)
346370
first_result = self._sentinel
347371
try:
348372
took_events = True
@@ -353,7 +377,12 @@ async def processing_loop( # noqa: C901
353377

354378
# Phase 1: eventless transitions and internal events
355379
while not macrostep_done:
356-
logger.debug("Macrostep: eventless/internal queue")
380+
self._microstep_count = 0
381+
logger.debug(
382+
"%s Macrostep %d: eventless/internal queue",
383+
self._log_id,
384+
self._macrostep_count,
385+
)
357386

358387
self.clear_cache()
359388
internal_event = TriggerData(self.sm, event=None) # null object for eventless
@@ -365,7 +394,9 @@ async def processing_loop( # noqa: C901
365394
internal_event = self.internal_queue.pop()
366395
enabled_transitions = await self.select_transitions(internal_event)
367396
if enabled_transitions:
368-
logger.debug("Enabled transitions: %s", enabled_transitions)
397+
logger.debug(
398+
"%s Enabled transitions: %s", self._log_id, enabled_transitions
399+
)
369400
took_events = True
370401
await self._run_microstep(enabled_transitions, internal_event)
371402

@@ -380,7 +411,9 @@ async def processing_loop( # noqa: C901
380411
await self._run_microstep(enabled_transitions, internal_event)
381412

382413
# Phase 3: external events
383-
logger.debug("Macrostep: external queue")
414+
logger.debug(
415+
"%s Macrostep %d: external queue", self._log_id, self._macrostep_count
416+
)
384417
while not self.external_queue.is_empty():
385418
self.clear_cache()
386419
took_events = True
@@ -393,7 +426,14 @@ async def processing_loop( # noqa: C901
393426
# transitions can be processed while we wait.
394427
break
395428

396-
logger.debug("External event: %s", external_event.event)
429+
self._macrostep_count += 1
430+
self._microstep_count = 0
431+
logger.debug(
432+
"%s macrostep %d: event=%s",
433+
self._log_id,
434+
self._macrostep_count,
435+
external_event.event,
436+
)
397437

398438
# Handle lazy initial state activation.
399439
# Break out of phase 3 so the outer loop restarts from phase 1
@@ -412,7 +452,9 @@ async def processing_loop( # noqa: C901
412452
event_future = external_event.future
413453
try:
414454
enabled_transitions = await self.select_transitions(external_event)
415-
logger.debug("Enabled transitions: %s", enabled_transitions)
455+
logger.debug(
456+
"%s Enabled transitions: %s", self._log_id, enabled_transitions
457+
)
416458
if enabled_transitions:
417459
result = await self.microstep(
418460
list(enabled_transitions), external_event
@@ -451,6 +493,7 @@ async def processing_loop( # noqa: C901
451493
_in_processing_loop.reset(_ctx_token)
452494
self._processing.release()
453495

496+
logger.debug("%s Processing loop ended", self._log_id)
454497
result = first_result if first_result is not self._sentinel else None
455498
# If the caller has a future, await it (already resolved by now).
456499
if caller_future is not None:

0 commit comments

Comments
 (0)