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 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."""