Files
aiturk-hermes-ide/tests/hermes_cli/test_local_abandoned_requests.py
T

214 lines
7.3 KiB
Python

"""Abandoned-request lifecycle: work sent to the managed local server must
die when its caller goes away, and teardown must never orphan VRAM.
The incident this guards: auxiliary calls (title generation + retries)
queued at the router behind a cold model load, their clients timed out
and hung up, and the router then dispatched them anyway. Non-streamed
responses write the socket only after the FULL generation, so nothing
noticed the dead clients — two uncapped decodes ran at full GPU for the
better part of an hour with nobody listening.
Three contracts, one per failure link:
1. Auxiliary requests to the managed local endpoint are always streamed
(a dead client then cancels decode at the first chunk write).
2. Explicit caller max_tokens caps reach the managed local endpoint
(title generation's 64-token cap must not be silently dropped).
3. Supervisor teardown terminates the whole process tree, and a router
respawn reaps orphaned model children first (each holds GiB of VRAM).
"""
from __future__ import annotations
import json
import subprocess
import types
import pytest
import agent.auxiliary_client as aux
from hermes_cli.local_runtime.supervisor import LlamaServerSupervisor
MANAGED_URL = "http://127.0.0.1:18434/v1"
@pytest.fixture
def managed_state(tmp_path, monkeypatch):
"""A supervisor state file declaring the managed endpoint, cache reset."""
state = tmp_path / "server.json"
state.write_text(json.dumps({"base_url": MANAGED_URL, "api_key": "k",
"pid": 4242}), encoding="utf-8")
monkeypatch.setattr("hermes_cli.local_runtime.supervisor.state_path",
lambda: state)
monkeypatch.setattr(aux, "_managed_local_cache", (0.0, ""))
return state
# ── 1. managed endpoint is always streamed ───────────────────
def test_managed_endpoint_requires_stream(managed_state):
assert aux._provider_requires_stream("custom", MANAGED_URL) is True
def test_managed_detection_matches_netloc_not_substring(managed_state):
# Same host, different port: a user's own external server — untouched.
assert aux._provider_requires_stream("custom", "http://127.0.0.1:8080/v1") is False
def test_no_state_file_means_no_managed_endpoint(tmp_path, monkeypatch):
monkeypatch.setattr("hermes_cli.local_runtime.supervisor.state_path",
lambda: tmp_path / "absent.json")
monkeypatch.setattr(aux, "_managed_local_cache", (0.0, ""))
assert aux._is_managed_local_endpoint(MANAGED_URL) is False
def test_remote_providers_unaffected(managed_state):
assert aux._provider_requires_stream("nous",
"https://inference-api.nousresearch.com/v1/") is False
# ── 2. explicit caps reach the managed endpoint ──────────────
def test_explicit_max_tokens_forwarded_to_managed_local(managed_state, monkeypatch):
monkeypatch.setattr(aux, "_current_custom_base_url", lambda: MANAGED_URL)
kwargs = aux._build_call_kwargs(
"custom", "Qwen-Local", [{"role": "user", "content": "hi"}],
max_tokens=64, timeout=30.0, task="title_generation")
assert kwargs.get("max_tokens") == 64 or kwargs.get("max_completion_tokens") == 64, (
"explicit caller cap dropped on the managed local endpoint — an "
"EOS-less generation then runs to the full context window")
def test_no_default_cap_policy_unchanged_for_remote(monkeypatch):
# A generic remote provider still drops the cap (the forwarding gate
# is an allow-list). openrouter no longer qualifies as the example
# here: main forwards its caps deliberately (#41035, 402 affordability).
monkeypatch.setattr(aux, "_managed_local_cache", (0.0, ""))
kwargs = aux._build_call_kwargs(
"openai", "some/model", [{"role": "user", "content": "hi"}],
max_tokens=64, timeout=30.0)
assert "max_tokens" not in kwargs and "max_completion_tokens" not in kwargs
# ── 3. teardown kills the tree; respawn reaps orphans ────────
class _FakeChild:
def __init__(self, pid):
self.pid = pid
self.terminated = False
self.killed = False
def terminate(self):
self.terminated = True
def is_running(self):
return not self.terminated and not self.killed
def kill(self):
self.killed = True
def test_terminate_tree_terminates_children_too(monkeypatch):
children = [_FakeChild(101), _FakeChild(102)]
class _FakeParentProc:
def __init__(self, pid):
self.pid = pid
def children(self, recursive=False):
assert recursive is True
return children
fake_psutil = types.SimpleNamespace(Process=_FakeParentProc)
monkeypatch.setitem(__import__("sys").modules, "psutil", fake_psutil)
class _FakeRouter:
pid = 4242
terminated = False
def terminate(self):
_FakeRouter.terminated = True
def wait(self, timeout=None):
return 0
def poll(self):
return None
LlamaServerSupervisor._terminate_tree(_FakeRouter())
assert _FakeRouter.terminated
assert all(c.terminated for c in children), (
"router children orphaned on stop — each holds GiB of VRAM")
def test_terminate_tree_survives_missing_psutil(monkeypatch):
import builtins
real_import = builtins.__import__
def _no_psutil(name, *a, **k):
if name == "psutil":
raise ImportError("nope")
return real_import(name, *a, **k)
monkeypatch.setattr(builtins, "__import__", _no_psutil)
class _FakeRouter:
pid = 4242
terminated = False
def terminate(self):
_FakeRouter.terminated = True
def wait(self, timeout=None):
return 0
LlamaServerSupervisor._terminate_tree(_FakeRouter())
assert _FakeRouter.terminated # router still stopped without psutil
def test_reap_orphans_kills_only_our_parentless_binaries(tmp_path, monkeypatch):
exe = tmp_path / "llama-server.exe"
exe.write_text("")
orphan = _FakeChild(300)
adopted = _FakeChild(301) # parent alive -> not an orphan
foreign = _FakeChild(302) # different binary -> never touched
def _info(pid, exe_path, ppid):
p = _FakeChild(pid)
p.info = {"exe": exe_path, "ppid": ppid}
return p
procs = [
_info(300, str(exe), 9999), # dead parent -> reap
_info(301, str(exe), 1), # live parent -> keep
_info(302, str(tmp_path / "other.exe"), 9999), # foreign -> keep
]
reaped = []
for p in procs:
p.kill = lambda p=p: reaped.append(p.info and p.pid)
class _NoSuch(Exception):
pass
fake_psutil = types.SimpleNamespace(
process_iter=lambda attrs: procs,
pid_exists=lambda pid: pid == 1,
NoSuchProcess=_NoSuch,
AccessDenied=_NoSuch,
)
monkeypatch.setitem(__import__("sys").modules, "psutil", fake_psutil)
monkeypatch.setattr("hermes_cli.local_runtime.supervisor.server_binary",
lambda install_dir: exe)
sup = LlamaServerSupervisor.__new__(LlamaServerSupervisor)
sup.install_dir = tmp_path
sup.proc = None
sup._reap_orphaned_children()
assert reaped == [300], f"reaped {reaped}; wanted only the orphan (300)"