This commit is contained in:
poison-control 2026-10-05 16:05:20 +00:00 • committed by GitHub
commit 774c42da27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 60 additions and 0 deletions

View file

@ -76,6 +76,7 @@ class GoTuiRuntime:
self._output_syncs: set[asyncio.Task[None]] = 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,
@ -370,6 +371,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

View file

@ -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,