Release resilient atomic Napseer CLI runtime
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SUPERVISOR = ROOT / "resources" / "scripts" / "napseer_mcp_supervisor.py"
|
||||
|
||||
|
||||
def write_worker(path: pathlib.Path, version: int) -> None:
|
||||
path.write_text(
|
||||
f"""\
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
VERSION = {version}
|
||||
|
||||
for line in sys.stdin:
|
||||
message = json.loads(line)
|
||||
if message.get("method") == "notifications/initialized":
|
||||
continue
|
||||
if message.get("method") == "tools/call":
|
||||
name = (message.get("params") or {{}}).get("name")
|
||||
if name == "crash":
|
||||
os._exit(17)
|
||||
result = {{"pid": os.getpid(), "version": VERSION, "name": name}}
|
||||
else:
|
||||
result = {{"version": VERSION}}
|
||||
print(json.dumps({{"jsonrpc": "2.0", "id": message.get("id"), "result": result}}), flush=True)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class SupervisorClient:
|
||||
def __init__(self, worker_path: pathlib.Path):
|
||||
environment = os.environ.copy()
|
||||
environment["NAPSEER_MCP_WORKER_PATH"] = str(worker_path)
|
||||
environment["NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS"] = "5"
|
||||
self.process = subprocess.Popen(
|
||||
[sys.executable, str(SUPERVISOR)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def request(self, request_id: int, method: str, params=None):
|
||||
assert self.process.stdin is not None
|
||||
assert self.process.stdout is not None
|
||||
message = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
self.process.stdin.write(json.dumps(message).encode("utf-8") + b"\n")
|
||||
self.process.stdin.flush()
|
||||
readable, _, _ = select.select([self.process.stdout], [], [], 8)
|
||||
assert readable, "supervisor did not return a response"
|
||||
return json.loads(self.process.stdout.readline())
|
||||
|
||||
def close(self):
|
||||
if self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
|
||||
def test_restarts_worker_killed_between_requests(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
first = client.request(1, "tools/call", {"name": "pid", "arguments": {}})
|
||||
os.kill(first["result"]["pid"], signal.SIGTERM)
|
||||
time.sleep(0.1)
|
||||
|
||||
second = client.request(2, "tools/list")
|
||||
|
||||
assert second["result"]["version"] == 1
|
||||
assert client.process.poll() is None
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_reloads_replaced_worker_between_requests(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
assert client.request(1, "initialize")["result"]["version"] == 1
|
||||
time.sleep(0.01)
|
||||
write_worker(worker_path, 2)
|
||||
os.utime(worker_path, None)
|
||||
|
||||
assert client.request(2, "tools/list")["result"]["version"] == 2
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_worker_crash_returns_error_without_closing_transport(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
failed = client.request(1, "tools/call", {"name": "crash", "arguments": {}})
|
||||
recovered = client.request(2, "tools/list")
|
||||
|
||||
assert failed["error"]["code"] == -32098
|
||||
assert recovered["result"]["version"] == 1
|
||||
assert client.process.poll() is None
|
||||
finally:
|
||||
client.close()
|
||||
Reference in New Issue
Block a user