Files
nanobot/tests/gateway/test_runtime.py
T

1229 lines
42 KiB
Python
Raw Normal View History

import asyncio
import json
2026-08-16 22:45:09 +08:00
import os
2026-07-03 17:05:57 +08:00
import signal
import subprocess
2026-07-13 13:11:46 +08:00
import sys
import threading
2026-08-17 15:02:51 +08:00
import time
from pathlib import Path
2026-08-16 22:45:09 +08:00
from types import SimpleNamespace
2026-07-13 13:11:46 +08:00
import pytest
2026-08-16 22:45:09 +08:00
from nanobot.gateway import (
GatewayClientLease,
GatewayInstance,
2026-08-16 22:45:09 +08:00
GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
GatewayStatus,
)
from nanobot.gateway.runtime import monitor_gateway_clients
2026-08-17 01:19:39 +08:00
from nanobot.process_runtime import process_is_running
class FakeProcess:
def __init__(self, pid: int = 12345):
self.pid = pid
2026-08-16 18:26:03 +08:00
class PollableProcess(FakeProcess):
def __init__(self, pid: int = 12345):
super().__init__(pid)
self.returncode: int | None = None
def poll(self):
return self.returncode
def _paths(tmp_path: Path) -> GatewayRuntimePaths:
return GatewayRuntimePaths.for_instance(data_dir=tmp_path)
2026-08-17 15:02:51 +08:00
_FOREGROUND_CHILD = r"""
2026-08-17 15:18:34 +08:00
import os
2026-08-17 15:02:51 +08:00
import signal
import sys
import time
from pathlib import Path
from nanobot.gateway import (
GatewayAlreadyRunningError,
GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
)
root = Path(sys.argv[1])
duration = float(sys.argv[2])
2026-08-17 15:18:34 +08:00
marker = Path(sys.argv[3])
2026-08-17 15:02:51 +08:00
runtime = GatewayRuntime(paths=GatewayRuntimePaths.for_instance(data_dir=root))
def stop(*_args):
raise SystemExit(0)
try:
2026-08-17 15:18:34 +08:00
if os.name != "nt":
signal.signal(signal.SIGTERM, stop)
2026-08-17 15:02:51 +08:00
with runtime.foreground_instance(GatewayStartOptions(port=18790)):
marker.write_text(f"claimed:{os.getpid()}", encoding="utf-8")
2026-08-17 15:02:51 +08:00
time.sleep(duration)
except GatewayAlreadyRunningError:
2026-08-17 15:18:34 +08:00
marker.write_text("occupied", encoding="utf-8")
2026-08-17 15:02:51 +08:00
raise SystemExit(17)
2026-08-17 15:18:34 +08:00
except BaseException as exc:
marker.write_text(f"error:{type(exc).__name__}:{exc}", encoding="utf-8")
raise
2026-08-17 15:02:51 +08:00
"""
2026-08-17 15:18:34 +08:00
def _foreground_child(
runtime_dir: Path,
duration_s: float,
marker: Path,
) -> subprocess.Popen[str]:
2026-08-17 15:02:51 +08:00
env = os.environ.copy()
root = str(Path(__file__).resolve().parents[2])
env["PYTHONPATH"] = os.pathsep.join(filter(None, (root, env.get("PYTHONPATH"))))
return subprocess.Popen(
2026-08-17 15:18:34 +08:00
[
sys.executable,
"-c",
_FOREGROUND_CHILD,
str(runtime_dir),
str(duration_s),
str(marker),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
2026-08-17 15:02:51 +08:00
env=env,
start_new_session=os.name != "nt",
)
2026-08-17 15:18:34 +08:00
def _wait_for_claim(
process: subprocess.Popen[str],
marker: Path,
) -> int:
2026-08-17 15:02:51 +08:00
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
if marker.exists():
detail = marker.read_text(encoding="utf-8")
if detail.startswith("claimed:"):
return int(detail.partition(":")[2])
pytest.fail(f"gateway claim failed: returncode={process.poll()}, {detail}")
if process.poll() is not None:
break
2026-08-17 15:02:51 +08:00
time.sleep(0.01)
2026-08-17 15:18:34 +08:00
detail = marker.read_text(encoding="utf-8") if marker.exists() else "no marker"
pytest.fail(f"gateway claim failed: returncode={process.poll()}, {detail}")
2026-08-17 15:02:51 +08:00
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(
data_dir=tmp_path,
workspace="/tmp/workspace-a",
config_path="/tmp/config-a.json",
)
second_paths = GatewayRuntimePaths.for_instance(
data_dir=tmp_path,
workspace="/tmp/workspace-b",
config_path="/tmp/config-b.json",
)
assert default_paths.state_path.name == "gateway.json"
assert first_paths.state_path.name.startswith("gateway.")
assert first_paths.state_path != second_paths.state_path
assert first_paths.log_path != second_paths.log_path
def test_default_instance_preserves_released_gateway_paths() -> None:
config_path = Path.home() / ".nanobot" / "config.json"
instance = GatewayInstance.resolve(config_path=config_path)
assert instance.paths.state_path == config_path.parent / "run" / "gateway.json"
assert instance.paths.log_path == config_path.parent / "logs" / "gateway.log"
assert instance.start_options(port=18790) == GatewayStartOptions(port=18790)
def test_custom_instance_round_trips_the_same_child_selectors(tmp_path: Path) -> None:
config_path = tmp_path / "instance" / "config.json"
workspace = tmp_path / "workspace"
parent = GatewayInstance.resolve(
config_path=config_path,
workspace=str(workspace),
)
options = parent.start_options(port=18790)
child = GatewayInstance.resolve(
config_path=options.config_path or "",
workspace=options.workspace,
)
assert child == parent
assert parent.paths.state_path.name.startswith("gateway.")
def test_start_background_writes_state_and_child_command(tmp_path, monkeypatch):
calls: list[dict] = []
def fake_popen(command, **kwargs):
calls.append({"command": command, "kwargs": kwargs})
return FakeProcess()
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
python_executable="/python",
popen=fake_popen,
sleep=lambda _seconds: None,
)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
result = runtime.start_background(
GatewayStartOptions(
port=18790,
verbose=True,
workspace="/tmp/workspace",
config_path="/tmp/config.json",
)
)
assert result.ok is True
assert result.status.running is True
assert calls[0]["command"] == [
"/python",
"-m",
"nanobot",
"gateway",
"--foreground",
"--port",
"18790",
"--verbose",
"--workspace",
"/tmp/workspace",
"--config",
"/tmp/config.json",
]
assert calls[0]["kwargs"]["start_new_session"] is True
state = json.loads(runtime.paths.state_path.read_text(encoding="utf-8"))
assert state["pid"] == 12345
assert state["identity"] == 12345
assert state["port"] == 18790
assert state["launch_mode"] == "background"
assert result.status.launch_mode == "background"
2026-08-16 23:08:44 +08:00
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",
)
with runtime.foreground_instance(options):
status = runtime.status()
assert status.running is True
assert status.pid == os.getpid()
assert status.port == 18790
assert status.launch_mode == "foreground"
assert status.lifetime == "explicit"
assert status.command == tuple(runtime._build_child_command(options))
2026-08-16 23:08:44 +08:00
assert runtime.status().running is False
assert not runtime.paths.state_path.exists()
def test_explicit_foreground_gateway_clears_stale_auto_stop_state(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
lease = GatewayClientLease(runtime, kind="stale")
lease.mark_ephemeral()
with runtime.foreground_instance(GatewayStartOptions(port=18790)):
assert runtime.status().lifetime == "explicit"
assert not lease.state_path.exists()
2026-08-16 23:08:44 +08:00
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)
with runtime.foreground_instance(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")
2026-08-16 23:08:44 +08:00
assert runtime.paths.state_path.exists()
def test_foreground_gateway_clears_its_state_after_an_error(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
with pytest.raises(RuntimeError, match="startup failed"):
with runtime.foreground_instance(GatewayStartOptions(port=18790)):
raise RuntimeError("startup failed")
assert not runtime.paths.state_path.exists()
2026-08-17 15:02:51 +08:00
@pytest.mark.skipif(os.name == "nt", reason="POSIX signal escalation regression")
def test_stop_allows_a_foreground_gateway_to_release_before_timeout(tmp_path):
runtime = GatewayRuntime(paths=_paths(tmp_path))
2026-08-17 15:18:34 +08:00
marker = tmp_path / "foreground.marker"
child = _foreground_child(tmp_path, 30, marker)
2026-08-17 15:02:51 +08:00
try:
_wait_for_claim(child, marker)
2026-08-17 15:02:51 +08:00
result = runtime.stop(timeout_s=1)
child.wait(timeout=3)
assert result.ok is True
assert child.returncode == 0
assert not runtime.paths.state_path.exists()
finally:
if child.poll() is None:
child.kill()
child.wait(timeout=3)
def test_competing_foreground_claim_preserves_the_live_gateway(tmp_path):
runtime = GatewayRuntime(paths=_paths(tmp_path))
2026-08-17 15:18:34 +08:00
first_marker = tmp_path / "first.marker"
first = _foreground_child(tmp_path, 30, first_marker)
2026-08-17 15:02:51 +08:00
try:
first_pid = _wait_for_claim(first, first_marker)
2026-08-17 15:18:34 +08:00
second_marker = tmp_path / "second.marker"
second = _foreground_child(tmp_path, 0, second_marker)
2026-08-17 15:02:51 +08:00
try:
second.wait(timeout=3)
2026-08-17 15:18:34 +08:00
assert second_marker.read_text(encoding="utf-8") == "occupied"
2026-08-17 15:02:51 +08:00
assert second.returncode == 17
finally:
if second.poll() is None:
second.kill()
second.wait(timeout=3)
state = json.loads(runtime.paths.state_path.read_text(encoding="utf-8"))
assert state["pid"] == first_pid
assert runtime.status().pid == first_pid
2026-08-17 15:02:51 +08:00
finally:
if first.poll() is None:
first.terminate()
first.wait(timeout=3)
runtime.status()
2026-08-16 18:26:03 +08:00
def test_stop_reaps_an_owned_child_without_consuming_the_shutdown_timeout(
tmp_path,
monkeypatch,
):
process = PollableProcess()
sleeps: list[float] = []
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Darwin",
popen=lambda *_args, **_kwargs: process,
sleep=sleeps.append,
)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
monkeypatch.setattr(
"nanobot.process_runtime.os.getpgid",
lambda _pid: process.pid,
raising=False,
)
monkeypatch.setattr(
"nanobot.process_runtime.os.killpg",
lambda _pgid, _signal: setattr(process, "returncode", -15),
raising=False,
)
assert runtime.start_background(GatewayStartOptions(port=18790)).ok is True
sleeps.clear()
result = runtime.stop(timeout_s=20)
assert result.ok is True
assert sleeps == []
def test_repeated_background_starts_create_only_one_process(tmp_path, monkeypatch):
2026-07-13 13:11:46 +08:00
calls: list[list[str]] = []
def fake_popen(command, **_kwargs):
calls.append(command)
return FakeProcess()
first = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
popen=fake_popen,
sleep=lambda _seconds: None,
2026-07-13 13:11:46 +08:00
)
second = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
popen=fake_popen,
sleep=lambda _seconds: None,
)
for runtime in (first, second):
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
results = [
first.start_background(GatewayStartOptions(port=18790)),
second.start_background(GatewayStartOptions(port=18790)),
]
2026-07-13 13:11:46 +08:00
assert len(calls) == 1
assert sorted((result.ok, result.message) for result in results) == [
(False, "gateway_already_running"),
(True, "gateway_started_background"),
]
2026-08-16 22:52:35 +08:00
def test_restart_does_not_start_a_gateway_that_is_not_running(tmp_path):
spawned: list[list[str]] = []
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
popen=lambda command, **_kwargs: spawned.append(command),
)
result = runtime.restart(GatewayStartOptions(port=18790))
assert result.ok is False
assert result.message == "gateway_not_running"
assert spawned == []
def test_restart_does_not_detach_a_foreground_gateway(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
status = GatewayStatus(
running=True,
pid=12345,
state_path=runtime.paths.state_path,
log_path=runtime.paths.log_path,
launch_mode="foreground",
)
monkeypatch.setattr(runtime, "status", lambda **_kwargs: status)
monkeypatch.setattr(
runtime,
"_stop",
lambda **_kwargs: pytest.fail("foreground gateway must not be stopped"),
)
result = runtime.restart(GatewayStartOptions(port=18790))
assert result.ok is False
assert result.message == "gateway_foreground_restart_required"
2026-08-16 22:45:09 +08:00
def test_last_interactive_client_stops_an_on_demand_gateway(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)
2026-08-16 22:45:09 +08:00
stopped: list[int] = []
def stop(*, timeout_s: int):
stopped.append(timeout_s)
return SimpleNamespace(ok=True, message="gateway_stopped")
monkeypatch.setattr(runtime, "_stop", stop)
2026-08-16 22:45:09 +08:00
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()
2026-08-18 16:03:15 +08:00
def test_last_client_can_leave_shutdown_to_the_gateway_monitor(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)
monkeypatch.setattr(
runtime,
"_stop",
lambda **_kwargs: pytest.fail("deferred release must not stop synchronously"),
)
client = GatewayClientLease(runtime, kind="tui", pid=os.getpid(), token="tui")
client.acquire()
client.mark_ephemeral()
assert client.release(wait_for_stop=False) is False
state = json.loads(client.state_path.read_text(encoding="utf-8"))
assert state == {"auto_stop": True, "clients": {}}
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] = []
def fake_popen(*_args, **_kwargs):
lease_path = runtime.paths.state_path.with_name("gateway.clients.json")
observed_auto_stop.append(
json.loads(lease_path.read_text(encoding="utf-8"))["auto_stop"]
)
return FakeProcess()
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
popen=fake_popen,
sleep=lambda _seconds: None,
)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
client = GatewayClientLease(runtime, kind="tui", token="client")
client.acquire()
result = client.ensure_on_demand_gateway(GatewayStartOptions(port=18790))
assert result.ok is True
assert observed_auto_stop == [True]
assert result.status.lifetime == "on_demand"
2026-08-16 22:45:09 +08:00
def test_explicit_background_gateway_survives_the_last_client(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(),
"launch_mode": "background",
}
)
2026-08-16 22:45:09 +08:00
stopped: list[int] = []
monkeypatch.setattr(
runtime,
"_stop",
2026-08-16 22:45:09 +08:00
lambda *, timeout_s: stopped.append(timeout_s),
)
client = GatewayClientLease(runtime, kind="webui", pid=os.getpid())
client.acquire()
client.mark_ephemeral()
result = runtime.start_background(GatewayStartOptions(port=18790))
2026-08-16 22:45:09 +08:00
assert result.ok is False
assert result.message == "gateway_already_running"
assert result.promoted is True
2026-08-16 22:45:09 +08:00
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, "_process_identity", lambda pid: pid)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
2026-08-16 22:45:09 +08:00
monkeypatch.setattr(
runtime,
"_stop",
2026-08-16 22:45:09 +08:00
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_lease_snapshot_prunes_a_reused_client_pid(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
identity = "same-pid:first-process"
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: identity)
client = GatewayClientLease(runtime, kind="tui", pid=12345, token="client")
client.acquire()
client.mark_ephemeral()
identity = "same-pid:replacement-process"
snapshot = client.snapshot()
assert snapshot.auto_stop is True
assert snapshot.clients == 0
assert json.loads(client.state_path.read_text(encoding="utf-8")) == {
"auto_stop": True,
"clients": {},
}
2026-08-18 02:52:58 +08:00
def test_lease_snapshot_keeps_a_legacy_localized_darwin_client(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
started_at = int(time.mktime((2026, 8, 18, 2, 17, 54, -1, -1, -1)))
identity = "42:二 8/18 02:17:54 2026"
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: identity)
client = GatewayClientLease(runtime, kind="webui", pid=12345, token="client")
client.acquire()
client.mark_ephemeral()
identity = f"darwin:42:{started_at}:123456"
snapshot = client.snapshot()
assert snapshot.auto_stop is True
assert snapshot.clients == 1
def test_darwin_lease_keeps_old_readers_compatible_and_detects_pid_reuse(
tmp_path,
monkeypatch,
):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
identity = "darwin:42:1786992348:123456"
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: identity)
client = GatewayClientLease(runtime, kind="tui", pid=12345, token="client")
client.acquire()
client.mark_ephemeral()
state = json.loads(client.state_path.read_text(encoding="utf-8"))
record = state["clients"]["client"]
assert record["identity"] is None # Pre-upgrade lease readers keep the live PID.
assert record["stable_identity"] == identity
identity = "darwin:42:1786992348:654321"
assert client.snapshot().clients == 0
def test_lease_snapshot_keeps_a_client_when_identity_probe_is_unavailable(
tmp_path,
monkeypatch,
):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
identity: str | None = "created-at"
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: identity)
client = GatewayClientLease(runtime, kind="tui", pid=12345, token="client")
client.acquire()
client.mark_ephemeral()
identity = None
snapshot = client.snapshot()
assert snapshot.auto_stop is True
assert snapshot.clients == 1
async def test_client_monitor_stops_an_orphaned_on_demand_gateway(tmp_path):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
lease = GatewayClientLease(runtime, kind="gateway-monitor")
lease.mark_ephemeral()
shutdown_event = asyncio.Event()
orphaned = await monitor_gateway_clients(
lease,
shutdown_event,
poll_interval_s=0.001,
)
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):
calls: list[dict] = []
def fake_popen(command, **kwargs):
calls.append({"command": command, "kwargs": kwargs})
return FakeProcess()
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Windows",
python_executable="python.exe",
popen=fake_popen,
sleep=lambda _seconds: None,
)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: "created-at")
result = runtime.start_background(GatewayStartOptions(port=18790))
assert result.ok is True
assert "creationflags" 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)
2026-08-17 01:19:39 +08:00
def test_windows_process_probe_never_sends_ctrl_c(monkeypatch):
monkeypatch.setattr(
"nanobot.process_runtime._windows_process_identity",
lambda pid: "created-at" if pid == 12345 else None,
)
monkeypatch.setattr(
"nanobot.process_runtime.os.kill",
lambda *_args: pytest.fail("Windows process probes must not call os.kill(pid, 0)"),
)
assert process_is_running(12345, platform_name="Windows") is True
assert process_is_running(54321, platform_name="Windows") is False
2026-08-17 03:53:38 +08:00
def test_windows_host_probe_stays_safe_when_target_platform_is_posix(monkeypatch):
monkeypatch.setattr("nanobot.process_runtime._platform_name", lambda: "Windows")
monkeypatch.setattr(
"nanobot.process_runtime._windows_process_identity",
lambda pid: "created-at" if pid == 12345 else None,
)
monkeypatch.setattr(
"nanobot.process_runtime.os.kill",
lambda *_args: pytest.fail("Windows process probes must not call os.kill(pid, 0)"),
)
assert process_is_running(12345, platform_name="Linux") is True
assert process_is_running(54321, platform_name="Darwin") is False
def test_posix_process_probe_treats_a_zombie_as_stopped(monkeypatch):
monkeypatch.setattr("nanobot.process_runtime._platform_name", lambda: "Darwin")
monkeypatch.setattr("nanobot.process_runtime.os.kill", lambda *_args: None)
monkeypatch.setattr(
"nanobot.process_runtime.subprocess.run",
lambda *_args, **_kwargs: SimpleNamespace(stdout="Z+"),
)
assert process_is_running(12345, platform_name="Darwin") is False
2026-08-17 03:53:38 +08:00
def test_windows_host_identity_stays_safe_when_target_platform_is_posix(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr("nanobot.process_runtime._platform_name", lambda: "Windows")
monkeypatch.setattr(
"nanobot.process_runtime._windows_process_identity",
lambda pid: "created-at" if pid == 12345 else None,
)
monkeypatch.setattr(
"nanobot.process_runtime.os.getpgid",
lambda *_args: pytest.fail("Windows process identities must not use POSIX APIs"),
raising=False,
)
assert runtime.process_identity(12345) == "created-at"
2026-08-18 02:52:58 +08:00
def test_windows_lease_prunes_a_reused_pid_by_creation_time(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Windows")
identity = "filetime:first-process"
monkeypatch.setattr("nanobot.process_runtime._platform_name", lambda: "Windows")
monkeypatch.setattr(
"nanobot.process_runtime._windows_process_identity",
lambda _pid: identity,
)
client = GatewayClientLease(runtime, kind="tui", pid=12345, token="client")
client.acquire()
client.mark_ephemeral()
identity = "filetime:replacement-process"
snapshot = client.snapshot()
assert snapshot.auto_stop is True
assert snapshot.clients == 0
def test_status_clears_stale_state(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
runtime.paths.run_dir.mkdir(parents=True)
runtime.paths.state_path.write_text('{"pid": 12345, "identity": 12345}', encoding="utf-8")
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: False)
status = runtime.status()
assert status.running is False
assert status.reason == "stale_state"
assert not runtime.paths.state_path.exists()
def test_status_clears_state_when_pid_identity_changes(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
runtime.paths.run_dir.mkdir(parents=True)
runtime.paths.state_path.write_text('{"pid": 12345, "identity": 111}', encoding="utf-8")
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 222)
status = runtime.status()
assert status.running is False
assert status.reason == "stale_state"
assert not runtime.paths.state_path.exists()
def test_status_keeps_live_state_when_identity_probe_is_temporarily_unavailable(
tmp_path,
monkeypatch,
):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
runtime.paths.run_dir.mkdir(parents=True)
runtime.paths.state_path.write_text(
'{"pid": 12345, "identity": "created-at"}',
encoding="utf-8",
)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: None)
status = runtime.status()
assert status.running is True
assert status.reason == "identity_unavailable"
assert runtime.paths.state_path.exists()
def test_stop_refuses_to_signal_a_process_when_identity_cannot_be_verified(
tmp_path,
monkeypatch,
):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
runtime.paths.run_dir.mkdir(parents=True)
runtime.paths.state_path.write_text(
'{"pid": 12345, "identity": "created-at"}',
encoding="utf-8",
)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: None)
monkeypatch.setattr(
runtime,
"_terminate",
lambda *_args, **_kwargs: pytest.fail("an unverified PID must not be signalled"),
)
result = runtime.stop()
assert result.ok is False
assert result.message == "gateway_identity_unavailable"
assert result.status.running is True
assert runtime.paths.state_path.exists()
def test_posix_process_identity_includes_start_time_and_accepts_legacy_state(
tmp_path,
monkeypatch,
):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr("nanobot.process_runtime._platform_name", lambda: "Linux")
monkeypatch.setattr("nanobot.process_runtime.os.getpgid", lambda _pid: 42, raising=False)
monkeypatch.setattr(runtime, "_posix_process_started_at", lambda _pid: "987654")
assert runtime.process_identity(12345) == "42:987654"
assert runtime._record_matches_process({"identity": 42}, 12345) is True
2026-08-18 02:52:58 +08:00
def test_darwin_process_identity_is_locale_independent(tmp_path, monkeypatch):
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Darwin",
subprocess_run=lambda *_args, **_kwargs: pytest.fail(
"Darwin identities must not depend on localized subprocess output"
),
)
monkeypatch.setenv("LANG", "zh_CN.UTF-8")
monkeypatch.setenv("LC_ALL", "zh_CN.UTF-8")
monkeypatch.setattr("nanobot.process_runtime._platform_name", lambda: "Darwin")
2026-08-18 02:52:58 +08:00
started_at = int(time.mktime((2026, 8, 18, 2, 17, 54, -1, -1, -1)))
monkeypatch.setattr(
"nanobot.process_runtime._darwin_process_birth",
lambda _pid: (42, started_at, 123456),
)
identity = f"darwin:42:{started_at}:123456"
assert runtime.process_identity(12345) == identity
assert runtime.process_identity_record(12345) == {
"identity": 42,
"stable_identity": identity,
}
2026-08-18 02:52:58 +08:00
assert runtime._record_matches_process({"identity": 42}, 12345) is True
def test_darwin_status_discovers_a_legacy_localized_state(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
started_at = int(time.mktime((2026, 8, 18, 2, 17, 54, -1, -1, -1)))
runtime.paths.run_dir.mkdir(parents=True)
runtime.paths.state_path.write_text(
'{"pid": 12345, "identity": "42:二 8/18 02:17:54 2026"}',
encoding="utf-8",
)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(
runtime,
"_process_identity",
lambda _pid: f"darwin:42:{started_at}:123456",
)
status = runtime.status()
assert status.running is True
assert status.reason == "running"
assert runtime.paths.state_path.exists()
def test_darwin_status_prefers_stable_identity_over_compatibility_pgid(
tmp_path,
monkeypatch,
):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
runtime.paths.run_dir.mkdir(parents=True)
runtime.paths.state_path.write_text(
json.dumps(
{
"pid": 12345,
"identity": 42,
"stable_identity": "darwin:42:1786992348:123456",
}
),
encoding="utf-8",
)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(
runtime,
"_process_identity",
lambda _pid: "darwin:42:1786992348:654321",
)
status = runtime.status()
assert status.running is False
assert status.reason == "stale_state"
assert not runtime.paths.state_path.exists()
2026-08-18 02:52:58 +08:00
@pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS proc_pidinfo")
def test_darwin_live_process_identity_is_stable(tmp_path):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
first = runtime.process_identity(os.getpid())
second = runtime.process_identity(os.getpid())
assert isinstance(first, str)
assert first.startswith("darwin:")
assert second == first
def test_stop_terminates_recorded_process(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
runtime.paths.run_dir.mkdir(parents=True)
runtime.paths.state_path.write_text('{"pid": 12345, "identity": 12345}', encoding="utf-8")
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
terminated: list[int] = []
2026-06-22 16:07:19 +08:00
def fake_terminate(pid, timeout_s):
terminated.append(pid)
return True
monkeypatch.setattr(runtime, "_terminate", fake_terminate)
result = runtime.stop()
assert result.ok is True
assert terminated == [12345]
assert not runtime.paths.state_path.exists()
2026-06-22 16:07:19 +08:00
def test_stop_keeps_state_when_process_survives_timeout(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
runtime.paths.run_dir.mkdir(parents=True)
runtime.paths.state_path.write_text('{"pid": 12345, "identity": 12345}', encoding="utf-8")
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
monkeypatch.setattr(runtime, "_terminate", lambda _pid, timeout_s: False)
result = runtime.stop(timeout_s=0)
assert result.ok is False
assert result.message == "gateway_stop_timeout"
assert result.status.running is True
assert result.status.reason == "stop_timeout"
assert runtime.paths.state_path.exists()
2026-07-03 17:05:57 +08:00
2026-07-13 13:11:46 +08:00
def test_stop_succeeds_when_process_exits_at_timeout_boundary(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
running = GatewayStatus(
running=True,
pid=12345,
state_path=runtime.paths.state_path,
log_path=runtime.paths.log_path,
)
stopped = GatewayStatus(
running=False,
pid=None,
state_path=runtime.paths.state_path,
log_path=runtime.paths.log_path,
reason="stop_timeout",
)
statuses = iter([running, stopped])
monkeypatch.setattr(runtime, "status", lambda **_kwargs: next(statuses))
monkeypatch.setattr(runtime, "_read_state", lambda: {"pid": 12345, "identity": 12345})
monkeypatch.setattr(runtime, "_process_identity_match", lambda *_args: "match")
2026-07-13 13:11:46 +08:00
monkeypatch.setattr(runtime, "_terminate", lambda *_args, **_kwargs: False)
result = runtime.stop(timeout_s=0)
assert result.ok is True
assert result.message == "gateway_stopped"
assert result.status.running is False
def test_terminate_windows_targets_only_the_recorded_process_tree(tmp_path, monkeypatch):
2026-07-03 17:05:57 +08:00
taskkill_calls: list[dict] = []
wait_timeouts: list[int | float] = []
2026-07-03 17:05:57 +08:00
def fake_run(command, **kwargs):
taskkill_calls.append({"command": command, "kwargs": kwargs})
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Windows",
subprocess_run=fake_run,
sleep=lambda _seconds: None,
)
monkeypatch.setattr(
"nanobot.process_runtime.os.kill",
lambda *_args: pytest.fail("Windows termination must not broadcast a console event"),
)
2026-07-03 17:05:57 +08:00
def fake_wait_for_exit(_pid, _timeout_s):
wait_timeouts.append(_timeout_s)
2026-07-03 17:05:57 +08:00
# Simulate a process that only exits after the taskkill fallback runs.
return bool(taskkill_calls)
monkeypatch.setattr(runtime, "_wait_for_exit", fake_wait_for_exit)
assert runtime._terminate_windows(12345, timeout_s=20) is True
assert wait_timeouts == [20]
2026-07-03 17:05:57 +08:00
assert taskkill_calls == [
{
"command": ["taskkill", "/PID", "12345", "/T"],
"kwargs": {
"check": False,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
},
}
]
2026-07-13 13:11:46 +08:00
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups are unavailable")
def test_terminate_posix_tolerates_process_group_disappearing_before_sigkill(
tmp_path,
monkeypatch,
) -> None:
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Darwin",
sleep=lambda _seconds: None,
)
waits = iter([False, True])
monkeypatch.setattr(
"nanobot.process_runtime.os.getpgid",
lambda _pid: 1234,
raising=False,
)
def fake_killpg(_pgid, sent_signal):
if sent_signal == signal.SIGKILL:
raise PermissionError(1, "Operation not permitted")
monkeypatch.setattr("nanobot.process_runtime.os.killpg", fake_killpg, raising=False)
monkeypatch.setattr(runtime, "_wait_for_exit", lambda *_args: next(waits))
assert runtime._terminate_posix(1234, timeout_s=1) is True