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,