From c3d2be073a10ee2ed44cf8202361120d0531371e Mon Sep 17 00:00:00 2001 From: Benn Denton <80194363+TinkerOfThings@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:48:21 +0000 Subject: [PATCH] feat(pty): periodic reaper wired into dashboard lifespan Co-Authored-By: Claude Opus 4.8 --- hermes_cli/pty_session.py | 10 ++++++++++ hermes_cli/web_server.py | 7 ++++++- tests/test_pty_session.py | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py index 1a6e9b78bd2..43910be9deb 100644 --- a/hermes_cli/pty_session.py +++ b/hermes_cli/pty_session.py @@ -126,6 +126,16 @@ class RegistryFull(Exception): pass +async def run_reaper(registry: "PtySessionRegistry", *, interval: float = 60.0) -> None: + """Periodically reap idle/dead keep-alive sessions. Cancelled on shutdown.""" + while True: + await asyncio.sleep(interval) + try: + await registry.reap_idle() + except Exception: + pass + + class PtySessionRegistry: def __init__(self, *, ttl: float, max_sessions: int, buffer_cap: int, read_timeout: float) -> None: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 325d9353de1..07a5ef619b4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -204,9 +204,14 @@ async def _lifespan(app: "FastAPI"): ) cron_thread.start() + # Reap idle/dead keep-alive PTY sessions in the background (30-min TTL). + pty_reaper_task = asyncio.create_task(run_reaper(PTY_REGISTRY)) + try: yield finally: + pty_reaper_task.cancel() + await PTY_REGISTRY.close_all() if cron_stop is not None: cron_stop.set() @@ -12543,7 +12548,7 @@ _PTY_READ_CHUNK_TIMEOUT = 0.2 # Keep-alive PTY sessions: a terminal connecting with ``?attach=`` is # bound to a process that survives disconnect/refresh and is reattachable. -from hermes_cli.pty_session import PtySessionRegistry, RegistryFull # noqa: E402 +from hermes_cli.pty_session import PtySessionRegistry, RegistryFull, run_reaper # noqa: E402 PTY_REGISTRY = PtySessionRegistry( ttl=30 * 60, diff --git a/tests/test_pty_session.py b/tests/test_pty_session.py index f291bef134c..4fcce10c1a8 100644 --- a/tests/test_pty_session.py +++ b/tests/test_pty_session.py @@ -160,3 +160,23 @@ async def test_new_key_at_capacity_raises_when_none_reapable(): with pytest.raises(RegistryFull): await reg.attach_or_spawn("b", spawn=lambda: FakeBridge([])) await reg.close_all() + + +@pytest.mark.asyncio +async def test_reaper_loop_invokes_reap(monkeypatch): + from hermes_cli.pty_session import run_reaper + reg = make_registry() + calls = {"n": 0} + + async def fake_reap(now=None): + calls["n"] += 1 + + monkeypatch.setattr(reg, "reap_idle", fake_reap) + task = asyncio.create_task(run_reaper(reg, interval=0.01)) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + assert calls["n"] >= 2