diff --git a/nanobot/gateway/runtime.py b/nanobot/gateway/runtime.py index 2627c0ac..47f06e7e 100644 --- a/nanobot/gateway/runtime.py +++ b/nanobot/gateway/runtime.py @@ -293,14 +293,26 @@ class GatewayRuntime: def _terminate_windows(self, pid: int, *, timeout_s: int) -> bool: ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None) if ctrl_break is not None: - with suppress(ProcessLookupError): + # Detached Windows children can reject CTRL_BREAK_EVENT with WinError 87; + # keep the existing taskkill fallback for that process shape. + with suppress(ProcessLookupError, OSError): os.kill(pid, ctrl_break) if self._wait_for_exit(pid, timeout_s): return True - self._subprocess_run(["taskkill", "/PID", str(pid), "/T"], check=False) + self._subprocess_run( + ["taskkill", "/PID", str(pid), "/T"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) if self._wait_for_exit(pid, 2): return True - self._subprocess_run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False) + self._subprocess_run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) return self._wait_for_exit(pid, 2) def _wait_for_exit(self, pid: int, timeout_s: int | float) -> bool: diff --git a/tests/gateway/test_runtime.py b/tests/gateway/test_runtime.py index b3646b8b..e79aa3e4 100644 --- a/tests/gateway/test_runtime.py +++ b/tests/gateway/test_runtime.py @@ -1,4 +1,6 @@ import json +import signal +import subprocess from pathlib import Path from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions @@ -168,3 +170,42 @@ def test_stop_keeps_state_when_process_survives_timeout(tmp_path, monkeypatch): assert result.status.running is True assert result.status.reason == "stop_timeout" assert runtime.paths.state_path.exists() + + +def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monkeypatch): + taskkill_calls: list[dict] = [] + + def fake_run(command, **kwargs): + taskkill_calls.append({"command": command, "kwargs": kwargs}) + + runtime = GatewayRuntime( + paths=_paths(tmp_path), + platform_name="Windows", + subprocess_run=fake_run, + sleep=lambda _seconds: None, + ) + + monkeypatch.setattr(signal, "CTRL_BREAK_EVENT", 1, raising=False) + + def fake_kill(_pid, _signal): + raise OSError(87, "The parameter is incorrect") + + monkeypatch.setattr("nanobot.gateway.runtime.os.kill", fake_kill) + + def fake_wait_for_exit(_pid, _timeout_s): + # Simulate a process that only exits after the taskkill fallback runs. + return bool(taskkill_calls) + + monkeypatch.setattr(runtime, "_wait_for_exit", fake_wait_for_exit) + + assert runtime._terminate_windows(12345, timeout_s=20) is True + assert taskkill_calls == [ + { + "command": ["taskkill", "/PID", "12345", "/T"], + "kwargs": { + "check": False, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + }, + } + ]