From 05acd7946684c5f7f44b9d1c8dd86f6f2232ff19 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:19:39 +0800 Subject: [PATCH] fix(gateway): probe Windows clients safely --- nanobot/gateway/runtime.py | 17 ++--------------- nanobot/process_runtime.py | 31 ++++++++++++++++++++----------- tests/gateway/test_runtime.py | 15 +++++++++++++++ 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/nanobot/gateway/runtime.py b/nanobot/gateway/runtime.py index 075c932d3..7ed332e09 100644 --- a/nanobot/gateway/runtime.py +++ b/nanobot/gateway/runtime.py @@ -26,6 +26,7 @@ from nanobot.process_runtime import ( ProcessRuntimePaths, ProcessStartOptions, ProcessStatus, + process_is_running, ) GatewayStartOptions = ProcessStartOptions @@ -374,7 +375,7 @@ class GatewayClientLease: def _process_is_running(self, pid: int) -> bool: checker = getattr(self.runtime, "process_is_running", None) - return bool(checker(pid)) if callable(checker) else _pid_is_running(pid) + return bool(checker(pid)) if callable(checker) else process_is_running(pid) @staticmethod def _clients(state: dict[str, object]) -> dict[str, object]: @@ -442,17 +443,3 @@ def _instance_suffix(*, workspace: str | None, config_path: str | None) -> str | if not raw: return None return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] - - -def _pid_is_running(pid: int) -> bool: - if pid <= 0: - return False - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except PermissionError: - return True - except OSError: - return False - return True diff --git a/nanobot/process_runtime.py b/nanobot/process_runtime.py index fca87d08f..93113cdee 100644 --- a/nanobot/process_runtime.py +++ b/nanobot/process_runtime.py @@ -356,17 +356,7 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]): return poll() is None except OSError: pass - if self.platform_name == "Windows": - return _windows_process_identity(pid) is not None - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except PermissionError: - return True - except OSError: - return False - return True + return process_is_running(pid, platform_name=self.platform_name) def _process_identity(self, pid: int) -> str | int | None: if self.platform_name == "Windows": @@ -455,6 +445,25 @@ def _platform_name() -> str: return "Linux" +def process_is_running(pid: int, *, platform_name: str | None = None) -> bool: + """Probe a PID without delivering a control event on Windows.""" + if pid <= 0: + return False + if (platform_name or _platform_name()) == "Windows": + # On Windows ``os.kill(pid, 0)`` sends CTRL_C_EVENT (whose value is 0) + # instead of performing the harmless POSIX existence probe. + return _windows_process_identity(pid) is not None + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + return True + + def _utc_now() -> str: return datetime.now(UTC).isoformat().replace("+00:00", "Z") diff --git a/tests/gateway/test_runtime.py b/tests/gateway/test_runtime.py index 6f122c579..6857af870 100644 --- a/tests/gateway/test_runtime.py +++ b/tests/gateway/test_runtime.py @@ -18,6 +18,7 @@ from nanobot.gateway import ( GatewayStatus, ) from nanobot.gateway.runtime import monitor_gateway_clients +from nanobot.process_runtime import process_is_running class FakeProcess: @@ -436,6 +437,20 @@ def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch assert "start_new_session" not in calls[0]["kwargs"] +def test_windows_process_probe_never_sends_ctrl_c(monkeypatch): + monkeypatch.setattr( + "nanobot.process_runtime._windows_process_identity", + lambda pid: "created-at" if pid == 12345 else None, + ) + monkeypatch.setattr( + "nanobot.process_runtime.os.kill", + lambda *_args: pytest.fail("Windows process probes must not call os.kill(pid, 0)"), + ) + + assert process_is_running(12345, platform_name="Windows") is True + assert process_is_running(54321, platform_name="Windows") is False + + def test_status_clears_stale_state(tmp_path, monkeypatch): runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux") runtime.paths.run_dir.mkdir(parents=True)