mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
fix(gateway): serialize shared runtime lifecycle
This commit is contained in:
@@ -1991,6 +1991,8 @@ def _patch_webui_managed_gateway(
|
||||
self.running = False
|
||||
return RuntimeResult(True, "gateway_stopped", self.status())
|
||||
|
||||
_stop = stop
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
|
||||
|
||||
@@ -57,6 +57,7 @@ class FakeRuntime:
|
||||
|
||||
def stop(self, *, timeout_s: int) -> RuntimeResult:
|
||||
self.stop_timeout = timeout_s
|
||||
self.paths.state_path.with_name("gateway.clients.json").unlink(missing_ok=True)
|
||||
return RuntimeResult(True, "gateway_stopped", self.status_value)
|
||||
|
||||
def status(self) -> GatewayStatus:
|
||||
@@ -223,7 +224,13 @@ def test_gateway_background_adopts_an_existing_on_demand_gateway(tmp_path):
|
||||
lease_state.write_text('{"auto_stop": true, "clients": {}}', encoding="utf-8")
|
||||
|
||||
def already_running(_options: GatewayStartOptions) -> RuntimeResult:
|
||||
return RuntimeResult(False, "gateway_already_running", fake_runtime.status_value)
|
||||
lease_state.unlink(missing_ok=True)
|
||||
return RuntimeResult(
|
||||
False,
|
||||
"gateway_already_running",
|
||||
fake_runtime.status_value,
|
||||
promoted=True,
|
||||
)
|
||||
|
||||
fake_runtime.start_background = already_running # type: ignore[method-assign]
|
||||
|
||||
@@ -311,6 +318,7 @@ def test_gateway_stop_treats_not_running_as_clean(tmp_path):
|
||||
|
||||
def fake_stop(*, timeout_s: int) -> RuntimeResult:
|
||||
fake_runtime.stop_timeout = timeout_s
|
||||
lease_state.unlink(missing_ok=True)
|
||||
return RuntimeResult(False, "gateway_not_running", fake_runtime.status_value)
|
||||
|
||||
fake_runtime.stop = fake_stop # type: ignore[method-assign]
|
||||
|
||||
@@ -537,6 +537,8 @@ def test_gateway_started_for_tui_stops_when_its_last_lease_exits(
|
||||
stopped = True
|
||||
return SimpleNamespace(ok=True, message="gateway_stopped")
|
||||
|
||||
_stop = stop
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.tui_launcher._webui_endpoint_reachable",
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -284,7 +285,7 @@ def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatc
|
||||
stopped.append(timeout_s)
|
||||
return SimpleNamespace(ok=True, message="gateway_stopped")
|
||||
|
||||
monkeypatch.setattr(runtime, "stop", stop)
|
||||
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")
|
||||
|
||||
@@ -299,6 +300,95 @@ def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatc
|
||||
assert not webui.state_path.exists()
|
||||
|
||||
|
||||
def test_last_client_shutdown_preserves_a_replacement_lease(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
stop_started = threading.Event()
|
||||
finish_stop = threading.Event()
|
||||
replacement_acquired = threading.Event()
|
||||
|
||||
def stop(*, timeout_s: int):
|
||||
assert timeout_s == 20
|
||||
stop_started.set()
|
||||
assert finish_stop.wait(timeout=2)
|
||||
return SimpleNamespace(
|
||||
ok=True,
|
||||
message="gateway_stopped",
|
||||
status=runtime.status(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(runtime, "_stop", stop)
|
||||
original = GatewayClientLease(runtime, kind="tui", token="original")
|
||||
replacement = GatewayClientLease(runtime, kind="webui", token="replacement")
|
||||
original.acquire()
|
||||
original.mark_ephemeral()
|
||||
|
||||
release_thread = threading.Thread(target=original.release)
|
||||
release_thread.start()
|
||||
assert stop_started.wait(timeout=2)
|
||||
|
||||
acquire_thread = threading.Thread(
|
||||
target=lambda: (replacement.acquire(), replacement_acquired.set())
|
||||
)
|
||||
acquire_thread.start()
|
||||
assert not replacement_acquired.wait(timeout=0.05)
|
||||
|
||||
finish_stop.set()
|
||||
release_thread.join(timeout=2)
|
||||
acquire_thread.join(timeout=2)
|
||||
|
||||
assert replacement_acquired.is_set()
|
||||
state = json.loads(replacement.state_path.read_text(encoding="utf-8"))
|
||||
assert set(state["clients"]) == {"replacement"}
|
||||
|
||||
|
||||
def test_explicit_stop_clears_leases_before_accepting_a_replacement(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
stop_started = threading.Event()
|
||||
finish_stop = threading.Event()
|
||||
replacement_acquired = threading.Event()
|
||||
|
||||
stale = GatewayClientLease(runtime, kind="tui", token="stale")
|
||||
replacement = GatewayClientLease(runtime, kind="webui", token="replacement")
|
||||
stale.acquire()
|
||||
stale.mark_ephemeral()
|
||||
|
||||
def stop(*, timeout_s: int):
|
||||
assert timeout_s == 20
|
||||
stop_started.set()
|
||||
assert finish_stop.wait(timeout=2)
|
||||
return SimpleNamespace(
|
||||
ok=True,
|
||||
message="gateway_stopped",
|
||||
status=runtime.status(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(runtime, "_stop", stop)
|
||||
stop_thread = threading.Thread(target=runtime.stop)
|
||||
stop_thread.start()
|
||||
assert stop_started.wait(timeout=2)
|
||||
|
||||
acquire_thread = threading.Thread(
|
||||
target=lambda: (replacement.acquire(), replacement_acquired.set())
|
||||
)
|
||||
acquire_thread.start()
|
||||
assert not replacement_acquired.wait(timeout=0.05)
|
||||
|
||||
finish_stop.set()
|
||||
stop_thread.join(timeout=2)
|
||||
acquire_thread.join(timeout=2)
|
||||
|
||||
assert replacement_acquired.is_set()
|
||||
state = json.loads(replacement.state_path.read_text(encoding="utf-8"))
|
||||
assert set(state["clients"]) == {"replacement"}
|
||||
|
||||
|
||||
def test_on_demand_lifetime_is_recorded_before_the_gateway_spawns(tmp_path, monkeypatch):
|
||||
observed_auto_stop: list[bool] = []
|
||||
|
||||
@@ -331,18 +421,28 @@ def test_explicit_background_gateway_survives_the_last_client(tmp_path, monkeypa
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
runtime._write_state(
|
||||
{
|
||||
"pid": os.getpid(),
|
||||
"identity": os.getpid(),
|
||||
"launch_mode": "background",
|
||||
}
|
||||
)
|
||||
stopped: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
runtime,
|
||||
"stop",
|
||||
"_stop",
|
||||
lambda *, timeout_s: stopped.append(timeout_s),
|
||||
)
|
||||
client = GatewayClientLease(runtime, kind="webui", pid=os.getpid())
|
||||
|
||||
client.acquire()
|
||||
client.mark_ephemeral()
|
||||
assert GatewayClientLease(runtime, kind="gateway-background").mark_persistent() is True
|
||||
result = runtime.start_background(GatewayStartOptions(port=18790))
|
||||
|
||||
assert result.ok is False
|
||||
assert result.message == "gateway_already_running"
|
||||
assert result.promoted is True
|
||||
assert client.release() is False
|
||||
assert stopped == []
|
||||
assert not client.state_path.exists()
|
||||
@@ -354,7 +454,7 @@ def test_failed_last_client_shutdown_remains_retryable(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
monkeypatch.setattr(
|
||||
runtime,
|
||||
"stop",
|
||||
"_stop",
|
||||
lambda *, timeout_s: SimpleNamespace(ok=False, message="gateway_stop_timeout"),
|
||||
)
|
||||
client = GatewayClientLease(runtime, kind="tui", pid=os.getpid())
|
||||
@@ -400,6 +500,39 @@ async def test_client_monitor_stops_an_orphaned_on_demand_gateway(tmp_path):
|
||||
|
||||
assert orphaned is True
|
||||
assert shutdown_event.is_set()
|
||||
assert json.loads(lease.state_path.read_text(encoding="utf-8"))["stopping"] is True
|
||||
|
||||
|
||||
async def test_client_monitor_blocks_replacement_until_gateway_exit(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
runtime._write_state({"pid": os.getpid(), "identity": os.getpid()})
|
||||
monitor = GatewayClientLease(runtime, kind="gateway-monitor")
|
||||
monitor.mark_ephemeral()
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
assert await monitor_gateway_clients(
|
||||
monitor,
|
||||
shutdown_event,
|
||||
poll_interval_s=0.001,
|
||||
) is True
|
||||
|
||||
replacement = GatewayClientLease(runtime, kind="webui", token="replacement")
|
||||
replacement_acquired = threading.Event()
|
||||
acquire_thread = threading.Thread(
|
||||
target=lambda: (replacement.acquire(), replacement_acquired.set())
|
||||
)
|
||||
acquire_thread.start()
|
||||
assert not replacement_acquired.wait(timeout=0.05)
|
||||
|
||||
runtime._release_current_process()
|
||||
acquire_thread.join(timeout=2)
|
||||
|
||||
assert replacement_acquired.is_set()
|
||||
state = json.loads(replacement.state_path.read_text(encoding="utf-8"))
|
||||
assert set(state["clients"]) == {"replacement"}
|
||||
assert "stopping" not in state
|
||||
|
||||
|
||||
def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user