From 414c12a40db07b941b0c589674bc6aad3f090ff7 Mon Sep 17 00:00:00 2001 From: 0-CYBERDYNE-SYSTEMS-0 Date: Sat, 27 Jun 2026 20:22:24 -0700 Subject: [PATCH] fix(dashboard): keep session DB reads off event loop --- hermes_cli/web_server.py | 33 ++++++++------ .../test_web_server_boot_handshake.py | 44 +++++++++++++++++++ 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 308e5f697b8..6fd00bc189d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1910,6 +1910,22 @@ async def fs_default_cwd(): return {"cwd": cwd, "branch": _fs_git_branch(cwd)} +def _count_active_sessions() -> int: + from hermes_state import SessionDB + + db = SessionDB() + try: + sessions = db.list_sessions_rich(limit=50) + now = time.time() + return sum( + 1 for s in sessions + if s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) + finally: + db.close() + + @app.get("/api/status") async def get_status(profile: Optional[str] = None): status_scope = None @@ -2008,18 +2024,7 @@ async def get_status(profile: Optional[str] = None): active_sessions = 0 try: - from hermes_state import SessionDB - db = SessionDB() - try: - sessions = db.list_sessions_rich(limit=50) - now = time.time() - active_sessions = sum( - 1 for s in sessions - if s.get("ended_at") is None - and (now - s.get("last_active", s.get("started_at", 0))) < 300 - ) - finally: - db.close() + active_sessions = await asyncio.to_thread(_count_active_sessions) except Exception: pass @@ -3137,7 +3142,7 @@ async def get_action_status(name: str, lines: int = 200): @app.get("/api/sessions") -async def get_sessions( +def get_sessions( limit: int = 20, offset: int = 0, min_messages: int = 0, @@ -3226,7 +3231,7 @@ async def get_sessions( @app.get("/api/profiles/sessions") -async def get_profiles_sessions( +def get_profiles_sessions( limit: int = 20, offset: int = 0, min_messages: int = 0, diff --git a/tests/hermes_cli/test_web_server_boot_handshake.py b/tests/hermes_cli/test_web_server_boot_handshake.py index 4ca82e9f626..01b9b649d62 100644 --- a/tests/hermes_cli/test_web_server_boot_handshake.py +++ b/tests/hermes_cli/test_web_server_boot_handshake.py @@ -29,6 +29,7 @@ from unittest.mock import patch import pytest import hermes_cli.web_server as web_server_mod +import hermes_state SLOW_SECONDS = 3 # represents the Defender worst-case (scaled down for CI speed) @@ -186,3 +187,46 @@ def test_concurrent_status_probes_all_respond(): f"{len(failed)}/{PROBES} probes failed (codes: {responses}). " f"This would cause WinError 10054 and orphan accumulation on desktop." ) + + +def test_status_session_count_does_not_block_event_loop(monkeypatch): + import httpx + + class SlowSessionDB: + def list_sessions_rich(self, limit: int = 50): + time.sleep(SLOW_SECONDS) + return [] + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", SlowSessionDB) + results: dict[str, float | int] = {} + + async def _run(): + transport = httpx.ASGITransport(app=web_server_mod.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + scenario_started = time.perf_counter() + async with asyncio.TaskGroup() as tg: + async def _status(): + t = time.perf_counter() + r = await client.get("/api/status", timeout=SLOW_SECONDS + 5) + results["status_ms"] = (time.perf_counter() - t) * 1000 + results["status_code"] = r.status_code + + async def _version(): + await asyncio.sleep(0.1) + t = time.perf_counter() + r = await client.get("/api/version", timeout=5) + results["version_ms"] = (time.perf_counter() - t) * 1000 + results["version_elapsed_ms"] = (time.perf_counter() - scenario_started) * 1000 + results["version_code"] = r.status_code + + tg.create_task(_status()) + tg.create_task(_version()) + + asyncio.run(_run()) + + assert results.get("version_code") in {200, 401} + assert results.get("status_code") == 200 + assert results["version_elapsed_ms"] < SLOW_SECONDS * 1000