Skip to content

Commit c3354bb

Browse files
authored
fix: answer callers queued behind a macrostep that raises (#688)
1 parent 4d4003d commit c3354bb

6 files changed

Lines changed: 330 additions & 12 deletions

File tree

‎docs/async.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,11 @@ model: they are enqueued and processed by the running loop.
163163
```
164164

165165
If an exception occurs during processing (with `catch_errors_as_events=False`),
166-
the exception is routed to the caller whose event caused it. Other callers
166+
the exception is routed to the caller whose event raised it. Other callers
167167
whose events were still pending will also receive the exception, since the
168-
processing loop clears the queue on failure.
168+
processing loop clears the queue on failure. An exception raised by the
169+
internal events or eventless transitions that follow an event goes only to
170+
the callers still pending: the caller of that event already has its result.
169171

170172

171173
(syncengine)=

‎docs/releases/3.2.2.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ fail after upgrading:
6767
awaiting a delayed one, which was never answered, now gets `None`, or `TransitionNotAllowed`
6868
on a `StateMachine`. See
6969
{ref}`release-3.2.2-macrostep`.
70+
- When an exception escapes an internal event or eventless transition enabled by an external
71+
event (any exception on a `StateMachine`, one from a validator or `prepare_event` on a
72+
`StateChart`), the events still queued are dropped, and a coroutine awaiting one gets the
73+
exception. The queued external events used to run before the internal event or eventless
74+
transition. Internal events raised before a callback fails, and events sent before a failed
75+
lazy initial activation on the async engine, are dropped too, instead of running at the next
76+
event. See {ref}`release-3.2.2-macrostep`.
7077
- An attribute named with the `error_`, `done_state_` or `done_invoke_` prefix now registers
7178
two events, so `events`, `allowed_events` and `enabled_events()` list both ids. See
7279
{ref}`release-3.2.2-prefixed-events`.
@@ -363,8 +370,23 @@ machine terminates because every region of a top-level parallel state reached a
363370
On 3.2.1, a coroutine awaiting a delayed event still pending when the machine terminated was
364371
never answered.
365372

373+
An exception that escapes the internal events or eventless transitions following an external
374+
event is now handled like one escaping the external event itself: every event still queued,
375+
internal or external, delayed or not, is dropped, and a coroutine awaiting the `send()` of a
376+
dropped event gets that exception. Any exception escapes with `catch_errors_as_events=False`,
377+
as on a `StateMachine`; on a `StateChart`, one from a validator or `prepare_event` does. On
378+
3.2.1 the queued external events ran before those internal events and eventless transitions,
379+
and their callers got their own results; the exception was then raised by the sync `send()`
380+
that started processing, and on the async engine it reached no caller. On the async engine, as
381+
on 3.2.1, the coroutine whose event enabled the failing transitions gets its own event's
382+
result. Internal events raised by a callback before it fails are dropped too: they used to run
383+
at the next event. The same applies when the lazy initial activation fails on the async engine,
384+
where a second coroutine awaiting an event sent before it used to wait forever, and the events
385+
sent before it, which used to run at the next `send()`, are dropped.
386+
366387
[#668](https://github.com/fgmacedo/python-statemachine/pull/668),
367388
[#678](https://github.com/fgmacedo/python-statemachine/issues/678),
389+
[#686](https://github.com/fgmacedo/python-statemachine/issues/686),
368390
[#685](https://github.com/fgmacedo/python-statemachine/pull/685).
369391

370392
(release-3.2.2-prefixed-events)=

‎statemachine/engines/async_.py‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,8 @@ async def processing_loop(self, caller_future: "asyncio.Future[object] | None" =
373373
self._drop_external_events()
374374

375375
except Exception as exc:
376+
self._reject_pending_futures(exc)
377+
self.clear()
376378
if caller_future is not None:
377379
# Route the exception to the caller's future if still pending.
378380
# If already resolved (caller's own event succeeded before a
@@ -478,17 +480,12 @@ async def _take_external_event(self, external_event: TriggerData):
478480
self._resolve_future(event_future, result)
479481
return result
480482
if not self.sm.allow_event_without_transition:
481-
tna = TransitionNotAllowed(external_event.event, self.sm.configuration)
482-
self._reject_future(event_future, tna)
483-
self._reject_pending_futures(tna)
484-
raise tna
483+
raise TransitionNotAllowed(external_event.event, self.sm.configuration)
485484
# Event allowed but no transition — resolve with None
486485
self._resolve_future(event_future, None)
487486
return self._sentinel
488487
except Exception as exc:
489488
self._reject_future(event_future, exc)
490-
self._reject_pending_futures(exc)
491-
self.clear()
492489
raise
493490

494491
async def enabled_events(self, *args, **kwargs):

‎statemachine/engines/base.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ def put(self, trigger_data: TriggerData, internal: bool = False):
185185
)
186186

187187
def clear(self):
188+
self.internal_queue.clear()
188189
self.external_queue.clear()
189190

190191
def _drop_external_events(self):

‎statemachine/engines/sync.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,13 @@ def processing_loop(self):
7070
took_events = True
7171
while took_events and self.running:
7272
self.clear_cache()
73-
took_events = self._run_macrostep()
74-
75-
# Spawn invoke handlers for states entered during this macrostep.
76-
self._invoke_manager.spawn_pending_sync()
73+
try:
74+
took_events = self._run_macrostep()
75+
# Spawn invoke handlers for states entered during this macrostep.
76+
self._invoke_manager.spawn_pending_sync()
77+
except Exception:
78+
self.clear()
79+
raise
7780
self._check_root_final_state()
7881
if not self.running:
7982
break

0 commit comments

Comments
 (0)