From e50cbac60442041dbc126ad3cb2780c19e6df5c2 Mon Sep 17 00:00:00 2001 From: lores Date: Thu, 11 Jun 2026 02:23:53 +0300 Subject: [PATCH 1/2] fix: skip async invoke self-cancel on own done.invoke --- statemachine/invoke.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/statemachine/invoke.py b/statemachine/invoke.py index e4976104..798066b3 100644 --- a/statemachine/invoke.py +++ b/statemachine/invoke.py @@ -526,7 +526,11 @@ def _cancel(self, invokeid: str): self._debug("%s Error in on_cancel for %s", self._log_id, invokeid, exc_info=True) # 3) Cancel the async task (raises CancelledError at next await). - if invocation.task is not None and not invocation.task.done(): + if ( + invocation.task is not None + and invocation.task is not asyncio.current_task() + and not invocation.task.done() + ): invocation.task.cancel() # 4) Wait for the sync thread to actually finish (skip if we ARE From e20c88e017d62b2402ae545c7fdd5948017a7b65 Mon Sep 17 00:00:00 2001 From: lores Date: Thu, 11 Jun 2026 03:32:18 +0300 Subject: [PATCH 2/2] test: cover async invoke self-cancel on own done.invoke --- tests/test_invoke.py | 59 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_invoke.py b/tests/test_invoke.py index 4298e3a9..e31baad2 100644 --- a/tests/test_invoke.py +++ b/tests/test_invoke.py @@ -214,6 +214,65 @@ class SM(StateChart): assert "cancelled_state" in sm.configuration_values +class TestInvokeSelfCancelOnDone: + """Self-cancel — an invoke whose done.invoke exits the owning state must + not cancel itself when it completes after the initial loop releases.""" + + async def test_async_invoke_no_self_cancel_on_done(self): + import asyncio + + from tests.conftest import SMRunner + + sm_runner = SMRunner(is_async=True) + reached = [] + + class SM(StateChart): + loading = State(initial=True) + ready = State(final=True) + done_invoke_loading = loading.to(ready) + + async def on_invoke_loading(self, **kwargs): + await asyncio.sleep(0.05) + return "ok" + + async def on_enter_ready(self, **kwargs): + # Suspension point so a pending self-cancel surfaces here. + await asyncio.sleep(0) + reached.append(True) + + sm = await sm_runner.start(SM) + await sm_runner.sleep(0.15) + await sm_runner.processing_loop(sm) + + assert reached == [True] + assert "ready" in sm.configuration_values + + async def test_sync_invoke_no_self_cancel_on_done(self): + from tests.conftest import SMRunner + + sm_runner = SMRunner(is_async=False) + reached = [] + + class SM(StateChart): + loading = State(initial=True) + ready = State(final=True) + done_invoke_loading = loading.to(ready) + + def on_invoke_loading(self, **kwargs): + time.sleep(0.05) + return "ok" + + def on_enter_ready(self, **kwargs): + reached.append(True) + + sm = await sm_runner.start(SM) + await sm_runner.sleep(0.15) + await sm_runner.processing_loop(sm) + + assert reached == [True] + assert "ready" in sm.configuration_values + + class TestInvokeErrorHandling: """Error in invoker → error.execution event."""