feat(pty): periodic reaper wired into dashboard lifespan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Benn Denton 2026-06-21 08:48:21 +00:00 committed by Teknium
parent e10e4bca82
commit c3d2be073a
3 changed files with 36 additions and 1 deletions

View file

@ -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:

View file

@ -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=<token>`` 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,

View file

@ -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