Skip to content

Commit 36965b7

Browse files
authored
fix: report errors raised while an invocation processes its done.invoke (#698)
1 parent 8be9e4b commit 36965b7

3 files changed

Lines changed: 181 additions & 13 deletions

File tree

‎docs/invoke.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,8 @@ True
425425

426426
## Error handling
427427

428-
If an invoke handler raises an exception, `error.execution` is sent to the machine's
429-
internal queue (when `catch_errors_as_events=True`, the default for `StateChart`). You can
428+
If an invoke handler raises an exception while its state is active, `error.execution` is
429+
sent to the machine's external queue, whatever `catch_errors_as_events` is set to. You can
430430
handle it with a transition for `error.execution`:
431431

432432
```py
@@ -451,6 +451,13 @@ True
451451

452452
```
453453

454+
An error raised while the machine processes `done.invoke`, by a callback or by the
455+
handler's `on_finalize`, is handled like an error in any other step: it becomes `error.execution` when `catch_errors_as_events=True`,
456+
and propagates otherwise. When the machine is idle as the handler finishes, the handler's
457+
own thread or task processes `done.invoke`, and nobody awaits it: the sync engine lets the
458+
error propagate out of the thread, to `threading.excepthook`, and the async engine passes
459+
it to the event loop's exception handler.
460+
454461
## Multiple invokes
455462

456463
### Independent invokes (one event each)

‎statemachine/invoke.py‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -401,11 +401,6 @@ def _run_sync_handler(
401401
result = handler.run(ctx)
402402
else:
403403
result = callback.call(ctx=ctx, machine=ctx.machine, **ctx.kwargs)
404-
if not ctx.cancelled.is_set():
405-
self.sm.send(
406-
f"done.invoke.{ctx.invokeid}",
407-
data=result,
408-
)
409404
except Exception as e:
410405
if not ctx.cancelled.is_set():
411406
# Intentionally using the external queue (no internal=True):
@@ -415,6 +410,9 @@ def _run_sync_handler(
415410
# active (the internal queue is only drained within a macrostep).
416411
# This matches done.invoke, which also uses the external queue.
417412
self.sm.send("error.execution", error=e)
413+
else:
414+
if not ctx.cancelled.is_set():
415+
self.sm.send(f"done.invoke.{ctx.invokeid}", data=result)
418416
finally:
419417
invocation.terminated = True
420418
self._debug(
@@ -484,11 +482,6 @@ async def _run_async_handler(
484482
result = await loop.run_in_executor(
485483
None, lambda: callback.call(ctx=ctx, machine=ctx.machine, **ctx.kwargs)
486484
)
487-
if not ctx.cancelled.is_set():
488-
await self.sm.send(
489-
f"done.invoke.{ctx.invokeid}",
490-
data=result,
491-
)
492485
except asyncio.CancelledError:
493486
# Intentionally swallowed: the owning state was exited, so this
494487
# invocation was cancelled — there is nothing to propagate.
@@ -497,6 +490,9 @@ async def _run_async_handler(
497490
if not ctx.cancelled.is_set():
498491
# External queue — see comment in _run_sync_handler.
499492
await self.sm.send("error.execution", error=e)
493+
else:
494+
if not ctx.cancelled.is_set():
495+
await self._send_done_async(ctx.invokeid, result)
500496
finally:
501497
invocation.terminated = True
502498
self._debug(
@@ -506,6 +502,19 @@ async def _run_async_handler(
506502
ctx.cancelled.is_set(),
507503
)
508504

505+
async def _send_done_async(self, invokeid: str, result: Any):
506+
"""Send ``done.invoke`` and report an error raised while processing it.
507+
508+
The invocation's task drives the macrostep when the machine is idle, and no caller
509+
awaits that task, so the error goes to the event loop's exception handler.
510+
"""
511+
try:
512+
await self.sm.send(f"done.invoke.{invokeid}", data=result)
513+
except Exception as e:
514+
asyncio.get_running_loop().call_exception_handler(
515+
{"message": f"Error while processing done.invoke.{invokeid}", "exception": e}
516+
)
517+
509518
# --- Cancel ---
510519

511520
def _cancel(self, invokeid: str):
@@ -589,7 +598,10 @@ def handle_external_event(self, trigger_data) -> None:
589598
# Note: finalize must run even after the invocation terminates, because
590599
# child events may still be queued when the handler thread completes.
591600
if is_from_child and hasattr(handler, "on_finalize"):
592-
handler.on_finalize(trigger_data)
601+
try:
602+
handler.on_finalize(trigger_data)
603+
except Exception as e:
604+
self._engine._handle_error(e, trigger_data)
593605

594606
# Autoforward: forward parent events to child (not events from child itself).
595607
# Only forward if the invocation is still running.

‎tests/test_invoke.py‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
"""Tests for the invoke callback group."""
22

3+
import asyncio
34
import threading
45
import time
56

7+
import pytest
68
from statemachine.invoke import IInvoke
79
from statemachine.invoke import InvokeContext
810
from statemachine.invoke import invoke_group
911

1012
from statemachine import Event
1113
from statemachine import State
1214
from statemachine import StateChart
15+
from statemachine import StateMachine
1316

1417

1518
class TestInvokeSimpleCallable:
@@ -295,6 +298,152 @@ def on_enter_error_state(self, **kwargs):
295298
assert "error_state" in sm.configuration_values
296299

297300

301+
@pytest.fixture()
302+
def thread_errors(monkeypatch) -> "list[BaseException]":
303+
errors: "list[BaseException]" = []
304+
monkeypatch.setattr(threading, "excepthook", lambda args: errors.append(args.exc_value))
305+
return errors
306+
307+
308+
async def finish_invocation(
309+
sm, release: threading.Event, thread_errors: "list[BaseException]"
310+
) -> "list[BaseException]":
311+
"""Let the only invocation finish and return the errors its thread or task reported."""
312+
(invocation,) = sm._engine._invoke_manager._active.values()
313+
if invocation.task is None:
314+
release.set()
315+
invocation.thread.join(timeout=5)
316+
return thread_errors
317+
loop_errors: "list[BaseException]" = []
318+
loop = asyncio.get_running_loop()
319+
previous_handler = loop.get_exception_handler()
320+
loop.set_exception_handler(lambda _loop, context: loop_errors.append(context["exception"]))
321+
try:
322+
release.set()
323+
await asyncio.wait([invocation.task], timeout=5)
324+
finally:
325+
loop.set_exception_handler(previous_handler)
326+
assert invocation.task.exception() is None
327+
return loop_errors
328+
329+
330+
class TestInvokeCompletionProcessingErrors:
331+
"""An error raised while the invocation itself processes its ``done.invoke`` is not lost.
332+
333+
The invocation finishes after the machine settled, so its own thread or task processes
334+
``done.invoke`` and the error has no other caller to reach.
335+
"""
336+
337+
async def test_error_leaving_the_state_propagates(self, sm_runner, thread_errors):
338+
release = threading.Event()
339+
340+
class SM(StateMachine):
341+
s1 = State(initial=True)
342+
s2 = State(final=True)
343+
done_invoke_s1 = s1.to(s2, on="boom")
344+
345+
def on_invoke_s1(self):
346+
release.wait(timeout=5)
347+
348+
def boom(self):
349+
raise ValueError("boom")
350+
351+
sm = await sm_runner.start(SM)
352+
errors = await finish_invocation(sm, release, thread_errors)
353+
354+
assert [repr(e) for e in errors] == [repr(ValueError("boom"))]
355+
assert sm.configuration_values == {"s1"}
356+
357+
async def test_error_staying_in_the_state_propagates(self, sm_runner, thread_errors):
358+
release = threading.Event()
359+
360+
class SM(StateMachine):
361+
s1 = State(initial=True)
362+
s2 = State(final=True)
363+
done_invoke_s1 = s1.to.itself(internal=True, on="boom")
364+
error_execution = s1.to(s2)
365+
366+
def on_invoke_s1(self):
367+
release.wait(timeout=5)
368+
369+
def boom(self):
370+
raise ValueError("boom")
371+
372+
sm = await sm_runner.start(SM)
373+
errors = await finish_invocation(sm, release, thread_errors)
374+
375+
assert [repr(e) for e in errors] == [repr(ValueError("boom"))]
376+
assert sm.configuration_values == {"s1"}
377+
378+
async def test_error_leaving_the_state_becomes_error_execution(self, sm_runner, thread_errors):
379+
release = threading.Event()
380+
381+
class SM(StateChart):
382+
s1 = State(initial=True)
383+
s2 = State()
384+
handled = State(final=True)
385+
done_invoke_s1 = s1.to(s2, on="boom")
386+
error_execution = s2.to(handled)
387+
388+
def on_invoke_s1(self):
389+
release.wait(timeout=5)
390+
391+
def boom(self):
392+
raise ValueError("boom")
393+
394+
sm = await sm_runner.start(SM)
395+
errors = await finish_invocation(sm, release, thread_errors)
396+
397+
assert errors == []
398+
assert sm.configuration_values == {"handled"}
399+
400+
401+
class FailingFinalize:
402+
def __init__(self, release: threading.Event):
403+
self.release = release
404+
405+
def run(self, ctx):
406+
self.release.wait(timeout=5)
407+
408+
def on_finalize(self, trigger_data):
409+
raise ValueError("finalize")
410+
411+
412+
class TestInvokeFinalizeErrors:
413+
"""An error raised by ``on_finalize`` is handled like an error in any other step."""
414+
415+
async def test_finalize_error_becomes_error_execution(self, sm_runner, thread_errors):
416+
release = threading.Event()
417+
418+
class SM(StateChart):
419+
s1 = State(initial=True, invoke=FailingFinalize(release))
420+
s2 = State()
421+
handled = State(final=True)
422+
done_invoke_s1 = s1.to(s2)
423+
error_execution = s2.to(handled)
424+
425+
sm = await sm_runner.start(SM)
426+
errors = await finish_invocation(sm, release, thread_errors)
427+
428+
assert errors == []
429+
assert sm.configuration_values == {"handled"}
430+
431+
async def test_finalize_error_propagates(self, sm_runner, thread_errors):
432+
release = threading.Event()
433+
434+
class SM(StateMachine):
435+
s1 = State(initial=True, invoke=FailingFinalize(release))
436+
s2 = State(final=True)
437+
done_invoke_s1 = s1.to(s2)
438+
error_execution = s1.to(s2)
439+
440+
sm = await sm_runner.start(SM)
441+
errors = await finish_invocation(sm, release, thread_errors)
442+
443+
assert [repr(e) for e in errors] == [repr(ValueError("finalize"))]
444+
assert sm.configuration_values == {"s1"}
445+
446+
298447
class TestInvokeMultiple:
299448
"""Multiple invokes per state — all run concurrently."""
300449

0 commit comments

Comments
 (0)