* fix(#6335): filter unconfigured providers from model selector When a known provider (e.g. openai-api) is listed in config.yaml's providers section with only metadata (name, display_name) and no route configuration or credentials, it should not appear in the chat model selector dropdown. Previously, any provider recognised as a 'known provider' (present in _PROVIDER_MODELS or _PROVIDER_DISPLAY) would be admitted to the picker simply by being listed in the config.yaml providers block — even after the user removed its API key. This caused a persistent ghost provider entry in the dropdown (#6335). The fix gates known-provider admission on credential evidence: the provider must already have been detected from a credential source (env vars, hermes auth, or credential pool) OR carry explicit route configuration (api/base_url/api_key/key_env) OR be the active provider with a configured models allowlist. * fix(#6338): normalize provider aliases in credential check The credential check compared _canonical (config key like 'x-ai', 'google') directly against detected_providers (alias-resolved values like 'xai', 'gemini'). Alias-named authenticated providers were therefore incorrectly treated as unconfigured and removed from the model selector. Fix: resolve _canonical through _resolve_provider_alias() before the membership test, so config keys like 'x-ai' match credential evidence reported as 'xai'. Also check the raw canonical for cases where the config key is itself the alias target. Refs: #6335, #6338 * fix(#6338): normalize detected_providers through alias resolver Normalize both _canonical and detected_providers entries through _resolve_provider_alias before the membership test, so that a WebUI canonical form in detected_providers (e.g. x-ai added by a prior loop iteration) also matches the alias-resolved form used in credential detection (e.g. xai from hermes_cli). This is belt-and-braces on the existing _resolve_provider_alias(_canonical) check: both sides now map into the same alias-resolved namespace, closing any edge case where detected_providers contains a WebUI-canonical form that happens to differ from the alias target. * test(ui): catalog-admission regression for credentialed providers (#6338) * test(ui): pin config path state in catalog-admission regression (#6338) * fix(system-health): expose runtime memory diagnostics without blocking owner locks (#6351) * fix(system-health): keep owner contention local to runtime status (#6351) * docs(changelog): stamp #6338 #6363 (certified batch) --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@gmail.com> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
1116 lines
43 KiB
Python
1116 lines
43 KiB
Python
"""Regression coverage for #693 live VPS host resource health panel."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import queue
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, patch
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
|
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
|
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
|
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
|
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
|
ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
|
AUTH_PY = (REPO_ROOT / "api" / "auth.py").read_text(encoding="utf-8")
|
|
|
|
|
|
class _FakeHandler:
|
|
def __init__(self):
|
|
self.status = None
|
|
self.sent_headers = []
|
|
self.body = bytearray()
|
|
self.wfile = self
|
|
self.headers = {}
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, name, value):
|
|
self.sent_headers.append((name, value))
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
def write(self, data):
|
|
self.body.extend(data)
|
|
|
|
def json_body(self):
|
|
return json.loads(bytes(self.body).decode("utf-8"))
|
|
|
|
|
|
def test_system_health_payload_normalizes_safe_aggregate_metrics(monkeypatch):
|
|
from api import system_health
|
|
|
|
monkeypatch.setattr(system_health, "_cpu_percent", lambda: 17.345)
|
|
monkeypatch.setattr(
|
|
system_health,
|
|
"_memory_usage",
|
|
lambda: {"used_bytes": 4_000, "total_bytes": 10_000, "percent": 40.0},
|
|
)
|
|
monkeypatch.setattr(
|
|
system_health,
|
|
"_disk_usage",
|
|
lambda: {"used_bytes": 55_500, "total_bytes": 100_000, "percent": 55.5},
|
|
)
|
|
|
|
payload = system_health.build_system_health_payload()
|
|
|
|
assert payload["status"] == "ok"
|
|
assert payload["available"] is True
|
|
assert payload["cpu"] == {"percent": 17.3}
|
|
assert payload["memory"] == {"used_bytes": 4000, "total_bytes": 10000, "percent": 40.0}
|
|
assert payload["disk"] == {"used_bytes": 55500, "total_bytes": 100000, "percent": 55.5}
|
|
assert payload["checked_at"]
|
|
rendered = repr(payload)
|
|
for private_fragment in ("/home/", "/Users/", "mount", "path", "argv", "command", "env", "token"):
|
|
assert private_fragment not in rendered
|
|
|
|
|
|
def test_system_health_payload_partial_and_unavailable_are_graceful(monkeypatch):
|
|
from api import system_health
|
|
|
|
def boom():
|
|
raise RuntimeError("private /home/user/path should not leak")
|
|
|
|
monkeypatch.setattr(system_health, "_cpu_percent", boom)
|
|
monkeypatch.setattr(system_health, "_memory_usage", boom)
|
|
monkeypatch.setattr(
|
|
system_health,
|
|
"_disk_usage",
|
|
lambda: {"used_bytes": 1, "total_bytes": 4, "percent": 25.0},
|
|
)
|
|
|
|
partial = system_health.build_system_health_payload()
|
|
assert partial["status"] == "partial"
|
|
assert partial["available"] is True
|
|
assert partial["disk"]["percent"] == 25.0
|
|
assert partial["cpu"] is None
|
|
assert partial["memory"] is None
|
|
assert {e["metric"] for e in partial["errors"]} == {"cpu", "memory"}
|
|
assert "/home/user" not in repr(partial)
|
|
|
|
monkeypatch.setattr(system_health, "_disk_usage", boom)
|
|
unavailable = system_health.build_system_health_payload()
|
|
assert unavailable["status"] == "unavailable"
|
|
assert unavailable["available"] is False
|
|
assert unavailable["cpu"] is None
|
|
assert unavailable["memory"] is None
|
|
assert unavailable["disk"] is None
|
|
assert "/home/user" not in repr(unavailable)
|
|
|
|
|
|
def test_system_health_falls_back_to_psutil_when_procfs_is_unavailable(monkeypatch):
|
|
from api import system_health
|
|
|
|
class _MissingProcPath:
|
|
def open(self, *args, **kwargs):
|
|
raise FileNotFoundError("/private/proc/path")
|
|
|
|
class _FakeMemory:
|
|
total = 1000
|
|
available = 250
|
|
percent = 75.0
|
|
|
|
fake_psutil = SimpleNamespace(
|
|
cpu_percent=lambda interval=0.0: 42.25,
|
|
virtual_memory=lambda: _FakeMemory(),
|
|
)
|
|
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
|
monkeypatch.setattr(system_health, "_PROC_STAT", _MissingProcPath())
|
|
monkeypatch.setattr(system_health, "_PROC_MEMINFO", _MissingProcPath())
|
|
|
|
assert system_health._cpu_percent() == 42.2
|
|
assert system_health._memory_usage() == {
|
|
"used_bytes": 750,
|
|
"total_bytes": 1000,
|
|
"percent": 75.0,
|
|
}
|
|
|
|
|
|
def test_system_health_missing_optional_psutil_is_safe_unavailable(monkeypatch):
|
|
from api import system_health
|
|
|
|
class _MissingProcPath:
|
|
def open(self, *args, **kwargs):
|
|
raise FileNotFoundError("/private/proc/path")
|
|
|
|
def missing_psutil(name):
|
|
if name == "psutil":
|
|
raise ModuleNotFoundError("No module named 'psutil'")
|
|
raise AssertionError(f"unexpected import: {name}")
|
|
|
|
monkeypatch.setattr(system_health, "_PROC_STAT", _MissingProcPath())
|
|
monkeypatch.setattr(system_health, "_PROC_MEMINFO", _MissingProcPath())
|
|
monkeypatch.setattr(system_health, "import_module", missing_psutil)
|
|
|
|
for collect in (system_health._cpu_percent, system_health._memory_usage):
|
|
try:
|
|
collect()
|
|
except RuntimeError as exc:
|
|
assert str(exc) == "psutil_unavailable"
|
|
else: # pragma: no cover - defensive regression clarity
|
|
raise AssertionError("missing optional psutil should surface a safe unavailable error")
|
|
|
|
|
|
def test_system_health_procfs_parse_errors_remain_visible(monkeypatch):
|
|
from api import system_health
|
|
|
|
fake_psutil = SimpleNamespace(
|
|
cpu_percent=lambda interval=0.0: 42.25,
|
|
virtual_memory=lambda: SimpleNamespace(total=1000, available=250),
|
|
)
|
|
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
|
monkeypatch.setattr(
|
|
system_health,
|
|
"_read_proc_stat_cpu",
|
|
lambda: (_ for _ in ()).throw(RuntimeError("proc_stat_unavailable")),
|
|
)
|
|
monkeypatch.setattr(system_health, "_read_meminfo_kib", lambda: {})
|
|
|
|
try:
|
|
system_health._cpu_percent()
|
|
except RuntimeError as exc:
|
|
assert str(exc) == "proc_stat_unavailable"
|
|
else: # pragma: no cover - defensive regression clarity
|
|
raise AssertionError("procfs parse RuntimeError should not fall back to psutil")
|
|
|
|
try:
|
|
system_health._memory_usage()
|
|
except RuntimeError as exc:
|
|
assert str(exc) == "meminfo_unavailable"
|
|
else: # pragma: no cover - defensive regression clarity
|
|
raise AssertionError("meminfo invariant RuntimeError should not fall back to psutil")
|
|
|
|
|
|
def test_system_health_cpu_second_procfs_read_fallback_does_not_sleep_twice(monkeypatch):
|
|
from api import system_health
|
|
|
|
calls = []
|
|
|
|
def fake_read_proc_stat_cpu():
|
|
calls.append("proc")
|
|
if len(calls) == 1:
|
|
return (10, 100)
|
|
raise FileNotFoundError("/proc/stat disappeared")
|
|
|
|
def fake_sleep(seconds):
|
|
calls.append(("sleep", seconds))
|
|
|
|
def fake_cpu_percent(interval=0.0):
|
|
calls.append(("psutil", interval))
|
|
return 12.34
|
|
|
|
monkeypatch.setattr(system_health, "_read_proc_stat_cpu", fake_read_proc_stat_cpu)
|
|
monkeypatch.setattr(system_health.time, "sleep", fake_sleep)
|
|
monkeypatch.setitem(sys.modules, "psutil", SimpleNamespace(cpu_percent=fake_cpu_percent))
|
|
|
|
assert system_health._cpu_percent() == 12.3
|
|
assert calls == ["proc", ("sleep", system_health._CPU_SAMPLE_SECONDS), "proc", ("psutil", 0.0)]
|
|
|
|
|
|
def test_system_health_route_registered_and_auth_gated(monkeypatch):
|
|
assert 'parsed.path == "/api/system/health"' in ROUTES_PY
|
|
assert "build_system_health_payload()" in ROUTES_PY
|
|
assert '"/api/system/health"' not in AUTH_PY, "system metrics must not be public"
|
|
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
|
|
from api import auth as _auth
|
|
from api.auth import check_auth
|
|
|
|
# The password hash is cached process-wide (PBKDF2 is ~1s). A prior test may
|
|
# have populated the cache with "no password" (None), so the env var we just
|
|
# set would be ignored on the fast path. Invalidate before AND after so this
|
|
# test sees its own password and doesn't leak the test-password cache to the
|
|
# next test — required for order-independence under sharded/random runs.
|
|
_auth._invalidate_password_hash_cache()
|
|
|
|
handler = _FakeHandler()
|
|
try:
|
|
assert check_auth(handler, SimpleNamespace(path="/api/system/health", query="")) is False
|
|
assert handler.status in (302, 401)
|
|
finally:
|
|
monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False)
|
|
_auth._invalidate_password_hash_cache()
|
|
|
|
|
|
def test_system_health_route_returns_only_sanitized_payload(monkeypatch):
|
|
from api import routes
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"build_system_health_payload",
|
|
lambda: {
|
|
"status": "ok",
|
|
"available": True,
|
|
"checked_at": "2026-05-05T00:00:00+00:00",
|
|
"cpu": {"percent": 12.0},
|
|
"memory": {"used_bytes": 1, "total_bytes": 2, "percent": 50.0},
|
|
"disk": {"used_bytes": 3, "total_bytes": 4, "percent": 75.0},
|
|
"errors": [],
|
|
},
|
|
)
|
|
handler = _FakeHandler()
|
|
assert routes.handle_get(handler, urlparse("http://example.test/api/system/health")) is True
|
|
payload = handler.json_body()
|
|
assert payload["cpu"]["percent"] == 12.0
|
|
assert set(payload) == {"status", "available", "checked_at", "cpu", "memory", "disk", "errors"}
|
|
|
|
|
|
def test_system_health_panel_markup_and_styles_live_under_insights_not_top_chrome():
|
|
top_shell = INDEX_HTML[: INDEX_HTML.index('<div class="layout">')]
|
|
assert 'id="systemHealthPanel"' not in top_shell
|
|
assert 'aria-label="Host resource health"' not in top_shell
|
|
assert 'function _renderSystemHealthPanel()' in PANELS_JS
|
|
assert 'id="systemHealthPanel"' in PANELS_JS
|
|
assert 'aria-label="Host resource health"' in PANELS_JS
|
|
assert 'System health' in PANELS_JS
|
|
assert 'Current VPS resource usage' in PANELS_JS
|
|
assert PANELS_JS.index('_renderSystemHealthPanel()') < PANELS_JS.index('_renderLlmWikiStatus(wikiStatus)')
|
|
assert 'data-system-health-metric="cpu"' in PANELS_JS
|
|
assert 'data-system-health-metric="memory"' in PANELS_JS
|
|
assert 'data-system-health-metric="disk"' in PANELS_JS
|
|
assert ".system-health-panel.insights-card" in STYLE_CSS
|
|
assert ".system-health-bar-fill" in STYLE_CSS
|
|
assert ".system-health-panel.unavailable" in STYLE_CSS
|
|
assert "@media(max-width:640px)" in STYLE_CSS and ".system-health-panel.insights-card" in STYLE_CSS
|
|
|
|
|
|
def test_system_health_frontend_polls_visible_and_renders_progress_labels():
|
|
assert "const SYSTEM_HEALTH_INTERVAL_MS=5000" in UI_JS
|
|
assert "api('/api/system/health',{timeoutToast:false})" in UI_JS
|
|
assert "document.visibilityState !== 'visible'" in UI_JS
|
|
assert "document.querySelector('main.main.showing-insights')" in UI_JS
|
|
assert "document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility)" in UI_JS
|
|
assert "typeof _syncSystemHealthMonitorVisibility === 'function'" in PANELS_JS
|
|
assert "function renderSystemHealth(payload)" in UI_JS
|
|
assert "setSystemHealthUnavailable" in UI_JS
|
|
assert "data-system-health-metric" in PANELS_JS
|
|
assert "CPU" in PANELS_JS and "RAM" in PANELS_JS and "Disk" in PANELS_JS
|
|
assert "aria-valuenow" in UI_JS
|
|
assert "style.width=`${percent}%`" in UI_JS
|
|
|
|
|
|
def test_system_health_backend_uses_no_shell_or_private_process_sources():
|
|
src = (REPO_ROOT / "api" / "system_health.py").read_text(encoding="utf-8")
|
|
assert "import subprocess" not in src
|
|
assert "os.environ" not in src
|
|
assert "ps aux" not in src
|
|
assert "/proc/self/environ" not in src
|
|
for private_field in ("argv", "cmdline", "username", "mountpoint"):
|
|
assert private_field not in src
|
|
|
|
|
|
# #6351 runtime-diagnostics hygiene rule for every test below: a test that
|
|
# touches a process-owned shared mapping copies it by value, performs the
|
|
# clear/seed and the restore inside the mapping's owning lock, and restores the
|
|
# original key order and value identities. Contested locks are always held from
|
|
# a worker thread, which is the only schedule production can produce.
|
|
_LOCK_HOLD_SECONDS = 3.0
|
|
# A nonblocking collector does no I/O, so a second is generous even on a loaded
|
|
# CI box, while staying far enough below the hold that a blocking regression
|
|
# (which waits the full _LOCK_HOLD_SECONDS) cannot slip past.
|
|
_NONBLOCKING_BUDGET_SECONDS = 1.0
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _held_from_worker(lock, hold_seconds=_LOCK_HOLD_SECONDS):
|
|
"""Hold ``lock`` from a worker thread for the body of the with-block."""
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def hold():
|
|
with lock:
|
|
entered.set()
|
|
release.wait(hold_seconds)
|
|
|
|
worker = threading.Thread(target=hold, daemon=True)
|
|
worker.start()
|
|
try:
|
|
# Inside the try so a worker that never acquires still gets released and
|
|
# joined; otherwise it takes the lock after this test fails and starves
|
|
# the next contention test for the full hold.
|
|
assert entered.wait(5), "worker thread never acquired the contested lock"
|
|
yield
|
|
finally:
|
|
release.set()
|
|
worker.join(hold_seconds + 5)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _seeded_mapping(lock, mapping, entries):
|
|
"""Seed a process-owned mapping under its owning lock and restore it exactly."""
|
|
with lock:
|
|
original = dict(mapping)
|
|
mapping.clear()
|
|
mapping.update(entries)
|
|
try:
|
|
yield original
|
|
finally:
|
|
with lock:
|
|
mapping.clear()
|
|
mapping.update(original)
|
|
|
|
|
|
def _seed_channel_counters(
|
|
channel,
|
|
*,
|
|
subscribers=0,
|
|
offline_buffered=0,
|
|
offline_dropped=0,
|
|
subscriber_dropped=0,
|
|
):
|
|
"""Seed a real StreamChannel's diagnostic counters under its own lock."""
|
|
with channel._lock:
|
|
channel._subscribers.extend(queue.Queue() for _ in range(subscribers))
|
|
channel._offline_buffer.extend(
|
|
("token", {"seq": index}, str(index)) for index in range(offline_buffered)
|
|
)
|
|
channel._offline_dropped_total = offline_dropped
|
|
channel._subscriber_dropped_total = subscriber_dropped
|
|
|
|
|
|
def _pin_config_module_cache(monkeypatch):
|
|
"""Let pytest restore the config globals a forced reload or eviction rebinds."""
|
|
from api import config
|
|
|
|
# _LAST_APPLIED_SESSIONS_CACHE_MAX belongs here because a real
|
|
# _evict_sessions_over_cap() pass rewrites it outside monkeypatch's view;
|
|
# pinning first records the true pre-test value.
|
|
for name in (
|
|
"cfg",
|
|
"_cfg_cache",
|
|
"_cfg_mtime",
|
|
"_cfg_path",
|
|
"_cfg_fingerprint",
|
|
"_LAST_APPLIED_SESSIONS_CACHE_MAX",
|
|
):
|
|
monkeypatch.setattr(config, name, getattr(config, name), raising=False)
|
|
|
|
|
|
def _fixed_host_metrics(monkeypatch):
|
|
from api import system_health
|
|
|
|
monkeypatch.setattr(system_health, "_cpu_percent", lambda: 12.0)
|
|
monkeypatch.setattr(
|
|
system_health,
|
|
"_memory_usage",
|
|
lambda: {"used_bytes": 1, "total_bytes": 4, "percent": 25.0},
|
|
)
|
|
monkeypatch.setattr(
|
|
system_health,
|
|
"_disk_usage",
|
|
lambda: {"used_bytes": 4, "total_bytes": 8, "percent": 50.0},
|
|
)
|
|
|
|
|
|
def _assert_host_metrics_intact(payload):
|
|
assert payload["cpu"] == {"percent": 12.0}
|
|
assert payload["memory"] == {"used_bytes": 1, "total_bytes": 4, "percent": 25.0}
|
|
assert payload["disk"] == {"used_bytes": 4, "total_bytes": 8, "percent": 50.0}
|
|
|
|
|
|
def test_runtime_diagnostics_absence_presence_and_privacy(monkeypatch):
|
|
from api import system_health
|
|
|
|
monkeypatch.setattr(system_health, "_cpu_percent", lambda: 1.0)
|
|
monkeypatch.setattr(system_health, "_memory_usage", lambda: {"used_bytes": 1, "total_bytes": 2, "percent": 50})
|
|
monkeypatch.setattr(system_health, "_disk_usage", lambda: {"used_bytes": 1, "total_bytes": 2, "percent": 50})
|
|
payload = system_health.build_system_health_payload()
|
|
assert set(payload["webui_runtime"]) == {"sessions", "streams", "session_list_cache", "models_cache"}
|
|
assert "stream_id" not in repr(payload)
|
|
assert "provider" not in repr(payload)
|
|
assert "secret-model" not in repr(payload)
|
|
assert "private-provider" not in repr(payload)
|
|
|
|
|
|
def test_health_route_completes_while_config_cache_lock_is_held(monkeypatch, tmp_path):
|
|
from api import config, routes
|
|
|
|
_fixed_host_metrics(monkeypatch)
|
|
_pin_config_module_cache(monkeypatch)
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("webui:\n sessions_cache_max: 41\n", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_CONFIG_PATH", str(config_path))
|
|
# Cold cache: get_config() takes the reload branch, which waits on _cfg_lock.
|
|
monkeypatch.setattr(config, "_cfg_cache", {})
|
|
monkeypatch.setattr(config, "_cfg_mtime", 0.0)
|
|
monkeypatch.setattr(config, "_cfg_path", None)
|
|
monkeypatch.setattr(config, "_LAST_APPLIED_SESSIONS_CACHE_MAX", 41, raising=False)
|
|
|
|
seeded = {"health-route-a": object(), "health-route-b": object()}
|
|
with _seeded_mapping(config.LOCK, config.SESSIONS, seeded):
|
|
with _held_from_worker(config._cfg_lock):
|
|
handler = _FakeHandler()
|
|
started = time.monotonic()
|
|
assert routes.handle_get(handler, urlparse("http://example.test/api/system/health")) is True
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < _NONBLOCKING_BUDGET_SECONDS, (
|
|
f"health response waited {elapsed:.3f}s on the config cache lock"
|
|
)
|
|
payload = handler.json_body()
|
|
_assert_host_metrics_intact(payload)
|
|
assert payload["status"] == "ok"
|
|
runtime = payload["webui_runtime"]
|
|
assert runtime["sessions"] == {"available": True, "resident": 2, "cap": 41}
|
|
assert runtime["streams"]["available"] is True
|
|
assert runtime["session_list_cache"]["available"] is True
|
|
assert runtime["session_list_cache"]["cap"] == 64
|
|
assert set(runtime["models_cache"]) == {"available", "groups", "models", "age_seconds"}
|
|
assert "health-route-a" not in repr(payload)
|
|
|
|
|
|
def test_sessions_snapshot_never_resolves_config_or_profile(monkeypatch):
|
|
from api import config, profiles, system_health
|
|
|
|
_fixed_host_metrics(monkeypatch)
|
|
fired = []
|
|
|
|
def trap(*args, **kwargs):
|
|
fired.append(True)
|
|
raise AssertionError("health collection must not resolve config or profile state")
|
|
|
|
monkeypatch.setattr(config, "get_config", trap)
|
|
monkeypatch.setattr(config, "_get_config_path", trap)
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", trap)
|
|
monkeypatch.setattr(config, "_LAST_APPLIED_SESSIONS_CACHE_MAX", 57, raising=False)
|
|
|
|
with _seeded_mapping(config.LOCK, config.SESSIONS, {"trap-probe": object()}):
|
|
payload = system_health.build_system_health_payload()
|
|
|
|
assert fired == []
|
|
assert payload["webui_runtime"]["sessions"] == {"available": True, "resident": 1, "cap": 57}
|
|
assert {"metric": "webui_runtime.sessions", "code": "AssertionError"} not in payload["errors"]
|
|
|
|
|
|
def test_health_route_completes_while_one_real_stream_channel_lock_is_held(monkeypatch):
|
|
from api import config, routes
|
|
|
|
_fixed_host_metrics(monkeypatch)
|
|
healthy = config.StreamChannel()
|
|
_seed_channel_counters(
|
|
healthy,
|
|
subscribers=2,
|
|
offline_buffered=3,
|
|
offline_dropped=4,
|
|
subscriber_dropped=5,
|
|
)
|
|
busy = config.StreamChannel()
|
|
|
|
streams = {"health-open-stream": healthy, "health-busy-stream": busy}
|
|
with _seeded_mapping(config.STREAMS_LOCK, config.STREAMS, streams):
|
|
with _seeded_mapping(config.STREAMS_LOCK, config.AGENT_INSTANCES, {"health-open-stream": object()}):
|
|
with _held_from_worker(busy._lock):
|
|
handler = _FakeHandler()
|
|
started = time.monotonic()
|
|
assert routes.handle_get(handler, urlparse("http://example.test/api/system/health")) is True
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < _NONBLOCKING_BUDGET_SECONDS, (
|
|
f"health response waited {elapsed:.3f}s on one busy stream channel"
|
|
)
|
|
payload = handler.json_body()
|
|
_assert_host_metrics_intact(payload)
|
|
assert payload["status"] == "ok"
|
|
runtime = payload["webui_runtime"]
|
|
assert runtime["streams"] == {
|
|
"available": True,
|
|
"active": 2,
|
|
"agent_instances": 1,
|
|
"subscribers": 2,
|
|
"offline_buffered_events": 3,
|
|
"offline_dropped_events": 4,
|
|
"subscriber_dropped_events": 5,
|
|
"unavailable_channels": 1,
|
|
}
|
|
assert runtime["sessions"]["available"] is True
|
|
assert runtime["session_list_cache"]["available"] is True
|
|
assert set(runtime["models_cache"]) == {"available", "groups", "models", "age_seconds"}
|
|
assert "health-open-stream" not in repr(payload)
|
|
assert "health-busy-stream" not in repr(payload)
|
|
|
|
|
|
def test_stream_channel_try_snapshot_matches_blocking_snapshot():
|
|
from api import config
|
|
|
|
channel = config.StreamChannel()
|
|
_seed_channel_counters(
|
|
channel,
|
|
subscribers=1,
|
|
offline_buffered=2,
|
|
offline_dropped=3,
|
|
subscriber_dropped=4,
|
|
)
|
|
blocking = channel.diagnostic_snapshot()
|
|
nonblocking = channel.try_diagnostic_snapshot()
|
|
assert nonblocking == blocking
|
|
assert set(nonblocking) == {
|
|
"subscriber_count",
|
|
"offline_buffered_events",
|
|
"offline_dropped_events",
|
|
"subscriber_dropped_events",
|
|
}
|
|
assert blocking == {
|
|
"subscriber_count": 1,
|
|
"offline_buffered_events": 2,
|
|
"offline_dropped_events": 3,
|
|
"subscriber_dropped_events": 4,
|
|
}
|
|
|
|
# diagnostic_snapshot() is deliberately not called here: it keeps its
|
|
# blocking contract for /health?deep=1 and would wait out the whole hold.
|
|
with _held_from_worker(channel._lock):
|
|
started = time.monotonic()
|
|
assert channel.try_diagnostic_snapshot() is None
|
|
assert time.monotonic() - started < _NONBLOCKING_BUDGET_SECONDS
|
|
|
|
|
|
def test_sessions_owner_snapshot_preserves_existing_cache_identities(monkeypatch):
|
|
from api import config
|
|
|
|
monkeypatch.setattr(config, "_LAST_APPLIED_SESSIONS_CACHE_MAX", 37, raising=False)
|
|
probe_key = "pre-existing-identity-probe"
|
|
probe = object()
|
|
with config.LOCK:
|
|
config.SESSIONS[probe_key] = probe
|
|
try:
|
|
seeded = {"first": object(), "second": object()}
|
|
with _seeded_mapping(config.LOCK, config.SESSIONS, seeded) as original:
|
|
assert probe_key not in config.SESSIONS
|
|
assert config.get_runtime_diagnostics_snapshot()["sessions"] == {
|
|
"available": True, "resident": 2, "cap": 37
|
|
}
|
|
assert list(config.SESSIONS) == ["first", "second"]
|
|
|
|
assert list(config.SESSIONS) == list(original)
|
|
for key, value in original.items():
|
|
assert config.SESSIONS[key] is value
|
|
assert config.SESSIONS[probe_key] is probe
|
|
finally:
|
|
with config.LOCK:
|
|
config.SESSIONS.pop(probe_key, None)
|
|
|
|
|
|
def test_diagnostics_cap_tracks_the_eviction_owner(monkeypatch, tmp_path):
|
|
from api import config, models
|
|
|
|
_pin_config_module_cache(monkeypatch)
|
|
config_path = tmp_path / "config.yaml"
|
|
monkeypatch.setenv("HERMES_CONFIG_PATH", str(config_path))
|
|
fallback = (
|
|
config.SESSIONS_MAX
|
|
if isinstance(config.SESSIONS_MAX, int) and config.SESSIONS_MAX >= 1
|
|
else config.DEFAULT_SESSIONS_CACHE_MAX
|
|
)
|
|
|
|
for body, expected in (
|
|
("webui:\n sessions_cache_max: 23\n", 23),
|
|
("webui:\n sessions_cache_max: 0\n", fallback),
|
|
("webui: {}\n", fallback),
|
|
):
|
|
config_path.write_text(body, encoding="utf-8")
|
|
monkeypatch.setattr(config, "_cfg_cache", {})
|
|
monkeypatch.setattr(config, "_cfg_path", None)
|
|
assert config.get_sessions_cache_max() == expected
|
|
# Poison the memo so only a real eviction pass can restore it.
|
|
monkeypatch.setattr(config, "_LAST_APPLIED_SESSIONS_CACHE_MAX", -1)
|
|
with _seeded_mapping(config.LOCK, config.SESSIONS, {}):
|
|
with config.LOCK:
|
|
models._evict_sessions_over_cap()
|
|
assert config.get_runtime_diagnostics_snapshot()["sessions"]["cap"] == expected
|
|
|
|
|
|
def test_diagnostics_cap_reports_the_owner_fallback_when_the_getter_raises(monkeypatch):
|
|
from api import config, models, system_health
|
|
|
|
_fixed_host_metrics(monkeypatch)
|
|
_pin_config_module_cache(monkeypatch)
|
|
|
|
def raiser(*args, **kwargs):
|
|
raise RuntimeError("cap resolution failed")
|
|
|
|
# A distinct prior memo, so no assertion below can pass on the incumbent value.
|
|
monkeypatch.setattr(config, "_LAST_APPLIED_SESSIONS_CACHE_MAX", 4321, raising=False)
|
|
# api/models.py resolves the getter through _cfg at call time, so failing the
|
|
# real attribute is what the production lookup hits, not a shadowed copy.
|
|
monkeypatch.setattr(config, "get_sessions_cache_max", raiser)
|
|
# Pinning models' own binding is what proves the published cap came from the
|
|
# enforcer's fallback rather than from anything the resolver returned.
|
|
monkeypatch.setattr(models, "SESSIONS_MAX", 29)
|
|
|
|
with _seeded_mapping(config.LOCK, config.SESSIONS, {}):
|
|
with config.LOCK:
|
|
models._evict_sessions_over_cap()
|
|
snapshot = config.get_runtime_diagnostics_snapshot()
|
|
payload = system_health.build_system_health_payload()
|
|
|
|
assert snapshot["sessions"]["cap"] != 4321
|
|
assert snapshot["sessions"]["cap"] == 29
|
|
assert payload["webui_runtime"]["sessions"]["cap"] == 29
|
|
|
|
|
|
def test_diagnostics_cap_reports_the_normalized_explicit_cap(monkeypatch):
|
|
from api import config, models, system_health
|
|
|
|
_fixed_host_metrics(monkeypatch)
|
|
_pin_config_module_cache(monkeypatch)
|
|
# An explicit cap never reaches the resolver, so only the enforcer can publish.
|
|
monkeypatch.setattr(models, "SESSIONS_MAX", 17)
|
|
|
|
for explicit, expected in ((0, 17), ("nope", 17), (5, 5)):
|
|
monkeypatch.setattr(config, "_LAST_APPLIED_SESSIONS_CACHE_MAX", 4321, raising=False)
|
|
with _seeded_mapping(config.LOCK, config.SESSIONS, {}):
|
|
with config.LOCK:
|
|
models._evict_sessions_over_cap(cap=explicit)
|
|
snapshot = config.get_runtime_diagnostics_snapshot()
|
|
payload = system_health.build_system_health_payload()
|
|
|
|
assert snapshot["sessions"]["cap"] != 4321, explicit
|
|
assert snapshot["sessions"]["cap"] == expected, explicit
|
|
assert payload["webui_runtime"]["sessions"]["cap"] == expected, explicit
|
|
|
|
|
|
def test_the_cap_getter_does_not_publish_diagnostics_state(monkeypatch, tmp_path):
|
|
from api import config
|
|
|
|
_pin_config_module_cache(monkeypatch)
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("webui:\n sessions_cache_max: 88\n", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setattr(config, "_cfg_cache", {})
|
|
monkeypatch.setattr(config, "_cfg_mtime", 0.0)
|
|
monkeypatch.setattr(config, "_cfg_path", None)
|
|
monkeypatch.setattr(config, "_LAST_APPLIED_SESSIONS_CACHE_MAX", 4321, raising=False)
|
|
|
|
assert config.get_sessions_cache_max({"webui": {"sessions_cache_max": 42}}) == 42
|
|
assert config._LAST_APPLIED_SESSIONS_CACHE_MAX == 4321
|
|
assert config.get_sessions_cache_max() == 88
|
|
assert config._LAST_APPLIED_SESSIONS_CACHE_MAX == 4321
|
|
|
|
# Process start: a fresh import must already carry the getter's dict-mode
|
|
# answer for the config it loaded, not the SESSIONS_MAX/default tier. Read in
|
|
# a child process because this one's memo has been rewritten by earlier tests.
|
|
seeded = subprocess.run(
|
|
[sys.executable, "-c", "import api.config as c; print(c._LAST_APPLIED_SESSIONS_CACHE_MAX)"],
|
|
cwd=str(REPO_ROOT),
|
|
env={**os.environ, "HERMES_CONFIG_PATH": str(config_path)},
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=45,
|
|
)
|
|
assert seeded.returncode == 0, seeded.stderr
|
|
printed = seeded.stdout.strip().splitlines()
|
|
assert printed, f"child printed nothing; stderr={seeded.stderr!r}"
|
|
assert int(printed[-1]) == 88
|
|
|
|
|
|
def test_infinite_cap_falls_back_instead_of_raising(tmp_path):
|
|
"""A YAML float infinity must degrade to the bound, not abort the import.
|
|
|
|
``yaml.safe_load`` resolves ``.inf`` to a real float and ``int(float('inf'))``
|
|
raises OverflowError, which is neither TypeError nor ValueError. The cap is
|
|
resolved at module scope, so an uncaught raise here takes down startup.
|
|
"""
|
|
from api import config
|
|
|
|
fallback = (
|
|
config.SESSIONS_MAX
|
|
if isinstance(config.SESSIONS_MAX, int) and config.SESSIONS_MAX >= 1
|
|
else config.DEFAULT_SESSIONS_CACHE_MAX
|
|
)
|
|
for raw in (float("inf"), float("-inf"), float("nan")):
|
|
assert config.get_sessions_cache_max({"webui": {"sessions_cache_max": raw}}) == fallback
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("webui:\n sessions_cache_max: .inf\n", encoding="utf-8")
|
|
booted = subprocess.run(
|
|
[sys.executable, "-c", "import api.config as c; print(c._LAST_APPLIED_SESSIONS_CACHE_MAX)"],
|
|
cwd=str(REPO_ROOT),
|
|
env={**os.environ, "HERMES_CONFIG_PATH": str(config_path)},
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=45,
|
|
)
|
|
assert booted.returncode == 0, booted.stderr
|
|
printed = booted.stdout.strip().splitlines()
|
|
assert printed, f"child printed nothing; stderr={booted.stderr!r}"
|
|
assert int(printed[-1]) == fallback
|
|
|
|
|
|
def test_sessions_lock_busy_does_not_block_sibling_owner():
|
|
from api import config
|
|
|
|
with _held_from_worker(config.LOCK):
|
|
started = time.monotonic()
|
|
snapshot = config.get_runtime_diagnostics_snapshot()
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < _NONBLOCKING_BUDGET_SECONDS
|
|
assert snapshot["sessions"] == {"available": False, "resident": 0, "cap": 0}
|
|
assert "models_cache" in snapshot
|
|
|
|
|
|
def test_health_status_stays_ok_when_sessions_owner_lock_is_busy(monkeypatch):
|
|
from api import config, system_health
|
|
|
|
_fixed_host_metrics(monkeypatch)
|
|
with _held_from_worker(config.LOCK):
|
|
started = time.monotonic()
|
|
payload = system_health.build_system_health_payload()
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < _NONBLOCKING_BUDGET_SECONDS
|
|
assert payload["status"] == "ok"
|
|
assert payload["webui_runtime"]["sessions"]["available"] is False
|
|
assert payload["webui_runtime"]["models_cache"]["available"] is True
|
|
|
|
|
|
def test_models_cache_snapshot_counts_published_groups_without_names():
|
|
from api import config
|
|
|
|
groups = [
|
|
{
|
|
"name": "private-provider",
|
|
"models": [{"id": "secret-model"}],
|
|
"extra_models": [{"id": "overflow-secret"}, {"id": "other"}],
|
|
},
|
|
{"models": [], "extra_models": [{"id": "overflow-two"}]},
|
|
]
|
|
baseline = time.monotonic()
|
|
with config._available_models_cache_lock:
|
|
original_cache = config._available_models_cache
|
|
original_ts = config._available_models_cache_ts
|
|
config._available_models_cache = {"groups": groups}
|
|
config._available_models_cache_ts = baseline - 30.0
|
|
try:
|
|
result = config.get_runtime_diagnostics_snapshot()["models_cache"]
|
|
finally:
|
|
with config._available_models_cache_lock:
|
|
config._available_models_cache = original_cache
|
|
config._available_models_cache_ts = original_ts
|
|
|
|
assert result["available"] is True
|
|
assert result["groups"] == 2
|
|
assert result["models"] == 4
|
|
# Real clock, bounded range: patching time.monotonic would freeze the stdlib
|
|
# clock for every other thread in the process.
|
|
assert 30.0 <= result["age_seconds"] < 30.0 + _NONBLOCKING_BUDGET_SECONDS
|
|
assert "private-provider" not in repr(result)
|
|
assert "secret-model" not in repr(result)
|
|
|
|
|
|
def test_stream_owner_snapshot_aggregates_channels():
|
|
from api import config, streaming
|
|
|
|
first = config.StreamChannel()
|
|
_seed_channel_counters(
|
|
first,
|
|
subscribers=2,
|
|
offline_buffered=3,
|
|
offline_dropped=4,
|
|
subscriber_dropped=5,
|
|
)
|
|
second = config.StreamChannel()
|
|
_seed_channel_counters(second, subscribers=1, subscriber_dropped=7)
|
|
|
|
streams = {"secret-id": first, "other-secret-id": second}
|
|
with _seeded_mapping(config.STREAMS_LOCK, config.STREAMS, streams):
|
|
with _seeded_mapping(config.STREAMS_LOCK, config.AGENT_INSTANCES, {"secret-id": object()}):
|
|
result = streaming.get_stream_runtime_snapshot()
|
|
|
|
assert result == {
|
|
"available": True, "active": 2, "agent_instances": 1,
|
|
"subscribers": 3, "offline_buffered_events": 3,
|
|
"offline_dropped_events": 4, "subscriber_dropped_events": 12,
|
|
"unavailable_channels": 0,
|
|
}
|
|
assert "secret-id" not in repr(result)
|
|
|
|
|
|
def test_stream_registry_lock_busy_isolated():
|
|
from api import config, streaming
|
|
|
|
with _held_from_worker(config.STREAMS_LOCK):
|
|
started = time.monotonic()
|
|
result = streaming.get_stream_runtime_snapshot()
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < _NONBLOCKING_BUDGET_SECONDS
|
|
assert result["available"] is False
|
|
assert result["active"] == 0
|
|
|
|
|
|
def test_session_list_cache_snapshot_reads_scalars_without_lru_mutation():
|
|
from api import route_session_list_cache as cache
|
|
|
|
probe_key = ("pre-existing-identity-probe",)
|
|
probe = (0.0, (), {})
|
|
with cache._SESSIONS_CACHE_LOCK:
|
|
cache._SESSIONS_CACHE[probe_key] = probe
|
|
try:
|
|
with _seeded_mapping(
|
|
cache._SESSIONS_CACHE_LOCK, cache._SESSIONS_CACHE, {("one",): (0.0, (), {})}
|
|
) as original:
|
|
result = cache.get_session_list_cache_snapshot()
|
|
assert result == {"available": True, "entries": 1, "inflight_rebuilds": 0, "cap": 64}
|
|
assert list(cache._SESSIONS_CACHE) == [("one",)]
|
|
|
|
assert list(cache._SESSIONS_CACHE) == list(original)
|
|
assert cache._SESSIONS_CACHE[probe_key] is probe
|
|
finally:
|
|
with cache._SESSIONS_CACHE_LOCK:
|
|
cache._SESSIONS_CACHE.pop(probe_key, None)
|
|
|
|
|
|
def test_runtime_unexpected_exception_fail_open(monkeypatch):
|
|
from api import config, system_health
|
|
|
|
monkeypatch.setattr(system_health, "_cpu_percent", lambda: 1.0)
|
|
monkeypatch.setattr(config, "get_runtime_diagnostics_snapshot", lambda: (_ for _ in ()).throw(RecursionError("secret")))
|
|
payload = system_health.build_system_health_payload()
|
|
assert payload["status"] == "partial"
|
|
assert payload["webui_runtime"]["sessions"]["available"] is False
|
|
assert {"metric": "webui_runtime.sessions", "code": "RecursionError"} in payload["errors"]
|
|
assert "secret" not in repr(payload)
|
|
|
|
|
|
def test_runtime_owner_failure_preserves_sibling_metrics(monkeypatch):
|
|
from api import config, route_session_list_cache, streaming, system_health
|
|
|
|
monkeypatch.setattr(config, "get_runtime_diagnostics_snapshot", lambda: (_ for _ in ()).throw(RecursionError("secret config")))
|
|
monkeypatch.setattr(streaming, "get_stream_runtime_snapshot", lambda: {"available": True, "active": 4, "agent_instances": 3, "subscribers": 0, "offline_buffered_events": 0, "offline_dropped_events": 0, "subscriber_dropped_events": 0, "unavailable_channels": 0})
|
|
monkeypatch.setattr(route_session_list_cache, "get_session_list_cache_snapshot", lambda: {"available": True, "entries": 2, "inflight_rebuilds": 0, "cap": 64})
|
|
payload = system_health.build_system_health_payload()
|
|
runtime = payload["webui_runtime"]
|
|
assert runtime["sessions"]["available"] is False
|
|
assert runtime["models_cache"]["available"] is False
|
|
assert runtime["streams"]["active"] == 4
|
|
assert runtime["session_list_cache"]["entries"] == 2
|
|
assert "secret config" not in repr(payload)
|
|
|
|
|
|
def test_config_owner_collected_once_per_health_payload(monkeypatch):
|
|
from api import config, system_health
|
|
|
|
_fixed_host_metrics(monkeypatch)
|
|
real = config.get_runtime_diagnostics_snapshot
|
|
calls = []
|
|
|
|
def counting_wrapper():
|
|
calls.append(True)
|
|
snapshot = real()
|
|
# Successive calls disagree, so a second collection would show up as a
|
|
# sessions/models_cache skew in the response.
|
|
snapshot["sessions"]["resident"] = 10 * len(calls)
|
|
snapshot["models_cache"]["groups"] = 10 * len(calls)
|
|
return snapshot
|
|
|
|
monkeypatch.setattr(config, "get_runtime_diagnostics_snapshot", counting_wrapper)
|
|
payload = system_health.build_system_health_payload()
|
|
|
|
assert len(calls) == 1
|
|
runtime = payload["webui_runtime"]
|
|
assert runtime["sessions"]["resident"] == 10
|
|
assert runtime["models_cache"]["groups"] == 10
|
|
|
|
|
|
def test_webui_runtime_compositor_failure_preserves_host_metrics(monkeypatch):
|
|
from api import system_health
|
|
|
|
_fixed_host_metrics(monkeypatch)
|
|
|
|
def boom(errors):
|
|
raise RecursionError("secret compositor path")
|
|
|
|
monkeypatch.setattr(system_health, "_webui_runtime_payload", boom)
|
|
payload = system_health.build_system_health_payload()
|
|
|
|
_assert_host_metrics_intact(payload)
|
|
assert payload["status"] == "partial"
|
|
assert payload["webui_runtime"] == system_health._zero_webui_runtime_payload()
|
|
assert {"metric": "webui_runtime", "code": "RecursionError"} in payload["errors"]
|
|
assert "secret compositor path" not in repr(payload)
|
|
|
|
|
|
def test_cancel_cleanup_updates_runtime_diagnostics():
|
|
from api import config, streaming
|
|
|
|
stream_id = "production-cleanup-stream"
|
|
session_id = "production-cleanup-session"
|
|
agent = Mock()
|
|
agent.session_id = session_id
|
|
session = SimpleNamespace(
|
|
session_id=session_id,
|
|
active_stream_id=stream_id,
|
|
pending_user_message=None,
|
|
pending_user_source=None,
|
|
pending_attachments=[],
|
|
pending_started_at=None,
|
|
messages=[],
|
|
save=Mock(),
|
|
)
|
|
with contextlib.ExitStack() as stack:
|
|
stack.enter_context(
|
|
_seeded_mapping(config.STREAMS_LOCK, config.STREAMS, {stream_id: config.StreamChannel()})
|
|
)
|
|
stack.enter_context(
|
|
_seeded_mapping(config.STREAMS_LOCK, config.AGENT_INSTANCES, {stream_id: agent})
|
|
)
|
|
stack.enter_context(
|
|
_seeded_mapping(config.STREAMS_LOCK, config.CANCEL_FLAGS, {stream_id: threading.Event()})
|
|
)
|
|
stack.enter_context(_seeded_mapping(config.ACTIVE_RUNS_LOCK, config.ACTIVE_RUNS, {}))
|
|
before = streaming.get_stream_runtime_snapshot()
|
|
with patch("api.streaming.get_session", return_value=session):
|
|
assert streaming.cancel_stream(stream_id) is True
|
|
after = streaming.get_stream_runtime_snapshot()
|
|
|
|
assert before["active"] == 1
|
|
assert before["agent_instances"] == 1
|
|
assert after["active"] == 0
|
|
assert after["agent_instances"] == 0
|
|
agent.interrupt.assert_called_once_with("Cancelled by user")
|
|
|
|
|
|
def test_models_cache_lock_busy():
|
|
from api import config
|
|
|
|
with _held_from_worker(config._available_models_cache_lock):
|
|
started = time.monotonic()
|
|
snapshot = config.get_runtime_diagnostics_snapshot()
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < _NONBLOCKING_BUDGET_SECONDS
|
|
assert snapshot["models_cache"]["available"] is False
|
|
|
|
|
|
def test_session_list_cache_lock_busy():
|
|
from api import route_session_list_cache as cache
|
|
|
|
with _held_from_worker(cache._SESSIONS_CACHE_LOCK):
|
|
started = time.monotonic()
|
|
result = cache.get_session_list_cache_snapshot()
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < _NONBLOCKING_BUDGET_SECONDS
|
|
assert result["available"] is False
|
|
|
|
|
|
def test_stream_snapshot_failure():
|
|
from api import config, streaming
|
|
|
|
class _FailingChannel(config.StreamChannel):
|
|
def try_diagnostic_snapshot(self):
|
|
raise RecursionError("secret path")
|
|
|
|
healthy = config.StreamChannel()
|
|
_seed_channel_counters(
|
|
healthy,
|
|
subscribers=2,
|
|
offline_buffered=3,
|
|
offline_dropped=4,
|
|
subscriber_dropped=5,
|
|
)
|
|
streams = {"healthy-secret-id": healthy, "failing-secret-id": _FailingChannel()}
|
|
with _seeded_mapping(config.STREAMS_LOCK, config.STREAMS, streams):
|
|
with _seeded_mapping(config.STREAMS_LOCK, config.AGENT_INSTANCES, {"healthy-secret-id": object()}):
|
|
result = streaming.get_stream_runtime_snapshot()
|
|
|
|
assert result == {
|
|
"available": True, "active": 2, "agent_instances": 1,
|
|
"subscribers": 2, "offline_buffered_events": 3,
|
|
"offline_dropped_events": 4, "subscriber_dropped_events": 5,
|
|
"unavailable_channels": 1,
|
|
}
|
|
rendered = repr(result)
|
|
assert "secret path" not in rendered
|
|
assert "RecursionError" not in rendered
|
|
assert "secret-id" not in rendered
|
|
|
|
|
|
def test_runtime_route_auth_and_privacy(monkeypatch):
|
|
from api import auth as _auth
|
|
from api.auth import check_auth
|
|
from api import system_health
|
|
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
|
|
_auth._invalidate_password_hash_cache()
|
|
handler = _FakeHandler()
|
|
try:
|
|
assert check_auth(handler, SimpleNamespace(path="/api/system/health", query="")) is False
|
|
assert handler.status in (302, 401)
|
|
finally:
|
|
monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False)
|
|
_auth._invalidate_password_hash_cache()
|
|
|
|
monkeypatch.setattr(system_health, "_cpu_percent", lambda: 1.0)
|
|
payload = system_health.build_system_health_payload()
|
|
assert payload["webui_runtime"]["streams"].keys() >= {
|
|
"available", "active", "agent_instances", "subscribers"
|
|
}
|
|
assert all(key not in repr(payload) for key in ("stream-id", "session-id", "secret-model"))
|
|
|
|
|
|
def test_runtime_owner_adapter_routing(monkeypatch):
|
|
from api import config, route_session_list_cache, streaming, system_health
|
|
|
|
monkeypatch.setattr(config, "get_runtime_diagnostics_snapshot", lambda: {
|
|
"sessions": {"available": True, "resident": 9, "cap": 11},
|
|
"models_cache": {"available": True, "groups": 3, "models": 4, "age_seconds": 5},
|
|
})
|
|
monkeypatch.setattr(streaming, "get_stream_runtime_snapshot", lambda: {
|
|
"available": True, "active": 7, "agent_instances": 6, "subscribers": 5,
|
|
"offline_buffered_events": 4, "offline_dropped_events": 3,
|
|
"subscriber_dropped_events": 2, "unavailable_channels": 1,
|
|
})
|
|
monkeypatch.setattr(route_session_list_cache, "get_session_list_cache_snapshot", lambda: {
|
|
"available": True, "entries": 8, "inflight_rebuilds": 1, "cap": 64,
|
|
})
|
|
runtime = system_health._webui_runtime_payload([])
|
|
assert runtime["sessions"]["resident"] == 9
|
|
assert runtime["streams"]["active"] == 7
|
|
assert runtime["session_list_cache"]["entries"] == 8
|
|
|
|
|
|
def test_runtime_diagnostics_ignore_profile_storage(monkeypatch, tmp_path):
|
|
from api import config, profiles, system_health
|
|
|
|
_fixed_host_metrics(monkeypatch)
|
|
hermes_home = tmp_path / ".hermes"
|
|
leftover_profile = hermes_home / "profiles" / "work"
|
|
(leftover_profile / "sessions").mkdir(parents=True)
|
|
(leftover_profile / "config.yaml").write_text(
|
|
"webui:\n sessions_cache_max: 7\n", encoding="utf-8"
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
fired = []
|
|
|
|
def trap(*args, **kwargs):
|
|
fired.append(True)
|
|
raise AssertionError("health collection must not resolve profile storage")
|
|
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", trap)
|
|
monkeypatch.setattr(config, "_get_config_path", trap)
|
|
|
|
payload = system_health.build_system_health_payload()
|
|
|
|
assert fired == []
|
|
assert payload["status"] in {"ok", "partial"}
|
|
rendered = repr(payload)
|
|
assert "profiles" not in rendered
|
|
assert str(hermes_home) not in rendered
|
|
assert "work" not in rendered
|