215 lines
7.1 KiB
Python
215 lines
7.1 KiB
Python
import json
|
|
import signal
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
|
|
|
|
|
class FakeProcess:
|
|
def __init__(self, pid: int = 12345):
|
|
self.pid = pid
|
|
|
|
|
|
def _paths(tmp_path: Path) -> GatewayRuntimePaths:
|
|
return GatewayRuntimePaths.for_instance(data_dir=tmp_path)
|
|
|
|
|
|
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_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
|
|
|
|
|
|
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"]
|
|
|
|
|
|
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_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] = []
|
|
|
|
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()
|
|
|
|
|
|
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()
|
|
|
|
|
|
def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monkeypatch):
|
|
taskkill_calls: list[dict] = []
|
|
wait_timeouts: list[int | float] = []
|
|
|
|
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(signal, "CTRL_BREAK_EVENT", 1, raising=False)
|
|
|
|
def fake_kill(_pid, _signal):
|
|
raise OSError(87, "The parameter is incorrect")
|
|
|
|
monkeypatch.setattr("nanobot.gateway.runtime.os.kill", fake_kill)
|
|
|
|
def fake_wait_for_exit(_pid, _timeout_s):
|
|
wait_timeouts.append(_timeout_s)
|
|
# 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 == [2]
|
|
assert taskkill_calls == [
|
|
{
|
|
"command": ["taskkill", "/PID", "12345", "/T"],
|
|
"kwargs": {
|
|
"check": False,
|
|
"stdout": subprocess.DEVNULL,
|
|
"stderr": subprocess.DEVNULL,
|
|
},
|
|
}
|
|
]
|