Skip to content

fix: answer callers queued behind a macrostep that raises - #688

Merged
fgmacedo merged 1 commit into
mainfrom
fgmacedo/fix-686-async-error-pending
Oct 13, 2026
Merged

fgmacedo merged 1 commit into
mainfrom
fgmacedo/fix-686-async-error-pending

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Why

On main after #668, with catch_errors_as_events=False (the StateMachine default), an exception escaping the internal events or eventless transitions that follow an external event leaves the events queued behind it in place. On the async engine, coroutines awaiting send() of those events never return: 17 of the 43 new regression tests hit the 5 s pytest-timeout on main. On the sync engine the queued events run later, as stale events, at the next send(). Fixes #686.

What changes

  • Async: any exception that ends the processing loop rejects every pending send() future with that exception and drops every queued event (internal, external, delayed). This covers raising callbacks (before/on/after/enter/exit), guards and validators in the following macrostep, and a failed lazy initial activation.
  • Sync: an exception from the macrostep (or from spawning its invocations) drops the queued events, as an exception from the external event's own microstep already did.
  • BaseEngine.clear() now clears the internal queue too, so internal events raised before a failure no longer run inside the next, unrelated event.
  • The coroutine whose own event enabled the failing transitions still gets its event's result (unchanged from v3.2.1).
  • StateChart with catch_errors_as_events=True is unchanged: errors become error.execution, and queued callers get their results.
  • Release notes (docs/releases/3.2.2.md, macrostep section and upgrade bullet) and docs/async.md state the contract and how it differs from v3.2.1, where the queued external events ran before the failing internal event.

Evidence

v3.2.1 contract checked on a v3.2.1 worktree with a repro script (holder sends go, two coroutines queue step, the failure happens after go):

                         v3.2.1                      main                      this branch
StateMachine internal    holder='went' others=[1,2]  holder='went' others=HANG  holder='went' others=ValueError x2
StateMachine eventless   holder='went' others=[1,2]  holder='went' others=HANG  holder='went' others=ValueError x2
StateChart (all)         others=[1,2]                others=[1,2]               others=[1,2]

New tests (TestMacrostepErrorAnswersQueuedCallers in tests/test_async_futures.py):

$ git checkout origin/main -- statemachine && timeout 300 uv run pytest tests/test_async_futures.py -k MacrostepError --no-cov -q -n 4
31 failed, 12 passed            # 17 of them by Timeout (>5.0s); the 12 passing are the StateChart guards
$ git checkout HEAD -- statemachine && timeout 120 uv run pytest tests/test_async_futures.py -k MacrostepError --no-cov -q -n 4
43 passed in 0.49s

Full suite, coverage, linters:

$ timeout 300 uv run pytest -n 4 --cov=statemachine --cov-report=term-missing:skip-covered
TOTAL    6226      0   1958      0   100%
2707 passed, 25 skipped, 44 xfailed
$ uv run pytest tests/test_threading.py --no-cov -q   # 3 runs
11 passed (x3)
$ uv run ruff check . && uv run mypy --namespace-packages --explicit-package-bases statemachine/ tests/
All checks passed! / Success: no issues found

Three adversarial review rounds by fresh reviewers; round 1 found the sync guard path and leftover internal events, round 2 the docs scope, round 3 nothing material.

Not verified

  • On the async engine, an exception from the macrostep that follows the holder's own event still reaches no caller when nobody else is queued (same as v3.2.1); not changed here.
  • A cancelled lock-holder task (CancelledError is not an Exception) still strands queued callers, on v3.2.1 too.
  • On the sync engine, TransitionNotAllowed or a raising guard in an external event's own selection does not clear the queue (same as v3.2.1).
  • Async handle_external_event raising leaves that popped event's awaiting coroutine unanswered (pre-existing).
  • Not exercised under real thread contention beyond tests/test_threading.py.

With catch_errors_as_events=False, an exception raised by a guard or a
callback of the internal events or eventless transitions that follow an
external event left the events queued behind it in the queues, and on
the async engine a coroutine awaiting one of them was never answered.
Handle it like an exception raised by the external event: drop the
queued events, internal and external, and reject their futures with the
exception.

Fixes #686

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

Copy link
Copy Markdown

@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (0be2460) to head (32f3f34).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #688   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           53        53           
  Lines         5816      5818    +2     
  Branches       926       926           
=========================================
+ Hits          5816      5818    +2     
Flag Coverage Δ
unittests 100.00% <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.

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.

Async: an exception in the lock holder's macrostep leaves other awaiting send() callers pending

1 participant