Skip to content

Commit 2007a7f

Browse files
authored
fix: answer callers of events dropped when the machine terminates (#685)
1 parent 73bf57c commit 2007a7f

5 files changed

Lines changed: 255 additions & 1 deletion

File tree

‎docs/releases/3.2.2.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,15 @@ engines. Eventless transitions are now checked after every external event instea
413413
batch, so a burst of queued external events costs one guard evaluation each, and an event
414414
queued behind one that reaches a final state is no longer processed.
415415

416-
[#668](https://github.com/fgmacedo/python-statemachine/pull/668).
416+
Such an event, delayed or not, is dropped when the machine terminates, and a coroutine awaiting
417+
its `send()` gets the answer the terminated machine gives to a new event: `None`, or
418+
`TransitionNotAllowed` for that event when `allow_event_without_transition` is `False`, as on a
419+
`StateMachine`. This also applies when the machine terminates because every region of a
420+
top-level parallel state reached a final state. On v3.2.1 a coroutine awaiting a delayed event
421+
still pending when the machine terminated was never answered.
422+
423+
[#668](https://github.com/fgmacedo/python-statemachine/pull/668),
424+
[#678](https://github.com/fgmacedo/python-statemachine/issues/678).
417425

418426
### Graphviz errors reported only the exit code
419427

‎statemachine/engines/async_.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,12 +361,17 @@ async def processing_loop(self, caller_future: "asyncio.Future[object] | None" =
361361
# Spawn invoke handlers for states entered during this macrostep.
362362
await self._invoke_manager.spawn_pending_async()
363363
self._check_root_final_state()
364+
if not self.running:
365+
break
364366

365367
took_external_events, first_result = await self._take_due_external_event(
366368
first_result
367369
)
368370
took_events = took_events or took_external_events
369371

372+
if not self.running:
373+
self._drop_external_events()
374+
370375
except Exception as exc:
371376
if caller_future is not None:
372377
# Route the exception to the caller's future if still pending.

‎statemachine/engines/base.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ def clear(self):
105105
with self.queue.mutex:
106106
self.queue.queue.clear()
107107

108+
def drain(self) -> list[TriggerData]:
109+
"""Remove every trigger from the queue and return those not cancelled."""
110+
with self.queue.mutex:
111+
triggers = [trigger for trigger in self.queue.queue if not trigger.cancelled]
112+
self.queue.queue.clear()
113+
return triggers
114+
108115
def reject_futures(self, exc: Exception):
109116
"""Reject all unresolved futures in the queue.
110117
@@ -180,6 +187,20 @@ def put(self, trigger_data: TriggerData, internal: bool = False):
180187
def clear(self):
181188
self.external_queue.clear()
182189

190+
def _drop_external_events(self):
191+
"""Drop the external events left once the machine has stopped, answering a caller
192+
awaiting one as the stopped machine answers a new event."""
193+
for trigger_data in self.external_queue.drain():
194+
future = trigger_data.future
195+
if future is None or future.done():
196+
continue
197+
if self.sm.allow_event_without_transition:
198+
future.set_result(None)
199+
else:
200+
future.set_exception(
201+
TransitionNotAllowed(trigger_data.event, self.sm.configuration)
202+
)
203+
183204
def cancel_event(self, send_id: str):
184205
"""Cancel the event with the given send_id."""
185206
self.external_queue.cancel(send_id)

‎statemachine/engines/sync.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,16 @@ def processing_loop(self):
7575
# Spawn invoke handlers for states entered during this macrostep.
7676
self._invoke_manager.spawn_pending_sync()
7777
self._check_root_final_state()
78+
if not self.running:
79+
break
7880

7981
took_external_events, first_result = self._take_due_external_event(
8082
first_result
8183
)
8284
took_events = took_events or took_external_events
85+
86+
if not self.running:
87+
self._drop_external_events()
8388
finally:
8489
self._processing.release()
8590
self._debug("%s Processing loop ended", self._log_id)

‎tests/test_async_futures.py‎

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
import pytest
1313
from statemachine.engines.base import EventQueue
1414
from statemachine.event_data import TriggerData
15+
from statemachine.exceptions import TransitionNotAllowed
1516

17+
from statemachine import Event
1618
from statemachine import State
1719
from statemachine import StateChart
20+
from statemachine import StateMachine
1821

1922
# ---------------------------------------------------------------------------
2023
# Fixtures / helpers
@@ -324,6 +327,218 @@ async def fn2():
324327
assert str(errors["fn2"]) == "noop2 is not allowed"
325328

326329

330+
def mount_doom_quest(base):
331+
class MountDoom(base):
332+
road = State(initial=True)
333+
orodruin = State()
334+
destroyed = State(final=True)
335+
336+
climb = road.to(orodruin) | orodruin.to.itself()
337+
cast_ring = road.to(destroyed) | orodruin.to(destroyed)
338+
339+
def __init__(self, *args, **kwargs):
340+
self.ring_falls = asyncio.Event()
341+
super().__init__(*args, **kwargs)
342+
343+
async def on_cast_ring(self):
344+
await self.ring_falls.wait()
345+
return "destroyed"
346+
347+
return MountDoom
348+
349+
350+
def shire_journey(base):
351+
class Shire(base):
352+
bag_end = State(initial=True)
353+
rivendell = State()
354+
grey_havens = State(final=True)
355+
356+
journey = bag_end.to(rivendell)
357+
long_journey = Event(bag_end.to(rivendell), delay=60000)
358+
sail = bag_end.to(grey_havens, on="say_goodbye") | rivendell.to(grey_havens)
359+
360+
def __init__(self, *args, **kwargs):
361+
self.journeys = 0
362+
super().__init__(*args, **kwargs)
363+
364+
def say_goodbye(self):
365+
self.journey.put()
366+
self.long_journey.put()
367+
368+
def on_enter_rivendell(self):
369+
self.journeys += 1
370+
371+
return Shire
372+
373+
374+
async def start_casting_the_ring(sm):
375+
"""Start ``cast_ring`` in a task that holds the processing lock until ``ring_falls``."""
376+
await sm.activate_initial_state()
377+
holder = asyncio.ensure_future(sm.send("cast_ring"))
378+
await asyncio.sleep(0)
379+
return holder
380+
381+
382+
async def queue_behind(sm, *sends):
383+
waiters = [asyncio.ensure_future(sm.send(event, **kwargs)) for event, kwargs in sends]
384+
await asyncio.sleep(0)
385+
return waiters
386+
387+
388+
class TestEventsDroppedAtFinalState:
389+
"""Events still queued when the machine reaches a top-level final state are not run,
390+
and a coroutine awaiting one gets what a terminated machine answers to a new event.
391+
392+
See: https://github.com/fgmacedo/python-statemachine/issues/678
393+
"""
394+
395+
@pytest.mark.timeout(5)
396+
@pytest.mark.asyncio()
397+
async def test_statechart_callers_get_none(self):
398+
sm = mount_doom_quest(StateChart)()
399+
holder = await start_casting_the_ring(sm)
400+
waiters = await queue_behind(sm, ("climb", {}), ("climb", {}), ("climb", {"delay": 60000}))
401+
402+
sm.ring_falls.set()
403+
404+
assert await holder == "destroyed"
405+
assert await asyncio.gather(*waiters) == [None, None, None]
406+
assert sm.configuration_values == {"destroyed"}
407+
408+
@pytest.mark.timeout(5)
409+
@pytest.mark.asyncio()
410+
async def test_statemachine_callers_get_transition_not_allowed(self):
411+
sm = mount_doom_quest(StateMachine)()
412+
holder = await start_casting_the_ring(sm)
413+
waiters = await queue_behind(sm, ("climb", {}), ("climb", {}), ("climb", {"delay": 60000}))
414+
415+
sm.ring_falls.set()
416+
417+
assert await holder == "destroyed"
418+
results = await asyncio.gather(*waiters, return_exceptions=True)
419+
assert all(isinstance(result, TransitionNotAllowed) for result in results)
420+
assert [result.event.id for result in results] == ["climb", "climb", "climb"]
421+
assert sm.configuration_values == {"destroyed"}
422+
423+
@pytest.mark.timeout(5)
424+
@pytest.mark.asyncio()
425+
async def test_each_caller_gets_its_own_event(self):
426+
sm = mount_doom_quest(StateMachine)()
427+
holder = await start_casting_the_ring(sm)
428+
waiters = await queue_behind(sm, ("climb", {}), ("cast_ring", {}))
429+
430+
sm.ring_falls.set()
431+
432+
assert await holder == "destroyed"
433+
results = await asyncio.gather(*waiters, return_exceptions=True)
434+
assert [result.event.id for result in results] == ["climb", "cast_ring"]
435+
436+
@pytest.mark.timeout(5)
437+
@pytest.mark.asyncio()
438+
async def test_cancelled_caller_does_not_stop_the_others(self):
439+
sm = mount_doom_quest(StateChart)()
440+
holder = await start_casting_the_ring(sm)
441+
gives_up, *waiters = await queue_behind(
442+
sm, ("climb", {}), ("climb", {}), ("climb", {"delay": 60000})
443+
)
444+
gives_up.cancel()
445+
await asyncio.sleep(0)
446+
447+
sm.ring_falls.set()
448+
449+
assert await holder == "destroyed"
450+
assert await asyncio.gather(*waiters) == [None, None]
451+
assert gives_up.cancelled()
452+
453+
@pytest.mark.timeout(5)
454+
@pytest.mark.parametrize("base", [StateChart, StateMachine])
455+
async def test_events_queued_behind_final_state_leave_no_trigger(self, sm_runner, base):
456+
Shire = shire_journey(base)
457+
sm = await sm_runner.start(Shire)
458+
await sm_runner.send(sm, "sail")
459+
460+
assert sm.configuration_values == {"grey_havens"}
461+
assert sm.journeys == 0
462+
assert sm._engine.external_queue.is_empty()
463+
464+
@pytest.mark.timeout(5)
465+
async def test_statechart_send_after_final_state_leaves_no_trigger(self, sm_runner):
466+
sm = await sm_runner.start(shire_journey(StateChart))
467+
await sm_runner.send(sm, "sail")
468+
469+
assert await sm_runner.send(sm, "journey") is None
470+
assert await sm_runner.send(sm, "journey", delay=60000) is None
471+
assert sm.journeys == 0
472+
assert sm._engine.external_queue.is_empty()
473+
474+
@pytest.mark.timeout(5)
475+
@pytest.mark.parametrize("base", [StateChart, StateMachine])
476+
async def test_events_queued_behind_root_parallel_completion_are_dropped(
477+
self, sm_runner, base
478+
):
479+
class LastAlliance(base):
480+
class war(State.Parallel):
481+
class mordor(State.Compound):
482+
besieged = State(initial=True)
483+
fallen = State(final=True)
484+
victory = besieged.to(fallen)
485+
486+
class barad_dur(State.Compound):
487+
standing = State(initial=True)
488+
ruined = State(final=True)
489+
victory = standing.to(ruined)
490+
491+
rally = war.to.itself()
492+
493+
def __init__(self, *args, **kwargs):
494+
self.rallies = 0
495+
super().__init__(*args, **kwargs)
496+
497+
def on_victory(self):
498+
self.rally.put()
499+
500+
def on_rally(self):
501+
self.rallies += 1
502+
503+
sm = await sm_runner.start(LastAlliance)
504+
await sm_runner.send(sm, "victory")
505+
506+
assert sm.is_terminated
507+
assert sm.rallies == 0
508+
assert sm._engine.external_queue.is_empty()
509+
510+
@pytest.mark.timeout(5)
511+
@pytest.mark.parametrize("base", [StateChart, StateMachine])
512+
async def test_events_queued_behind_an_eventless_final_transition_are_dropped(
513+
self, sm_runner, base
514+
):
515+
class Fellowship(base):
516+
rivendell = State(initial=True)
517+
moria = State()
518+
lothlorien = State(final=True)
519+
520+
enter_moria = rivendell.to(moria, on="hear_drums")
521+
moria.to(lothlorien)
522+
rest = rivendell.to(lothlorien)
523+
524+
def __init__(self, *args, **kwargs):
525+
self.rests = 0
526+
super().__init__(*args, **kwargs)
527+
528+
def hear_drums(self):
529+
self.rest.put()
530+
531+
def on_rest(self):
532+
self.rests += 1
533+
534+
sm = await sm_runner.start(Fellowship)
535+
await sm_runner.send(sm, "enter_moria")
536+
537+
assert sm.configuration_values == {"lothlorien"}
538+
assert sm.rests == 0
539+
assert sm._engine.external_queue.is_empty()
540+
541+
327542
class TestEventQueueRejectFutures:
328543
"""Unit tests for EventQueue.reject_futures."""
329544

0 commit comments

Comments
 (0)