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

423 lines
14 KiB
Python

"""Regression tests: ``/api/profiles`` handlers must not block the event loop.
``hermes_cli/web_routers/profiles.py`` holds handler bodies that were extracted
verbatim from ``web_server.py``, so the blocking library calls they inherited
run inline on the ASGI event loop. The worst of them are unbounded from the
dashboard's point of view: deleting a profile whose gateway is up sleeps up to
10 s in ``profiles._stop_gateway_process``, and ``describe-auto`` makes a
provider round-trip with a 60 s ceiling. While the loop is parked, the process
serves nothing else — including the ``/api/ws`` probes the desktop app and the
dashboard's own Chat tab depend on.
Two complementary assertions per site:
* a **loop probe** — the stubbed callee records whether an event loop is
running in its own thread, mirroring
``tests/hermes_cli/test_cron_dashboard_off_loop.py``; and
* a **concurrency proof** — the stubbed callee blocks on a ``threading.Event``
while an unrelated request is timed, which fails if the loop is parked.
The block is bounded by a timeout so a regression costs the suite a few
seconds rather than hanging it.
"""
from __future__ import annotations
import asyncio
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
# How long a stubbed blocking call holds its thread when nobody releases it.
BLOCK_SECONDS = 5.0
# A concurrently-served request has to land well inside that window. The gap is
# large (a served request takes milliseconds) so the bound is not timing-fragile.
CONCURRENT_BUDGET = BLOCK_SECONDS / 2
@pytest.fixture()
def profile_dir(tmp_path, monkeypatch) -> Path:
"""A real profile directory under a throwaway HERMES_HOME."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from hermes_cli import profiles as profiles_mod
d = profiles_mod.get_profile_dir("demo")
d.mkdir(parents=True, exist_ok=True)
return d
@pytest.fixture()
def client(profile_dir):
"""One ``TestClient`` context == one portal == one event loop.
This matters: entering the context manager pins a single blocking portal
for every request, so a handler that parks the loop really does starve the
concurrent request below. A bare ``TestClient(app)`` spins up a fresh loop
per request and would pass these tests even unfixed.
"""
from hermes_cli import web_server
with TestClient(web_server.app, raise_server_exceptions=False) as c:
# web_server resolves _SESSION_TOKEN once, at import, so read it back
# from the module rather than pinning a value from this file.
c.headers["Authorization"] = f"Bearer {web_server._SESSION_TOKEN}"
yield c
@pytest.fixture()
def loop_probe():
"""Collect ``(tag, on_loop)`` proof from stubbed blocking callees."""
seen: list[tuple[str, bool]] = []
def probe(tag: str) -> None:
try:
asyncio.get_running_loop()
seen.append((tag, True))
except RuntimeError:
seen.append((tag, False))
return seen, probe
def assert_off_loop(seen, tag: str) -> None:
assert (tag, False) in seen, (
f"{tag} must run off the event loop; proof: {seen}"
)
class _Blocker:
"""A stand-in for a slow library call, released by the test."""
def __init__(self, result=None):
self.entered = threading.Event()
self.release = threading.Event()
self._result = result
def __call__(self, *args, **kwargs):
self.entered.set()
# Bounded on purpose: a regression must not hang the suite.
self.release.wait(timeout=BLOCK_SECONDS)
return self._result
def assert_serves_concurrently(client, blocker: _Blocker, fire) -> None:
"""Fire a request that blocks inside its handler, then time another one.
``fire`` issues the blocking request from a worker thread. Once the stub
has been entered, an unrelated cheap route is timed on this thread: it can
only answer quickly if the blocking work left the event loop.
"""
with ThreadPoolExecutor(max_workers=1) as pool:
blocked = pool.submit(fire)
try:
assert blocker.entered.wait(timeout=BLOCK_SECONDS), (
"the blocking stub was never reached"
)
start = time.monotonic()
probe = client.get("/api/profiles/demo/setup-command")
elapsed = time.monotonic() - start
finally:
blocker.release.set()
blocked.result(timeout=BLOCK_SECONDS * 2)
assert probe.status_code == 200, probe.text
assert elapsed < CONCURRENT_BUDGET, (
f"a concurrent request waited {elapsed:.2f}s while the handler was "
f"busy — the event loop was parked by the blocking call"
)
# ── DELETE /api/profiles/{name} — the 10 s gateway-stop sleep ────────────────
def test_delete_profile_runs_off_loop(client, monkeypatch, loop_probe, tmp_path):
seen, probe = loop_probe
from hermes_cli import profiles as profiles_mod
def fake_delete(name, yes=False):
probe("delete_profile")
return tmp_path / "profiles" / name
monkeypatch.setattr(profiles_mod, "delete_profile", fake_delete)
resp = client.delete("/api/profiles/demo")
assert resp.status_code == 200, resp.text
assert_off_loop(seen, "delete_profile")
def test_delete_profile_does_not_block_the_dashboard(client, monkeypatch, tmp_path):
"""``delete_profile`` stops a running gateway by polling for up to 10 s.
That is longer than the desktop's WebSocket ready-probe tolerates, so it
must not hold the loop.
"""
from hermes_cli import profiles as profiles_mod
blocker = _Blocker(result=tmp_path / "profiles" / "demo")
monkeypatch.setattr(profiles_mod, "delete_profile", blocker)
assert_serves_concurrently(
client, blocker, lambda: client.delete("/api/profiles/demo")
)
# ── POST /api/profiles/{name}/describe-auto — the 60 s LLM round-trip ────────
def _outcome(ok=True, reason="described", description="a demo profile"):
from hermes_cli.profile_describer import DescribeOutcome
return DescribeOutcome("demo", ok, reason, description=description)
def test_describe_auto_runs_off_loop(client, monkeypatch, loop_probe):
seen, probe = loop_probe
from hermes_cli import profile_describer
def fake_describe(name, overwrite=False, timeout=None):
probe("describe_profile")
return _outcome()
monkeypatch.setattr(profile_describer, "describe_profile", fake_describe)
resp = client.post("/api/profiles/demo/describe-auto", json={"overwrite": True})
assert resp.status_code == 200, resp.text
assert resp.json()["ok"] is True
assert_off_loop(seen, "describe_profile")
def test_describe_auto_does_not_block_the_dashboard(client, monkeypatch):
"""The auxiliary provider call has a 60 s ceiling — six times the
desktop's disconnect threshold."""
from hermes_cli import profile_describer
blocker = _Blocker(result=_outcome())
monkeypatch.setattr(profile_describer, "describe_profile", blocker)
assert_serves_concurrently(
client,
blocker,
lambda: client.post(
"/api/profiles/demo/describe-auto", json={"overwrite": True}
),
)
# ── PATCH /api/profiles/{name} — rename walks and rewrites the profile tree ──
def test_rename_profile_runs_off_loop(client, monkeypatch, loop_probe, tmp_path):
seen, probe = loop_probe
from hermes_cli import profiles as profiles_mod
def fake_rename(old, new):
probe("rename_profile")
return tmp_path / "profiles" / new
monkeypatch.setattr(profiles_mod, "rename_profile", fake_rename)
resp = client.patch("/api/profiles/demo", json={"new_name": "renamed"})
assert resp.status_code == 200, resp.text
assert_off_loop(seen, "rename_profile")
def test_rename_profile_does_not_block_the_dashboard(client, monkeypatch, tmp_path):
from hermes_cli import profiles as profiles_mod
blocker = _Blocker(result=tmp_path / "profiles" / "renamed")
monkeypatch.setattr(profiles_mod, "rename_profile", blocker)
assert_serves_concurrently(
client,
blocker,
lambda: client.patch("/api/profiles/demo", json={"new_name": "renamed"}),
)
# ── /api/profiles/active — sticky active-profile state file ──────────────────
def test_get_active_profile_runs_off_loop(client, monkeypatch, loop_probe):
seen, probe = loop_probe
from hermes_cli import profiles as profiles_mod
def fake_get_active():
probe("get_active_profile")
return "demo"
def fake_get_current():
probe("get_active_profile_name")
return "default"
monkeypatch.setattr(profiles_mod, "get_active_profile", fake_get_active)
monkeypatch.setattr(profiles_mod, "get_active_profile_name", fake_get_current)
resp = client.get("/api/profiles/active")
assert resp.status_code == 200, resp.text
assert resp.json() == {"active": "demo", "current": "default"}
assert_off_loop(seen, "get_active_profile")
assert_off_loop(seen, "get_active_profile_name")
def test_set_active_profile_runs_off_loop(client, monkeypatch, loop_probe):
seen, probe = loop_probe
from hermes_cli import profiles as profiles_mod
def fake_set_active(name):
probe("set_active_profile")
monkeypatch.setattr(profiles_mod, "set_active_profile", fake_set_active)
resp = client.post("/api/profiles/active", json={"name": "demo"})
assert resp.status_code == 200, resp.text
assert resp.json()["active"] == "demo"
assert_off_loop(seen, "set_active_profile")
# ── PUT /api/profiles/{name}/description — profile.yaml read/modify/write ────
def test_update_description_runs_off_loop(client, monkeypatch, loop_probe):
seen, probe = loop_probe
from hermes_cli import profiles as profiles_mod
def fake_write_meta(profile_dir, **kwargs):
probe("write_profile_meta")
monkeypatch.setattr(profiles_mod, "write_profile_meta", fake_write_meta)
resp = client.put(
"/api/profiles/demo/description", json={"description": "a demo profile"}
)
assert resp.status_code == 200, resp.text
assert resp.json()["description_auto"] is False
assert_off_loop(seen, "write_profile_meta")
# ── GET /api/profiles/{name}/desktop-overlay — reads desktop.json ────────────
def test_desktop_overlay_read_runs_off_loop(client, monkeypatch, loop_probe, profile_dir):
seen, probe = loop_probe
(profile_dir / "desktop.json").write_text('{"theme": "dark"}', encoding="utf-8")
real_read_text = Path.read_text
def probing_read_text(self, *args, **kwargs):
if self.name == "desktop.json":
probe("desktop.json read")
return real_read_text(self, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", probing_read_text)
resp = client.get("/api/profiles/demo/desktop-overlay")
assert resp.status_code == 200, resp.text
assert resp.json() == {"exists": True, "desktop": {"theme": "dark"}}
assert_off_loop(seen, "desktop.json read")
def test_desktop_overlay_absent_still_reports_missing(client):
"""The offloaded read must keep distinguishing "no file" from "no data"."""
resp = client.get("/api/profiles/demo/desktop-overlay")
assert resp.status_code == 200, resp.text
assert resp.json() == {"exists": False, "desktop": None}
def test_desktop_overlay_null_document_still_reports_present(client, profile_dir):
"""A ``desktop.json`` holding literal ``null`` exists, it is just empty —
it must not be reported the same as a missing file."""
(profile_dir / "desktop.json").write_text("null", encoding="utf-8")
resp = client.get("/api/profiles/demo/desktop-overlay")
assert resp.status_code == 200, resp.text
assert resp.json() == {"exists": True, "desktop": None}
def test_desktop_overlay_unreadable_document_is_a_500(client, profile_dir):
"""A malformed overlay still surfaces as a 500, not a silent success."""
(profile_dir / "desktop.json").write_text("{not json", encoding="utf-8")
resp = client.get("/api/profiles/demo/desktop-overlay")
assert resp.status_code == 500, resp.text
# ── Status-code mapping must survive the move into a worker thread ───────────
def test_delete_missing_profile_is_still_404(client, monkeypatch):
from hermes_cli import profiles as profiles_mod
def fake_delete(name, yes=False):
raise FileNotFoundError(f"Profile '{name}' does not exist.")
monkeypatch.setattr(profiles_mod, "delete_profile", fake_delete)
assert client.delete("/api/profiles/demo").status_code == 404
def test_rename_to_existing_profile_is_still_400(client, monkeypatch):
from hermes_cli import profiles as profiles_mod
def fake_rename(old, new):
raise FileExistsError(f"Profile '{new}' already exists.")
monkeypatch.setattr(profiles_mod, "rename_profile", fake_rename)
resp = client.patch("/api/profiles/demo", json={"new_name": "taken"})
assert resp.status_code == 400, resp.text
def test_set_active_missing_profile_is_still_404(client, monkeypatch):
from hermes_cli import profiles as profiles_mod
def fake_set_active(name):
raise FileNotFoundError(f"Profile '{name}' does not exist.")
monkeypatch.setattr(profiles_mod, "set_active_profile", fake_set_active)
assert client.post("/api/profiles/active", json={"name": "demo"}).status_code == 404
def test_describe_auto_unknown_profile_is_still_404(client):
"""``_resolve_profile_dir`` still runs before the worker hop, so an
unknown profile is a 404 rather than a 500 from the wrapped call."""
assert (
client.post("/api/profiles/nope/describe-auto", json={}).status_code == 404
)
# ── PUT /api/profiles/{name}/model — config.yaml read-modify-write ───────────
def test_update_profile_model_runs_off_loop(client, monkeypatch, loop_probe):
seen, probe = loop_probe
from hermes_cli.web_routers import profiles as router_mod
def fake_write_model(profile_dir, provider, model):
probe("write_profile_model")
monkeypatch.setattr(router_mod, "_write_profile_model", fake_write_model)
resp = client.put(
"/api/profiles/demo/model", json={"provider": "openrouter", "model": "x/y"}
)
assert resp.status_code == 200, resp.text
assert_off_loop(seen, "write_profile_model")