From 602dc8615bcab39d5d8585362ae8b4f46d0b9c28 Mon Sep 17 00:00:00 2001 From: icn5381 <255778606+icn5381@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:28:24 +0800 Subject: [PATCH] fix(turn): clear active slot on cancel to avoid phantom -32003 turn.cancel relied on the sink's on_turn_end callback (fired from _drop at turn exit) to clear the _active_turns slot, and awaited handle.result() assuming the sink had already dropped it. A cancelled turn can resolve before that callback runs, so the slot was sometimes left populated and the next turn.send hit a phantom turn_in_progress (-32003). Call clear_active from turn_cancel itself after the drain. pop is idempotent, so the sink's later clear is a harmless no-op. Co-authored-by: Claude (claude-opus-5) --- raven/tui_rpc/methods/turn.py | 5 ++++ tests/test_turn_cancel_clears_active.py | 38 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/test_turn_cancel_clears_active.py diff --git a/raven/tui_rpc/methods/turn.py b/raven/tui_rpc/methods/turn.py index e75fa42..fec27bb 100644 --- a/raven/tui_rpc/methods/turn.py +++ b/raven/tui_rpc/methods/turn.py @@ -309,6 +309,11 @@ async def turn_cancel( # handle.result() returns None on cancellation (does not raise). await handle.result() + # Defensive clear: the sink drops the slot via on_turn_end at turn exit, + # but a cancelled turn can resolve before that callback runs. Clear here + # so the next turn.send does not race into a phantom -32003. + clear_active(parsed.session_key) + return {"cancelled": True} diff --git a/tests/test_turn_cancel_clears_active.py b/tests/test_turn_cancel_clears_active.py new file mode 100644 index 0000000..1f3983b --- /dev/null +++ b/tests/test_turn_cancel_clears_active.py @@ -0,0 +1,38 @@ +"""turn.cancel must drop the active-turn slot even if the sink has not yet (issue #115).""" + +from __future__ import annotations + +from raven.tui_rpc.methods import turn as turn_mod + + +class _FakeHandle: + """Stand-in for a TurnHandle: cancel marks it, result resolves to None.""" + + def __init__(self) -> None: + self.cancelled = False + + def cancel(self) -> None: + self.cancelled = True + + async def result(self) -> None: + return None + + +async def test_turn_cancel_clears_active_slot() -> None: + session_key = "sess-cancel-test" + turn_mod._active_turns[session_key] = _FakeHandle() + + result = await turn_mod.turn_cancel({"session_key": session_key}, emitter=None) + + assert result == {"cancelled": True} + assert turn_mod.is_turn_active(session_key) is False + + +async def test_turn_cancel_without_active_turn_is_noop() -> None: + session_key = "sess-idle" + turn_mod._active_turns.pop(session_key, None) + + result = await turn_mod.turn_cancel({"session_key": session_key}, emitter=None) + + assert result == {"cancelled": False} + assert turn_mod.is_turn_active(session_key) is False