From 369a3443eb979230922ffab46c7ce545025ad7c0 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 18 Aug 2026 11:46:23 +0800 Subject: [PATCH] fix(gateway): allow Windows launcher PID handoff --- nanobot/gateway/runtime.py | 18 ++++++++++--- tests/gateway/test_runtime.py | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/nanobot/gateway/runtime.py b/nanobot/gateway/runtime.py index 828c2f604..d3f270f42 100644 --- a/nanobot/gateway/runtime.py +++ b/nanobot/gateway/runtime.py @@ -234,6 +234,7 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]): state = self._read_state() if state and result.status.pid == state.get("pid"): state["launch_mode"] = "background" + state["pending_pid_handoff"] = True self._write_state(state) return RuntimeResult(True, result.message, self.status()) @@ -288,14 +289,24 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]): lease.wait_for_shutdown() with self._transition_lock(), self._lifecycle_lock(): current = self.status() - if current.running and current.pid != pid: + state = self._read_state() or {} + pid_handoff = ( + self.platform_name == "Windows" + and current.running + and current.pid != pid + and current.pid == os.getppid() + and state.get("pid") == current.pid + and state.get("launch_mode") == "background" + and state.get("pending_pid_handoff") is True + ) + if current.running and current.pid != pid and not pid_handoff: raise GatewayAlreadyRunningError(current) if lease._shutdown_pending_locked(): continue - state = self._read_state() or {} launch_mode: GatewayLaunchMode = ( "background" - if state.get("pid") == pid and state.get("launch_mode") == "background" + if state.get("launch_mode") == "background" + and (state.get("pid") == pid or pid_handoff) else "foreground" ) state.update( @@ -311,6 +322,7 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]): "launch_mode": launch_mode, } ) + state.pop("pending_pid_handoff", None) state.pop("stable_identity", None) state.update(self.process_identity_record(pid)) self._write_state(state) diff --git a/tests/gateway/test_runtime.py b/tests/gateway/test_runtime.py index 01059154b..e0fa0f514 100644 --- a/tests/gateway/test_runtime.py +++ b/tests/gateway/test_runtime.py @@ -120,6 +120,29 @@ def _wait_for_claim( pytest.fail(f"gateway claim failed: returncode={process.poll()}, {detail}") +class _ManagedForegroundChildRuntime(GatewayRuntime): + """Launch the foreground claim helper through the managed-process path.""" + + def __init__(self, *, data_dir: Path, marker: Path) -> None: + super().__init__( + paths=_paths(data_dir), + platform_name="Windows", + python_executable=sys.executable, + ) + self._data_dir = data_dir + self._marker = marker + + def _build_child_command(self, options: GatewayStartOptions) -> list[str]: + return [ + self.python_executable, + "-c", + _FOREGROUND_CHILD, + str(self._data_dir), + "30", + str(self._marker), + ] + + def test_paths_use_stable_instance_suffix_for_custom_selectors(tmp_path): default_paths = GatewayRuntimePaths.for_instance(data_dir=tmp_path) first_paths = GatewayRuntimePaths.for_instance( @@ -775,6 +798,32 @@ def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch assert "start_new_session" not in calls[0]["kwargs"] +@pytest.mark.skipif( + os.name != "nt" or sys.prefix == sys.base_prefix, + reason="requires a Windows virtualenv launcher", +) +def test_managed_background_hands_off_virtualenv_launcher_pid(tmp_path: Path) -> None: + marker = tmp_path / "managed-foreground.marker" + runtime = _ManagedForegroundChildRuntime(data_dir=tmp_path, marker=marker) + + result = runtime.start_on_demand(GatewayStartOptions(port=18790)) + launcher = runtime._owned_process + assert isinstance(launcher, subprocess.Popen) + try: + claimed_pid = _wait_for_claim(launcher, marker) + + status = runtime.status() + assert result.ok is True + assert claimed_pid != launcher.pid + assert status.pid == claimed_pid + assert status.launch_mode == "background" + assert status.lifetime == "on_demand" + finally: + if runtime.status().running: + runtime.stop(timeout_s=3) + launcher.wait(timeout=3) + + def test_windows_process_probe_never_sends_ctrl_c(monkeypatch): monkeypatch.setattr( "nanobot.process_runtime._windows_process_identity",