From cf1897281b2f30a6c095f46d1e1b59c4b42ab3dd Mon Sep 17 00:00:00 2001 From: "mr.yuk" Date: Fri, 18 Sep 2026 09:07:34 -0700 Subject: [PATCH] fix(tui): tear the sandbox down when Ctrl+Q quits twice The sidecar sends app.quit and exits in the same batch, so the command handler and run()'s finally both call quit(). The second cancel lands on the docker delete the first one's teardown is awaiting, and the sandbox container is left running. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QE1brKHrPWcD8SHYooGBA1 --- strix/interface/tui/runtime.py | 16 +++++++++++++ tests/test_go_tui_runtime.py | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py index a20960ce..be8aa978 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -71,6 +71,7 @@ class GoTuiRuntime: self._error_noted_agents: set[str] = set() self.model_verified = False self._setup_preflight: asyncio.Task[None] | None = None + self._quit_task: asyncio.Task[None] | None = None self.controller = TuiController( args, live_view=self.live_view, @@ -378,6 +379,21 @@ class GoTuiRuntime: await asyncio.sleep(0.5) async def quit(self) -> None: + """Stop the scan once, however many callers ask for it. + + Ctrl+Q quits twice: the sidecar sends ``app.quit`` and exits in the + same batch, so the command handler and ``run``'s finally both land + here. A second cancel while the first is tearing the sandbox down + interrupts the docker delete and leaves the container running, so + callers after the first wait on the quit already in flight. + """ + if self._quit_task is None: + self._quit_task = asyncio.create_task(self._quit_once()) + # Shielded: awaiting a task hands it the waiter's own cancellation, + # which is what this guard exists to keep out of the teardown. + await asyncio.shield(self._quit_task) + + async def _quit_once(self) -> None: self.controller.close_viewer() self.coordinator.mark_shutting_down() scan_task = self.scan_task diff --git a/tests/test_go_tui_runtime.py b/tests/test_go_tui_runtime.py index afae99f1..ffdd82ea 100644 --- a/tests/test_go_tui_runtime.py +++ b/tests/test_go_tui_runtime.py @@ -877,6 +877,50 @@ async def test_scan_passes_max_turns_and_budget(monkeypatch: pytest.MonkeyPatch) assert runtime.controller.scan_state == "stopped" +@pytest.mark.asyncio +async def test_quitting_twice_lets_the_sandbox_teardown_finish( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ctrl+Q quits twice: the app.quit command and run()'s finally.""" + runtime = GoTuiRuntime(args()) + runtime.scan_config = {"run_name": "test-run"} + torn_down: list[str] = [] + scanning = asyncio.Event() + tearing_down = asyncio.Event() + docker_replied = asyncio.Event() + + async def run_scan(**kwargs: Any) -> None: + try: + scanning.set() + await asyncio.sleep(3600) + finally: + # session_manager.cleanup() waits on the docker daemon here + tearing_down.set() + await docker_replied.wait() + torn_down.append(kwargs["scan_id"]) + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(runtime=SimpleNamespace(image="test-image")), + ) + monkeypatch.setattr(go_tui, "run_strix_scan", run_scan) + monkeypatch.setattr(go_tui, "read_workspace_files", lambda _value: []) + + runtime.start_scan() + await scanning.wait() + + command_quit = asyncio.create_task(runtime.quit()) + await tearing_down.wait() + final_quit = asyncio.create_task(runtime.quit()) + for _ in range(3): + await asyncio.sleep(0) + docker_replied.set() + await asyncio.gather(command_quit, final_quit) + + assert torn_down == ["test-run"], "the sandbox container was left running" + + @pytest.mark.asyncio async def test_setup_preflight_failure_does_not_start_scan( monkeypatch: pytest.MonkeyPatch,