Skip to content

fix: report errors raised while an invocation processes its done.invoke - #698

Merged
fgmacedo merged 1 commit into
mainfrom
fgmacedo/uml-w2e_async_invoke
Oct 15, 2026
Merged

fgmacedo merged 1 commit into
mainfrom
fgmacedo/uml-w2e_async_invoke

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Refs #692

Why

When an invoke handler finishes while the machine is idle, its own thread (sync engine) or task (async engine) sends done.invoke.<id> and runs that macrostep itself. InvokeManager wrapped both the handler's run() and that send in one except Exception, so an error raised while processing done.invoke:

  • was dropped silently when the transition left the invoke's state (ctx.cancelled was already set); the machine rolled back to the old configuration with its invocation cancelled;
  • was re-sent as error.execution otherwise, even on StateMachine (catch_errors_as_events=False).

This happened on both engines, not only async: the sync engine surfaced the error only when another thread happened to be driving the loop. Found by the PSSM suite work in #697.

What changes

  • done.invoke is sent in an else: branch, so only errors from the handler's own run() become error.execution (still only while the state is active, so cancellation semantics are unchanged).
  • Sync engine: an error from processing done.invoke propagates out of the handler thread, to threading.excepthook.
  • Async engine: _send_done_async passes it to the event loop's exception handler right away. Left as the task's exception, it only surfaced when the task was garbage collected.
  • handle_external_event runs on_finalize through the engine's _handle_error: a finalize error becomes an internal error.execution on StateChart (after the external event's transition, as SCXML orders it) and propagates on StateMachine. Without this, the else: change regressed finalize errors on StateChart, which reached error.execution only by accident through the old re-send.
  • docs/invoke.md "Error handling": the existing paragraph said handler errors went to the internal queue and only with catch_errors_as_events=True; it now matches the code. A new paragraph covers errors while processing done.invoke.

Behavior changes worth a release note (3.3.0 notes do not exist yet, so none was written):

  • A StateMachine whose done.invoke processing raises now reports the error instead of moving through error_execution. A StateMachine with error_execution but no done_invoke_<state> transition now reports TransitionNotAllowed for done.invoke.
  • A CancelledError raised during the done.invoke send now ends the invocation task cancelled instead of being swallowed. No state difference is observable; nothing awaits that task.

#697's harness wraps send to record this error (see build_machine in tests/uml/pssm/test_pssm_cases.py). Once both merge, that workaround can be removed; the harness then observes the error through threading.excepthook and the loop's exception handler.

Evidence

  • New tests in tests/test_invoke.py, on both engines through sm_runner: TestInvokeCompletionProcessingErrors (StateMachine leaving the state, StateMachine staying in it, StateChart turning it into error.execution) and TestInvokeFinalizeErrors (StateChart and StateMachine). 8 of the 10 fail on main; the StateChart callback pair passes on main and stays as a guard.
  • Full suite timeout 180 uv run pytest -n 4: 2787 passed, 25 skipped, 44 xfailed; branch coverage 100%. W3C SCXML and threading tests unchanged and green. ruff, mypy, pyright clean; pre-commit hooks passed.
  • New tests repeated 10+ times locally and 40x/25x by reviewers with no failure; also passed on Python 3.10 and 3.14 in a reviewer's isolated venvs.
  • Adversarial review by fresh subagents, three rounds:
    1. Found the async error surfaced only at task garbage collection (fixed with call_exception_handler) and an inaccurate existing docs paragraph (fixed). Also flagged the StateMachine without done_invoke change (accepted, listed above).
    2. Found the on_finalize regression on StateChart (fixed and tested) and that the test helper did not restore the previous loop exception handler (fixed). Also noted the ctx.send path below.
    3. No material findings. Confirmed SCXML finalize ordering (test233 shape with a failing finalize reaches pass on both engines), no double reporting on the busy path, and that a StateChart caller of an unrelated event no longer receives a finalize error.

Not verified

  • An event sent with ctx.send from inside run() (for example timeout(..., on=...)) is processed by the handler's thread or task too. An error there reaches run() as the caller of send; if run() lets it escape after the transition left the state, it is still dropped, as any run() error after cancellation. This is unchanged from main; changing it means changing cancellation semantics, which this PR does not do.
  • On StateMachine, a finalize error on the busy path still reaches whichever caller is driving the loop, as on main.
  • Whether reporting through threading.excepthook and the loop exception handler is the channel the maintainer wants, rather than delivering the error to a later caller. The latter would be a public API decision.

When an invoke handler finishes while the machine is idle, its own thread or
task sends done.invoke and runs the macrostep. An error raised there was
swallowed when the transition exited the invoke's state, and otherwise
re-sent as error.execution even on StateMachine.

done.invoke is now sent outside the handler's error path: the sync engine
lets the error propagate out of the handler thread, and the async engine
passes it to the event loop's exception handler. Errors raised by
on_finalize go through the engine's error handling, so they become
error.execution on StateChart and propagate on StateMachine.

Refs #692

Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
@fgmacedo fgmacedo added this to the 3.3.0 milestone Oct 14, 2026
@sonarqubecloud

Copy link
Copy Markdown

@codecov

codecov Bot commented Oct 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (d5282e0) to head (041b733).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #698   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           53        53           
  Lines         5897      5897           
  Branches       940       940           
=========================================
  Hits          5897      5897           
Flag Coverage Δ
unittests 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fgmacedo
fgmacedo merged commit 36965b7 into main Oct 15, 2026
14 checks passed
@fgmacedo
fgmacedo deleted the fgmacedo/uml-w2e_async_invoke branch October 15, 2026 01:32
fgmacedo added a commit that referenced this pull request Oct 16, 2026
Lay out the 3.3.0 notes in the structure later PRs extend and document
the history default entry fix (#693), the done.invoke error reporting
fix (#698) and the PSSM-based UML test cases (#697).

Refs #692
Refs #713

Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant