fix(cli): share on-demand gateway lifecycle

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 2d3045647a
commit 74c11e5d28
16 changed files with 594 additions and 232 deletions
+99 -56
View File
@@ -207,7 +207,7 @@ def test_webui_restores_tty_before_loading_config(monkeypatch, tmp_path: Path) -
monkeypatch.setattr(cli_webui, "_gateway_health_ready", lambda *_args, **_kwargs: False)
monkeypatch.setattr(cli_webui, "_webui_endpoint_reachable", lambda *_args, **_kwargs: False)
monkeypatch.setattr(cli_webui, "_tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
monkeypatch.setattr(cli_webui, "_run_gateway", lambda *_args, **_kwargs: None)
_patch_webui_managed_gateway(monkeypatch)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
@@ -1949,6 +1949,54 @@ def _patch_gateway_ports_free(monkeypatch) -> None:
)
def _patch_webui_managed_gateway(
monkeypatch,
seen: dict[str, object] | None = None,
) -> dict[str, object]:
"""Run WebUI CLI tests against a managed gateway without spawning a process."""
from nanobot.gateway import GatewayStatus, RuntimeResult
captured = seen if seen is not None else {}
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
captured["runtime_kwargs"] = kwargs
self.paths = kwargs["paths"]
self.running = False
def start_background(self, options):
captured["start_options"] = options
self.running = True
status = GatewayStatus(
running=True,
pid=123,
state_path=Path("gateway.json"),
log_path=Path("gateway.log"),
port=options.port,
reason="running",
)
return RuntimeResult(True, "gateway_started_background", status)
def status(self):
return SimpleNamespace(running=self.running)
def stop(self, *, timeout_s: int):
captured["stop_timeout"] = timeout_s
self.running = False
return RuntimeResult(True, "gateway_stopped", self.status())
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
"nanobot.cli.webui._attach_to_background_gateway",
lambda runtime, **_kwargs: captured.__setitem__("attached_runtime", runtime),
)
return captured
def _patch_cli_command_runtime(
monkeypatch,
config: Config,
@@ -2125,11 +2173,7 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
lambda path: seen.__setitem__("templates", path),
)
def _fake_run_gateway(config: Config, **kwargs) -> None:
seen["gateway_config"] = config
seen["gateway_kwargs"] = kwargs
monkeypatch.setattr("nanobot.cli.webui._run_gateway", _fake_run_gateway)
_patch_webui_managed_gateway(monkeypatch, seen)
result = runner.invoke(
app,
@@ -2159,18 +2203,15 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
assert len(websocket["tokenIssueSecret"]) >= 32
assert data["agents"]["defaults"]["workspace"] == str(workspace)
assert seen["templates"] == workspace
assert seen["gateway_kwargs"] == {
"port": 18888,
"open_browser_url": None,
"webui_bundle_mode": "auto",
"unconfigured_provider_error": None,
}
options = seen["start_options"]
assert options.port == 18888
assert options.config_path == str(config_file.resolve(strict=False))
assert options.workspace == str(workspace.resolve(strict=False))
compact_output = re.sub(r"\s+", " ", _strip_ansi(result.stdout))
assert "bootstrap secret was generated" in compact_output
assert "channels.websocket.tokenIssueSecret" in compact_output
assert "rerun without --no-open" in compact_output
assert "nanobot is running in this terminal" in compact_output
assert "Press Ctrl+C here to stop nanobot" in compact_output
assert "Last local client exited; the on-demand gateway was stopped" in compact_output
def test_webui_dev_rejects_background_before_creating_config(tmp_path: Path) -> None:
@@ -2208,12 +2249,20 @@ def test_webui_dev_starts_vite_sidecar_and_gateway(monkeypatch, tmp_path: Path)
finally:
seen["dev_running"] = False
def fake_run_gateway(_config: Config, **kwargs) -> None:
assert seen["dev_running"] is True
seen["gateway_kwargs"] = kwargs
monkeypatch.setattr("nanobot.cli.webui.run_webui_dev_server", fake_dev_server)
monkeypatch.setattr("nanobot.cli.webui._run_gateway", fake_run_gateway)
_patch_webui_managed_gateway(monkeypatch, seen)
monkeypatch.setattr(
"nanobot.cli.webui._attach_to_background_gateway",
lambda runtime, **kwargs: seen.update(
attached_runtime=runtime,
attach_kwargs=kwargs,
attached_while_dev_running=seen["dev_running"],
),
)
monkeypatch.setattr(
"nanobot.cli.webui._open_webui_browser",
lambda url: seen.__setitem__("opened_url", url),
)
result = runner.invoke(
app,
@@ -2237,17 +2286,10 @@ def test_webui_dev_starts_vite_sidecar_and_gateway(monkeypatch, tmp_path: Path)
browser_url = dev_kwargs["browser_url"]
assert isinstance(browser_url, str)
assert browser_url.startswith("http://127.0.0.1:5173/#/?bootstrapSecret=")
gateway_kwargs = seen["gateway_kwargs"]
assert isinstance(gateway_kwargs, dict)
assert gateway_kwargs == {
"port": 18888,
"open_browser_url": browser_url,
"open_browser_ready_url": "http://127.0.0.1:8899/webui/bootstrap",
"webui_static_dist": False,
"webui_bundle_mode": "skip",
"unconfigured_provider_error": None,
"webui_dev_server": seen["dev_server"],
}
assert seen["start_options"].port == 18888
assert seen["attached_while_dev_running"] is True
assert seen["attach_kwargs"] == {"poll_hook": seen["dev_server"].ensure_running}
assert seen["opened_url"] == browser_url
assert seen["dev_running"] is False
assert "WebUI dev: http://127.0.0.1:5173/#/?bootstrapSecret=<redacted>" in re.sub(
r"\s+", " ", _strip_ansi(result.stdout)
@@ -2352,16 +2394,13 @@ def test_webui_yes_starts_first_run_without_provider_setup(monkeypatch, tmp_path
)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.webui._run_gateway",
lambda config, **kwargs: seen.update(config=config, **kwargs),
)
_patch_webui_managed_gateway(monkeypatch, seen)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
assert result.exit_code == 0
assert config_file.exists()
assert seen["unconfigured_provider_error"] == "No API key configured for provider 'custom'."
assert seen["start_options"].config_path == str(config_file.resolve(strict=False))
assert "Configure a provider and model in WebUI Settings → Models." in result.stdout
@@ -2390,11 +2429,6 @@ def test_webui_missing_runtime_env_fails_before_starting_gateway(
}),
encoding="utf-8",
)
monkeypatch.setattr(
"nanobot.cli.webui._run_gateway",
lambda *_args, **_kwargs: pytest.fail("gateway must not start with unresolved config"),
)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
assert result.exit_code == 1
@@ -2454,6 +2488,7 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
seen["runtime_kwargs"] = kwargs
self.paths = kwargs["paths"]
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
seen["start_options"] = options
@@ -2550,6 +2585,7 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
seen["runtime_kwargs"] = kwargs
self.paths = kwargs["paths"]
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
seen["start_options"] = options
@@ -2610,18 +2646,30 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
"nanobot.cli.webui._open_webui_browser",
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
)
monkeypatch.setattr(
"nanobot.cli.webui._run_gateway",
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
)
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
seen["runtime_kwargs"] = kwargs
self.paths = kwargs["paths"]
def status(self):
return SimpleNamespace(running=True)
def start_background(self, options):
return SimpleNamespace(
ok=False,
message="gateway_already_running",
status=SimpleNamespace(log_path=self.paths.log_path),
)
def restart(self, options, *, timeout_s: int):
seen["restart_options"] = options
seen["restart_timeout"] = timeout_s
return SimpleNamespace(
ok=True,
message="gateway_started_background",
status=SimpleNamespace(log_path=self.paths.log_path),
)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.webui._attach_to_background_gateway",
@@ -2642,7 +2690,7 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
assert seen["open_kwargs"] == {"wait": False}
def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> None:
def test_attach_to_background_gateway_detaches_on_ctrl_c(monkeypatch, capsys) -> None:
stopped = False
class _FakeRuntime:
@@ -2661,11 +2709,11 @@ def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> No
cli_webui_support._attach_to_background_gateway(_FakeRuntime())
assert stopped is True
assert stopped is False
output = capsys.readouterr().out
assert "Closing the browser does not stop channels or automations" in output
assert "Press Ctrl+C here to stop nanobot" in output
assert "Gateway stopped" in output
assert "gateway stops only when the last local client exits" in output
assert "WebUI launcher detached" in output
def test_attach_to_background_gateway_checks_owned_sidecar() -> None:
@@ -2697,8 +2745,8 @@ def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path
)
class _FakeRuntime:
def __init__(self, **_kwargs) -> None:
pass
def __init__(self, **kwargs) -> None:
self.paths = kwargs["paths"]
def status(self):
return SimpleNamespace(running=False)
@@ -2719,11 +2767,6 @@ def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Pat
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: False)
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
monkeypatch.setattr("nanobot.cli.webui._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
monkeypatch.setattr(
"nanobot.cli.webui._run_gateway",
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 1
+20 -1
View File
@@ -7,7 +7,7 @@ from typer.testing import CliRunner
from nanobot.cli.gateway import create_gateway_app
from nanobot.config.schema import Config
from nanobot.gateway import GatewayStartOptions, GatewayStatus, RuntimeResult
from nanobot.gateway import GatewayRuntimePaths, GatewayStartOptions, GatewayStatus, RuntimeResult
from nanobot.gateway.service import GatewayServiceOptions, GatewayServiceResult
runner = CliRunner()
@@ -15,6 +15,7 @@ runner = CliRunner()
class FakeRuntime:
def __init__(self, tmp_path: Path):
self.paths = GatewayRuntimePaths.for_instance(data_dir=tmp_path)
self.status_value = GatewayStatus(
running=True,
pid=12345,
@@ -200,6 +201,20 @@ def test_gateway_background_starts_detached_runtime(tmp_path):
assert prepare_calls == [(config, "warn")]
def test_gateway_background_adopts_an_existing_on_demand_gateway(tmp_path):
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
def already_running(_options: GatewayStartOptions) -> RuntimeResult:
return RuntimeResult(False, "gateway_already_running", fake_runtime.status_value)
fake_runtime.start_background = already_running # type: ignore[method-assign]
result = runner.invoke(app, ["gateway", "--background"])
assert result.exit_code == 0
assert "Gateway is already running in the background" in result.stdout
def test_gateway_rejects_conflicting_modes(tmp_path):
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
@@ -230,6 +245,9 @@ def test_gateway_logs_can_read_without_following(tmp_path):
def test_gateway_stop_treats_not_running_as_clean(tmp_path):
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
lease_state = fake_runtime.paths.state_path.with_name("gateway.clients.json")
lease_state.parent.mkdir(parents=True, exist_ok=True)
lease_state.write_text('{"auto_stop": true, "clients": {}}', encoding="utf-8")
def fake_stop(*, timeout_s: int) -> RuntimeResult:
fake_runtime.stop_timeout = timeout_s
@@ -242,6 +260,7 @@ def test_gateway_stop_treats_not_running_as_clean(tmp_path):
assert result.exit_code == 0
assert "gateway_not_running" in result.stdout
assert fake_runtime.stop_timeout == 3
assert not lease_state.exists()
def test_gateway_restart_starts_background_runtime(tmp_path):
+17 -3
View File
@@ -79,12 +79,18 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6")
config.agents.defaults.model_preset = "Deep Research"
captured: dict[str, str] = {}
released: list[bool] = []
class FakeLease:
def release(self) -> None:
released.append(True)
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
monkeypatch.setattr(
"nanobot.cli.tui_launcher._ensure_gateway",
lambda *args, **kwargs: SimpleNamespace(
base_url="http://127.0.0.1:8765",
lease=FakeLease(),
),
)
monkeypatch.setattr(
@@ -116,6 +122,7 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6"
assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research"
assert "NANOBOT_TUI_CHAT_ID" not in captured
assert released == [True]
def test_explicit_tui_binary_must_exist(
@@ -451,12 +458,13 @@ def test_gateway_reuses_the_matching_managed_instance(
assert gateway.base_url == "http://127.0.0.1:8765"
def test_gateway_started_for_tui_remains_shared_after_launch(
def test_gateway_started_for_tui_stops_when_its_last_lease_exits(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
started = False
stopped = False
class FakeRuntime:
def __init__(self, *, paths: object) -> None:
@@ -477,8 +485,11 @@ def test_gateway_started_for_tui_remains_shared_after_launch(
status=SimpleNamespace(log_path=tmp_path / "gateway.log"),
)
def stop(self, *, timeout_s: int) -> None:
raise AssertionError(f"shared gateway stopped with timeout {timeout_s}")
def stop(self, *, timeout_s: int) -> SimpleNamespace:
nonlocal stopped
assert timeout_s == 20
stopped = True
return SimpleNamespace(ok=True, message="gateway_stopped")
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
monkeypatch.setattr(
@@ -494,3 +505,6 @@ def test_gateway_started_for_tui_remains_shared_after_launch(
assert gateway.base_url == "http://127.0.0.1:8765"
assert started is True
assert gateway.lease is not None
assert gateway.lease.release() is True
assert stopped is True
+67 -1
View File
@@ -1,13 +1,21 @@
import json
import os
import signal
import subprocess
import sys
import threading
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions, GatewayStatus
from nanobot.gateway import (
GatewayClientLease,
GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
GatewayStatus,
)
class FakeProcess:
@@ -179,6 +187,64 @@ def test_concurrent_background_starts_create_only_one_process(tmp_path, monkeypa
]
def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
stopped: list[int] = []
def stop(*, timeout_s: int):
stopped.append(timeout_s)
return SimpleNamespace(ok=True, message="gateway_stopped")
monkeypatch.setattr(runtime, "stop", stop)
tui = GatewayClientLease(runtime, kind="tui", pid=os.getpid(), token="tui")
webui = GatewayClientLease(runtime, kind="webui", pid=os.getpid(), token="webui")
tui.acquire()
tui.mark_ephemeral()
webui.acquire()
assert tui.release() is False
assert stopped == []
assert webui.release() is True
assert stopped == [20]
assert not webui.state_path.exists()
def test_explicit_background_gateway_survives_the_last_client(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
stopped: list[int] = []
monkeypatch.setattr(
runtime,
"stop",
lambda *, timeout_s: stopped.append(timeout_s),
)
client = GatewayClientLease(runtime, kind="webui", pid=os.getpid())
client.acquire()
client.mark_ephemeral()
GatewayClientLease(runtime, kind="gateway-background").mark_persistent()
assert client.release() is False
assert stopped == []
assert not client.state_path.exists()
def test_failed_last_client_shutdown_remains_retryable(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(
runtime,
"stop",
lambda *, timeout_s: SimpleNamespace(ok=False, message="gateway_stop_timeout"),
)
client = GatewayClientLease(runtime, kind="tui", pid=os.getpid())
client.acquire()
client.mark_ephemeral()
assert client.release() is False
state = json.loads(client.state_path.read_text(encoding="utf-8"))
assert state == {"auto_stop": True, "clients": {}}
def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch):
calls: list[dict] = []