fix(gateway): register foreground instances

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent a700697583
commit 014eab5f6a
4 changed files with 93 additions and 7 deletions
+19 -7
View File
@@ -387,19 +387,27 @@ def _run_gateway(
raise typer.Exit(1) from exc raise typer.Exit(1) from exc
session_manager = SessionManager(config.workspace_path) session_manager = SessionManager(config.workspace_path)
# Self-heal the gateway state file with the current PID after any restart. # Use the same runtime identity for foreground and managed gateway processes.
from nanobot.config.loader import get_config_path from nanobot.config.loader import get_config_path
from nanobot.gateway.runtime import GatewayRuntime, GatewayRuntimePaths from nanobot.gateway.runtime import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
config_path = str(get_config_path().resolve(strict=False)) config_path = str(get_config_path().resolve(strict=False))
GatewayRuntime.refresh_state_pid( gateway_workspace = (
str(config.workspace_path)
if not is_default_workspace(config.workspace_path)
else None
)
gateway_runtime = GatewayRuntime(
paths=GatewayRuntimePaths.for_instance( paths=GatewayRuntimePaths.for_instance(
workspace=str(config.workspace_path) workspace=gateway_workspace,
if not is_default_workspace(config.workspace_path)
else None,
config_path=config_path, config_path=config_path,
) )
) )
gateway_start_options = GatewayStartOptions(
port=port,
workspace=gateway_workspace,
config_path=config_path,
)
# Preserve existing single-workspace installs, but keep custom workspaces clean. # Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(config.workspace_path): if is_default_workspace(config.workspace_path):
@@ -946,4 +954,8 @@ def _run_gateway(
finally: finally:
restore_shutdown_handlers() restore_shutdown_handlers()
asyncio.run(run()) gateway_runtime.claim_current_process(gateway_start_options)
try:
asyncio.run(run())
finally:
gateway_runtime.release_current_process()
+28
View File
@@ -11,6 +11,7 @@ import time
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
@@ -102,6 +103,33 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
def _build_child_command(self, options: ProcessStartOptions) -> list[str]: def _build_child_command(self, options: ProcessStartOptions) -> list[str]:
return build_gateway_command(self.python_executable, options) return build_gateway_command(self.python_executable, options)
def claim_current_process(self, options: ProcessStartOptions) -> None:
"""Publish the running foreground gateway for local client discovery."""
with self._lifecycle_lock():
state = self._read_state() or {}
pid = os.getpid()
state.update(
{
"pid": pid,
"identity": self._process_identity(pid),
"started_at": datetime.now(UTC).isoformat(),
"platform": self.platform_name,
"port": options.port,
"workspace": options.workspace,
"config_path": options.config_path,
"command": self._build_child_command(options),
"log_path": str(self.paths.log_path),
}
)
self._write_state(state)
def release_current_process(self) -> None:
"""Remove this foreground gateway's state without touching a replacement."""
with self._lifecycle_lock():
state = self._read_state()
if state and self._record_matches_process(state, os.getpid()):
self._clear_state()
def restart(self, options: ProcessStartOptions, *, timeout_s: int = 20) -> ProcessResult: def restart(self, options: ProcessStartOptions, *, timeout_s: int = 20) -> ProcessResult:
"""Restart an existing gateway without creating a new persistent instance.""" """Restart an existing gateway without creating a new persistent instance."""
with self._lifecycle_lock(): with self._lifecycle_lock():
+4
View File
@@ -2658,6 +2658,10 @@ def test_webui_foreground_reports_an_existing_gateway_without_leaking_secret(
_patch_webui_provider_ready(monkeypatch) _patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: True) monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: True)
monkeypatch.setattr(
"nanobot.cli.webui_support._gateway_health_ready",
lambda *_args, **_kwargs: True,
)
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: False) monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: False)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"]) result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
+42
View File
@@ -104,6 +104,48 @@ def test_start_background_writes_state_and_child_command(tmp_path, monkeypatch):
assert state["port"] == 18790 assert state["port"] == 18790
def test_foreground_gateway_claim_is_discoverable_and_released(tmp_path, monkeypatch):
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Darwin",
python_executable="/python",
)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 54321)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
options = GatewayStartOptions(
port=18790,
workspace="/tmp/workspace",
config_path="/tmp/config.json",
)
runtime.claim_current_process(options)
status = runtime.status()
assert status.running is True
assert status.pid == os.getpid()
assert status.port == 18790
assert status.command == tuple(runtime._build_child_command(options))
runtime.release_current_process()
assert runtime.status().running is False
assert not runtime.paths.state_path.exists()
def test_foreground_gateway_release_preserves_a_replacement_state(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
runtime.claim_current_process(GatewayStartOptions(port=18790))
replacement = json.loads(runtime.paths.state_path.read_text(encoding="utf-8"))
replacement["pid"] = os.getpid() + 1
replacement["identity"] = replacement["pid"]
runtime.paths.state_path.write_text(json.dumps(replacement), encoding="utf-8")
runtime.release_current_process()
assert runtime.paths.state_path.exists()
def test_stop_reaps_an_owned_child_without_consuming_the_shutdown_timeout( def test_stop_reaps_an_owned_child_without_consuming_the_shutdown_timeout(
tmp_path, tmp_path,
monkeypatch, monkeypatch,