fix(gateway): allow Windows launcher PID handoff

This commit is contained in:
chengyongru
2026-08-18 11:51:14 +08:00
committed by chengyongru
parent 99d5fa2908
commit 369a3443eb
2 changed files with 64 additions and 3 deletions
+15 -3
View File
@@ -234,6 +234,7 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
state = self._read_state() state = self._read_state()
if state and result.status.pid == state.get("pid"): if state and result.status.pid == state.get("pid"):
state["launch_mode"] = "background" state["launch_mode"] = "background"
state["pending_pid_handoff"] = True
self._write_state(state) self._write_state(state)
return RuntimeResult(True, result.message, self.status()) return RuntimeResult(True, result.message, self.status())
@@ -288,14 +289,24 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
lease.wait_for_shutdown() lease.wait_for_shutdown()
with self._transition_lock(), self._lifecycle_lock(): with self._transition_lock(), self._lifecycle_lock():
current = self.status() 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) raise GatewayAlreadyRunningError(current)
if lease._shutdown_pending_locked(): if lease._shutdown_pending_locked():
continue continue
state = self._read_state() or {}
launch_mode: GatewayLaunchMode = ( launch_mode: GatewayLaunchMode = (
"background" "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" else "foreground"
) )
state.update( state.update(
@@ -311,6 +322,7 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
"launch_mode": launch_mode, "launch_mode": launch_mode,
} }
) )
state.pop("pending_pid_handoff", None)
state.pop("stable_identity", None) state.pop("stable_identity", None)
state.update(self.process_identity_record(pid)) state.update(self.process_identity_record(pid))
self._write_state(state) self._write_state(state)
+49
View File
@@ -120,6 +120,29 @@ def _wait_for_claim(
pytest.fail(f"gateway claim failed: returncode={process.poll()}, {detail}") 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): def test_paths_use_stable_instance_suffix_for_custom_selectors(tmp_path):
default_paths = GatewayRuntimePaths.for_instance(data_dir=tmp_path) default_paths = GatewayRuntimePaths.for_instance(data_dir=tmp_path)
first_paths = GatewayRuntimePaths.for_instance( 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"] 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): def test_windows_process_probe_never_sends_ctrl_c(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.process_runtime._windows_process_identity", "nanobot.process_runtime._windows_process_identity",