Skip to content

Commit 679d265

Browse files
committed
fix: child session termination, event ordering, and invokeid routing
- Add _terminate_machine() to fire onexit handlers on all active states when a child session reaches a top-level final state, ensuring #_parent sends in <onexit> of final states are delivered before done.invoke - Only applies to child sessions (with _parent_sm) to avoid changing behavior for regular state machines - Fix EventData.__post_init__ to handle transition=None (termination) - Fix _send_to_invokeid duplicate machine kwarg (same pattern as _eval_send_params fix) - Fix async spawn to await-guard activate_initial_state and send results for cross-engine scenarios (async parent, sync child)
1 parent a7ac2ce commit 679d265

13 files changed

Lines changed: 568 additions & 5 deletions

statemachine/engines/base.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -562,11 +562,43 @@ def _add_state_to_configuration(self, target: State):
562562
if not self.sm.atomic_configuration_update:
563563
self.sm.configuration |= {target}
564564

565+
def _terminate_machine(self):
566+
"""SCXML termination: exit all active states in exit order, firing onexit handlers.
567+
568+
Per SCXML spec, when a top-level final state is reached, the machine
569+
terminates. All active states have their ``onexit`` handlers fired
570+
(in reverse document order), but the final configuration is preserved
571+
so that observers can see which final state was reached.
572+
"""
573+
on_error = self._on_error_handler()
574+
# Sort active states by document_order (reverse) for exit order
575+
active_states = sorted(
576+
self.sm.configuration,
577+
key=lambda s: s.document_order,
578+
reverse=True,
579+
)
580+
trigger_data = TriggerData(self.sm, event=None)
581+
event_data = EventData(trigger_data=trigger_data, transition=None)
582+
args, kwargs = event_data.args, event_data.extended_kwargs
583+
for state in active_states:
584+
if getattr(state, "invocations", None):
585+
self.invoke_manager.cancel_for_state(state)
586+
self.sm._callbacks.call(state.exit.key, *args, on_error=on_error, **kwargs)
587+
# Note: we intentionally do NOT clear configuration — the final state
588+
# must remain visible for assertions and done.invoke reporting.
589+
self.invoke_manager.cancel_all()
590+
self.running = False
591+
565592
def _handle_final_state(self, target: State, on_entry_result: list):
566593
"""Handle final state entry: queue done events. No direct callback dispatch."""
567594
if target.parent is None:
568-
self.running = False
569-
self.invoke_manager.cancel_all()
595+
if getattr(self.sm, "_parent_sm", None) is not None:
596+
# Child session: fire onexit on all active states before terminating
597+
# so that #_parent sends in <onexit> of final states are delivered
598+
self._terminate_machine()
599+
else:
600+
self.running = False
601+
self.invoke_manager.cancel_all()
570602
else:
571603
parent = target.parent
572604
grandparent = parent.parent

statemachine/event_data.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,14 @@ class EventData:
7070
"""The destination :ref:`State` of the :ref:`transition`."""
7171

7272
def __post_init__(self):
73-
self.state = self.transition.source
74-
self.source = self.transition.source
75-
self.target = self.transition.target
73+
if self.transition is not None:
74+
self.state = self.transition.source
75+
self.source = self.transition.source
76+
self.target = self.transition.target
77+
else:
78+
self.state = None # type: ignore[assignment]
79+
self.source = None # type: ignore[assignment]
80+
self.target = None # type: ignore[assignment]
7681
self.machine = self.trigger_data.machine
7782

7883
@property
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Testcase: test239
2+
3+
FileNotFoundError: File not found.
4+
5+
Final configuration: `No configuration`
6+
7+
---
8+
9+
## Logs
10+
```py
11+
DEBUG statemachine.engines.base:base.py:556 States to enter: {S0, S01}
12+
DEBUG statemachine.engines.base:base.py:629 Entering state: S0
13+
DEBUG statemachine.engines.base:base.py:125 New event 'timeout' put on the 'external' queue
14+
DEBUG statemachine.engines.base:base.py:629 Entering state: S01
15+
DEBUG statemachine.engines.sync:sync.py:78 Processing loop started: {s0, s01}
16+
DEBUG statemachine.engines.sync:sync.py:92 Macrostep: eventless/internal queue
17+
18+
```
19+
20+
## "On transition" events
21+
```py
22+
OnEnterState(state='s0', event='__initial__', data='{}')
23+
OnTransition(source='', event='__initial__', data='{}', target='s0')
24+
OnEnterState(state='s01', event='__initial__', data='{}')
25+
```
26+
27+
## Traceback
28+
```py
29+
Traceback (most recent call last):
30+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/tests/scxml/test_scxml_cases.py", line 164, in _run_scxml_testcase
31+
sm = processor.start(listeners=listeners)
32+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 256, in start
33+
self.root = self.root_cls(**kwargs)
34+
~~~~~~~~~~~~~^^^^^^^^^^
35+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/statemachine.py", line 164, in __init__
36+
self._engine.start()
37+
~~~~~~~~~~~~~~~~~~^^
38+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/engines/sync.py", line 38, in start
39+
self.activate_initial_state()
40+
~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
41+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/engines/sync.py", line 58, in activate_initial_state
42+
return self.processing_loop()
43+
~~~~~~~~~~~~~~~~~~~~^^
44+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/engines/sync.py", line 116, in processing_loop
45+
self.invoke_manager.spawn_sync(state, config, internal_event)
46+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
47+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/invoke.py", line 102, in spawn_sync
48+
child_sm = self._create_child(config, invokeid, invocation, trigger_data)
49+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/invoke.py", line 212, in _create_child
50+
processor.parse_scxml_file(path)
51+
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
52+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 76, in parse_scxml_file
53+
scxml_content = path.read_text()
54+
File "/Users/fernando.macedo/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/lib/python3.13/pathlib/_local.py", line 546, in read_text
55+
return PathBase.read_text(self, encoding, errors, newline)
56+
~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
57+
File "/Users/fernando.macedo/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/lib/python3.13/pathlib/_abc.py", line 632, in read_text
58+
with self.open(mode='r', encoding=encoding, errors=errors, newline=newline) as f:
59+
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
60+
File "/Users/fernando.macedo/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/lib/python3.13/pathlib/_local.py", line 537, in open
61+
return io.open(self, mode, buffering, encoding, errors, newline)
62+
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
63+
FileNotFoundError: [Errno 2] No such file or directory: 'test239sub1.scxml'
64+
65+
```
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Testcase: test242
2+
3+
FileNotFoundError: File not found.
4+
5+
Final configuration: `No configuration`
6+
7+
---
8+
9+
## Logs
10+
```py
11+
DEBUG statemachine.engines.base:base.py:556 States to enter: {S0}
12+
DEBUG statemachine.engines.base:base.py:629 Entering state: S0
13+
DEBUG statemachine.engines.base:base.py:125 New event 'timeout1' put on the 'external' queue
14+
DEBUG statemachine.engines.sync:sync.py:78 Processing loop started: s0
15+
DEBUG statemachine.engines.sync:sync.py:92 Macrostep: eventless/internal queue
16+
17+
```
18+
19+
## "On transition" events
20+
```py
21+
OnEnterState(state='s0', event='__initial__', data='{}')
22+
```
23+
24+
## Traceback
25+
```py
26+
Traceback (most recent call last):
27+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/tests/scxml/test_scxml_cases.py", line 164, in _run_scxml_testcase
28+
sm = processor.start(listeners=listeners)
29+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 256, in start
30+
self.root = self.root_cls(**kwargs)
31+
~~~~~~~~~~~~~^^^^^^^^^^
32+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/statemachine.py", line 164, in __init__
33+
self._engine.start()
34+
~~~~~~~~~~~~~~~~~~^^
35+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/engines/sync.py", line 38, in start
36+
self.activate_initial_state()
37+
~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
38+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/engines/sync.py", line 58, in activate_initial_state
39+
return self.processing_loop()
40+
~~~~~~~~~~~~~~~~~~~~^^
41+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/engines/sync.py", line 116, in processing_loop
42+
self.invoke_manager.spawn_sync(state, config, internal_event)
43+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
44+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/invoke.py", line 102, in spawn_sync
45+
child_sm = self._create_child(config, invokeid, invocation, trigger_data)
46+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/invoke.py", line 212, in _create_child
47+
processor.parse_scxml_file(path)
48+
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
49+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 76, in parse_scxml_file
50+
scxml_content = path.read_text()
51+
File "/Users/fernando.macedo/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/lib/python3.13/pathlib/_local.py", line 546, in read_text
52+
return PathBase.read_text(self, encoding, errors, newline)
53+
~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
54+
File "/Users/fernando.macedo/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/lib/python3.13/pathlib/_abc.py", line 632, in read_text
55+
with self.open(mode='r', encoding=encoding, errors=errors, newline=newline) as f:
56+
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
57+
File "/Users/fernando.macedo/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/lib/python3.13/pathlib/_local.py", line 537, in open
58+
return io.open(self, mode, buffering, encoding, errors, newline)
59+
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
60+
FileNotFoundError: [Errno 2] No such file or directory: 'test242sub1.scxml'
61+
62+
```
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Testcase: test276
2+
3+
FileNotFoundError: File not found.
4+
5+
Final configuration: `No configuration`
6+
7+
---
8+
9+
## Logs
10+
```py
11+
DEBUG statemachine.engines.base:base.py:556 States to enter: {S0}
12+
DEBUG statemachine.engines.base:base.py:629 Entering state: S0
13+
DEBUG statemachine.engines.sync:sync.py:78 Processing loop started: s0
14+
DEBUG statemachine.engines.sync:sync.py:92 Macrostep: eventless/internal queue
15+
16+
```
17+
18+
## "On transition" events
19+
```py
20+
OnEnterState(state='s0', event='__initial__', data='{}')
21+
```
22+
23+
## Traceback
24+
```py
25+
Traceback (most recent call last):
26+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/tests/scxml/test_scxml_cases.py", line 164, in _run_scxml_testcase
27+
sm = processor.start(listeners=listeners)
28+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 256, in start
29+
self.root = self.root_cls(**kwargs)
30+
~~~~~~~~~~~~~^^^^^^^^^^
31+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/statemachine.py", line 164, in __init__
32+
self._engine.start()
33+
~~~~~~~~~~~~~~~~~~^^
34+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/engines/sync.py", line 38, in start
35+
self.activate_initial_state()
36+
~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
37+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/engines/sync.py", line 58, in activate_initial_state
38+
return self.processing_loop()
39+
~~~~~~~~~~~~~~~~~~~~^^
40+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/engines/sync.py", line 116, in processing_loop
41+
self.invoke_manager.spawn_sync(state, config, internal_event)
42+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
43+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/invoke.py", line 102, in spawn_sync
44+
child_sm = self._create_child(config, invokeid, invocation, trigger_data)
45+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/invoke.py", line 212, in _create_child
46+
processor.parse_scxml_file(path)
47+
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
48+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 76, in parse_scxml_file
49+
scxml_content = path.read_text()
50+
File "/Users/fernando.macedo/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/lib/python3.13/pathlib/_local.py", line 546, in read_text
51+
return PathBase.read_text(self, encoding, errors, newline)
52+
~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
53+
File "/Users/fernando.macedo/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/lib/python3.13/pathlib/_abc.py", line 632, in read_text
54+
with self.open(mode='r', encoding=encoding, errors=errors, newline=newline) as f:
55+
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
56+
File "/Users/fernando.macedo/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/lib/python3.13/pathlib/_local.py", line 537, in open
57+
return io.open(self, mode, buffering, encoding, errors, newline)
58+
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
59+
FileNotFoundError: [Errno 2] No such file or directory: 'test276sub1.scxml'
60+
61+
```
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Testcase: test530
2+
3+
KeyError: Mapping key not found.
4+
5+
Final configuration: `No configuration`
6+
7+
---
8+
9+
## Logs
10+
```py
11+
No logs
12+
```
13+
14+
## "On transition" events
15+
```py
16+
No events
17+
```
18+
19+
## Traceback
20+
```py
21+
Traceback (most recent call last):
22+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/tests/scxml/test_scxml_cases.py", line 162, in _run_scxml_testcase
23+
processor.parse_scxml_file(testcase_path)
24+
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
25+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 78, in parse_scxml_file
26+
return self.parse_scxml(path.stem, scxml_content)
27+
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
28+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 81, in parse_scxml
29+
definition = parse_scxml(scxml_content)
30+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 70, in parse_scxml
31+
state = parse_state(state_elem, all_initial_states)
32+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 150, in parse_state
33+
content = parse_executable_content(onentry_elem)
34+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 297, in parse_executable_content
35+
action = parse_element(child)
36+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 308, in parse_element
37+
return parse_assign(element)
38+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 332, in parse_assign
39+
expr = element.attrib["expr"]
40+
~~~~~~~~~~~~~~^^^^^^^^
41+
KeyError: 'expr'
42+
43+
```
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Testcase: test530
2+
3+
KeyError: Mapping key not found.
4+
5+
Final configuration: `No configuration`
6+
7+
---
8+
9+
## Logs
10+
```py
11+
No logs
12+
```
13+
14+
## "On transition" events
15+
```py
16+
No events
17+
```
18+
19+
## Traceback
20+
```py
21+
Traceback (most recent call last):
22+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/tests/scxml/test_scxml_cases.py", line 162, in _run_scxml_testcase
23+
processor.parse_scxml_file(testcase_path)
24+
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
25+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 78, in parse_scxml_file
26+
return self.parse_scxml(path.stem, scxml_content)
27+
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
28+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 81, in parse_scxml
29+
definition = parse_scxml(scxml_content)
30+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 70, in parse_scxml
31+
state = parse_state(state_elem, all_initial_states)
32+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 150, in parse_state
33+
content = parse_executable_content(onentry_elem)
34+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 297, in parse_executable_content
35+
action = parse_element(child)
36+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 308, in parse_element
37+
return parse_assign(element)
38+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 332, in parse_assign
39+
expr = element.attrib["expr"]
40+
~~~~~~~~~~~~~~^^^^^^^^
41+
KeyError: 'expr'
42+
43+
```
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Testcase: test520
2+
3+
ValueError: Inappropriate argument value (of correct type).
4+
5+
Final configuration: `No configuration`
6+
7+
---
8+
9+
## Logs
10+
```py
11+
No logs
12+
```
13+
14+
## "On transition" events
15+
```py
16+
No events
17+
```
18+
19+
## Traceback
20+
```py
21+
Traceback (most recent call last):
22+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/tests/scxml/test_scxml_cases.py", line 162, in _run_scxml_testcase
23+
processor.parse_scxml_file(testcase_path)
24+
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
25+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 78, in parse_scxml_file
26+
return self.parse_scxml(path.stem, scxml_content)
27+
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
28+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/processor.py", line 81, in parse_scxml
29+
definition = parse_scxml(scxml_content)
30+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 70, in parse_scxml
31+
state = parse_state(state_elem, all_initial_states)
32+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 150, in parse_state
33+
content = parse_executable_content(onentry_elem)
34+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 297, in parse_executable_content
35+
action = parse_element(child)
36+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 314, in parse_element
37+
return parse_send(element)
38+
File "/Users/fernando.macedo/projects/python-statemachine-invoke/statemachine/io/scxml/parser.py", line 385, in parse_send
39+
raise ValueError("<send> must have an 'event' or `eventexpr` attribute")
40+
ValueError: <send> must have an 'event' or `eventexpr` attribute
41+
42+
```

0 commit comments

Comments
 (0)