diff --git a/nanobot/process_runtime.py b/nanobot/process_runtime.py index 93113cdee..366963dac 100644 --- a/nanobot/process_runtime.py +++ b/nanobot/process_runtime.py @@ -308,26 +308,18 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]): return self._wait_for_exit(pid, 2) def _terminate_windows(self, pid: int, *, timeout_s: int) -> bool: - ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None) - if ctrl_break is not None: - ctrl_break_sent = False - try: - os.kill(pid, ctrl_break) - except ProcessLookupError: - return True - except OSError: - pass - else: - ctrl_break_sent = True - if ctrl_break_sent and self._wait_for_exit(pid, timeout_s): - return True + # ``os.kill(pid, CTRL_BREAK_EVENT)`` delegates to + # GenerateConsoleCtrlEvent. That API targets a console process group, + # not an individual process, and can interrupt the caller when a + # detached/no-window child has no addressable console group. Keep + # termination scoped to the recorded PID tree instead. self._subprocess_run( ["taskkill", "/PID", str(pid), "/T"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - if self._wait_for_exit(pid, 2): + if self._wait_for_exit(pid, timeout_s): return True self._subprocess_run( ["taskkill", "/PID", str(pid), "/T", "/F"], diff --git a/tests/gateway/test_runtime.py b/tests/gateway/test_runtime.py index c570de337..7b6306d34 100644 --- a/tests/gateway/test_runtime.py +++ b/tests/gateway/test_runtime.py @@ -564,7 +564,7 @@ def test_stop_succeeds_when_process_exits_at_timeout_boundary(tmp_path, monkeypa assert result.status.running is False -def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monkeypatch): +def test_terminate_windows_targets_only_the_recorded_process_tree(tmp_path, monkeypatch): taskkill_calls: list[dict] = [] wait_timeouts: list[int | float] = [] @@ -578,12 +578,10 @@ def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monk 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.process_runtime.os.kill", fake_kill) + monkeypatch.setattr( + "nanobot.process_runtime.os.kill", + lambda *_args: pytest.fail("Windows termination must not broadcast a console event"), + ) def fake_wait_for_exit(_pid, _timeout_s): wait_timeouts.append(_timeout_s) @@ -593,7 +591,7 @@ def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monk monkeypatch.setattr(runtime, "_wait_for_exit", fake_wait_for_exit) assert runtime._terminate_windows(12345, timeout_s=20) is True - assert wait_timeouts == [2] + assert wait_timeouts == [20] assert taskkill_calls == [ { "command": ["taskkill", "/PID", "12345", "/T"],