Skip to content

Commit 2669b92

Browse files
authored
Fix regression that did not trigger events in nested calls within an already running transition (#451)
* chore: Processing block protected by a threading.Lock * fix: Regression that did not trigger events in nested calls within an already running transition. Closes #449
1 parent af78785 commit 2669b92

7 files changed

Lines changed: 42 additions & 31 deletions

File tree

docs/processing_model.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ Note that the events `connect` and `connection_succeed` are executed sequentiall
9191

9292
## Non-RTC model
9393

94+
```{deprecated} 2.3.2
95+
`StateMachine.rtc` option is deprecated. We'll keep only the **run-to-completion** (RTC) model.
96+
```
97+
9498
In contrast, in a non-RTC (synchronous) processing model, the state machine starts executing nested events
9599
while processing a parent event. This means that when an event is triggered, the state machine
96100
chains the processing when another event was triggered as a result of the first event.

docs/releases/1.0.1.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ single `Transition`.
217217

218218
## Deprecated features in 1.0
219219

220-
### Statemachine class
220+
### Statemachine class deprecations
221221

222222
- `StateMachine.run` is deprecated in favor of `StateMachine.send`.
223223
- `StateMachine.allowed_transitions` is deprecated in favor of `StateMachine.allowed_events`.

docs/releases/2.3.2.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,15 @@ See {ref}`listeners` for more details.
4343

4444
## Bugfixes in 2.3.2
4545

46-
-
46+
- Fixes [#449](https://github.com/fgmacedo/python-statemachine/issues/449): Regression that did not trigger events
47+
in nested calls within an already running transition.
48+
4749

4850
## Deprecation notes
4951

50-
### Statemachine class
52+
### Statemachine class deprecations in 2.3.2
53+
54+
Deprecations that will be removed on the next major release:
5155

5256
- `StateMachine.add_observer` is deprecated in favor of `StateMachine.add_listener`.
57+
- `StateMachine.rtc` option is deprecated. We'll keep only the **run-to-completion** (RTC) model.

statemachine/event.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ def __init__(self, name: str):
1515
def __repr__(self):
1616
return f"{type(self).__name__}({self.name!r})"
1717

18-
async def trigger(self, machine: "StateMachine", *args, **kwargs):
18+
def trigger(self, machine: "StateMachine", *args, **kwargs):
1919
trigger_data = TriggerData(
2020
machine=machine,
2121
event=self.name,
2222
args=args,
2323
kwargs=kwargs,
2424
)
25-
26-
return await machine._process(trigger_data)
25+
machine._put_nonblocking(trigger_data)
26+
return machine._processing_loop()
2727

2828

2929
def trigger_event_factory(event_instance: Event):

statemachine/event_data.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class EventData:
4747
"""The destination :ref:`State` of the :ref:`transition`."""
4848

4949
result: "Any | None" = None
50+
5051
executed: bool = False
5152

5253
def __post_init__(self):

statemachine/statemachine.py

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from collections import deque
33
from copy import deepcopy
44
from functools import partial
5+
from threading import Lock
56
from typing import TYPE_CHECKING
67
from typing import Any
78
from typing import Dict
@@ -81,7 +82,7 @@ def __init__(
8182
self.allow_event_without_transition = allow_event_without_transition
8283
self.__initialized = False
8384
self.__rtc = rtc
84-
self.__processing: bool = False
85+
self.__processing = Lock()
8586
self._external_queue: deque = deque()
8687
self._callbacks_registry = CallbacksRegistry()
8788
self._states_for_instance: Dict[State, State] = {}
@@ -117,12 +118,17 @@ def __repr__(self):
117118

118119
def __deepcopy__(self, memo):
119120
deepcopy_method = self.__deepcopy__
120-
self.__deepcopy__ = None
121-
try:
122-
cp = deepcopy(self, memo)
123-
finally:
124-
self.__deepcopy__ = deepcopy_method
125-
cp.__deepcopy__ = deepcopy_method
121+
lock = self.__processing
122+
with lock:
123+
self.__deepcopy__ = None
124+
self.__processing = None
125+
try:
126+
cp = deepcopy(self, memo)
127+
cp.__processing = Lock()
128+
finally:
129+
self.__deepcopy__ = deepcopy_method
130+
cp.__deepcopy__ = deepcopy_method
131+
self.__processing = lock
126132
cp._callbacks_registry.clear()
127133
cp._register_callbacks([])
128134
cp.add_listener(*cp._listeners.keys())
@@ -312,7 +318,11 @@ async def _trigger(self, trigger_data: TriggerData):
312318

313319
return event_data.result if event_data else None
314320

315-
async def _process(self, trigger_data: TriggerData):
321+
def _put_nonblocking(self, trigger_data: TriggerData):
322+
"""Put the trigger on the queue without blocking the caller."""
323+
self._external_queue.append(trigger_data)
324+
325+
async def _processing_loop(self):
316326
"""Process event triggers.
317327
318328
The simplest implementation is the non-RTC (synchronous),
@@ -331,31 +341,24 @@ async def _process(self, trigger_data: TriggerData):
331341
will be processed sequentially (and not nested).
332342
333343
"""
344+
334345
if not self.__rtc:
335346
# The machine is in "synchronous" mode
347+
trigger_data = self._external_queue.popleft()
336348
return await self._trigger(trigger_data)
337349

338-
# The machine is in "queued" mode
339-
# Add the trigger to queue and start processing in a loop.
340-
self._external_queue.append(trigger_data)
341-
342350
# We make sure that only the first event enters the processing critical section,
343351
# next events will only be put on the queue and processed by the same loop.
344-
if self.__processing:
345-
return
346-
347-
return await self._processing_loop()
348-
349-
async def _processing_loop(self):
350-
"""Execute the triggers in the queue in order until the queue is empty"""
351-
self.__processing = True
352+
if not self.__processing.acquire(blocking=False):
353+
return None
352354

353355
# We will collect the first result as the processing result to keep backwards compatibility
354356
# so we need to use a sentinel object instead of `None` because the first result may
355357
# be also `None`, and on this case the `first_result` may be overridden by another result.
356358
sentinel = object()
357359
first_result = sentinel
358360
try:
361+
# Execute the triggers in the queue in FIFO order until the queue is empty
359362
while self._external_queue:
360363
trigger_data = self._external_queue.popleft()
361364
try:
@@ -368,7 +371,7 @@ async def _processing_loop(self):
368371
self._external_queue.clear()
369372
raise
370373
finally:
371-
self.__processing = False
374+
self.__processing.release()
372375
return first_result if first_result is not sentinel else None
373376

374377
async def _activate(self, event_data: EventData):
@@ -412,7 +415,7 @@ def send(self, event: str, *args, **kwargs):
412415
coro = self.async_send(event, *args, **kwargs)
413416
return run_async_from_sync(coro)
414417

415-
async def async_send(self, event: str, *args, **kwargs):
418+
def async_send(self, event: str, *args, **kwargs):
416419
"""Send an :ref:`Event` to the state machine.
417420
418421
.. seealso::
@@ -421,7 +424,7 @@ async def async_send(self, event: str, *args, **kwargs):
421424
422425
"""
423426
event_instance: Event = Event(event)
424-
return await event_instance.trigger(self, *args, **kwargs)
427+
return event_instance.trigger(self, *args, **kwargs)
425428

426429
def _get_callbacks(self, key) -> CallbacksExecutor:
427430
return self._callbacks_registry[key]

tests/testcases/issue449.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@ Exercise:
3737

3838

3939
```py
40-
>>> import pytest
41-
>>> pytest.xfail("This test is a regression on 2.3.0+ due to asyncio support.")
4240
>>> example = ExampleStateMachine()
4341
Entering state initial. Event: __initial__
4442

0 commit comments

Comments
 (0)