## Release v0.51.256 — Release HX (stage-r4) Performance — bound WebUI memory growth & idle CPU on large installs. ### Fixed | Issue | Author | Fix | |-------|--------|-----| | #3506 | @nesquena-hermes (reported w/ profiling by @djenttleman) | On a large install (~615 sessions / 40k messages / 454 MB state.db) the WebUI process climbed ~100 MB → ~1.5 GB RSS over days and held high idle CPU. Three root causes fixed: (1) `session_lifecycle._sessions` grew unbounded → new `discard_session()` drops the entry at agent-eviction boundaries, only when no in-flight commit / no uncommitted memory work (retry invariant preserved); (2) cache caps now operator-tunable (`HERMES_WEBUI_AGENT_CACHE_MAX` default 50→25, `HERMES_WEBUI_SESSIONS_MAX`); (3) GatewayWatcher computes a cheap fingerprint before the expensive per-session `MAX(messages.timestamp)` projection and only re-projects on change. | ### Rebase + review notes - Rebased onto current master; the code diff was verified **byte-identical to the nesquena-APPROVED head** at rebase time (only CHANGELOG re-resolved). - The Codex regression gate then surfaced **two correctness gaps** the approval didn't catch, both fixed here with regression tests: 1. **Watcher fingerprint missed same-count transcript rewrites.** `/retry`,`/undo`,`/compress` (`SessionDB.replace_messages`) rewrite messages with new timestamps but can leave `message_count` unchanged → stale sidebar `last_activity`. Fixed with a **per-session** grouped message aggregate (`id, count, user_count, MAX(timestamp)`) over the same non-excluded sessions (a global MAX would miss a rewrite of an older, non-newest session); cron/webui stay excluded so idle churn still doesn't re-project. 2. **LRU agent-cache eviction could close a live worker's agent** (`popitem(last=False)`, liveness-blind — pre-existing, but the lower 50→25 cap made it more likely). Eviction now snapshots `ACTIVE_RUNS` session_ids (before the cache lock — no nested lock) and skips live sessions, deferring (temporarily exceeding cap) rather than closing a live agent. ### Gate - Full pytest suite: **7612 passed, 0 failed** (one boot-cascade flake re-run; clean on re-run) - ruff: CLEAN · Codex (regression): 4 rounds → both gaps + a stale test fixed → **SAFE TO SHIP** Co-authored-by: nesquena <nesquena@users.noreply.github.com>
228 lines
9.2 KiB
Python
228 lines
9.2 KiB
Python
"""Regression tests for v0.50.259 — SessionDB FD leak fixes from PR #1421
|
|
plus Opus pre-release advisor follow-up extending the fix to LRU eviction.
|
|
|
|
The bug: `_run_agent_streaming` created a new `SessionDB` per request and
|
|
replaced the cached agent's `_session_db` without closing the old one.
|
|
After ~73 messages on a long-lived agent, leaked FDs exhausted the 256 FD
|
|
default limit causing `EMFILE` crashes.
|
|
|
|
Fix path 1 (PR #1421 by @wali-reheman): close `agent._session_db` before
|
|
replacing it on the cached-agent reuse path.
|
|
|
|
Fix path 2 (this PR Opus follow-up): same shape on the LRU eviction path.
|
|
When `SESSION_AGENT_CACHE.popitem(last=False)` evicts an old agent, its
|
|
`_session_db` is dropped on the floor and only released when GC eventually
|
|
finalizes the agent — which on a long-running server may be never.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
# ── 1: source-level pin: cached-agent-reuse path closes _session_db ─────────
|
|
|
|
|
|
def test_cached_agent_reuse_closes_old_session_db():
|
|
"""The cached-agent reuse path in `_run_agent_streaming` MUST close the
|
|
old `_session_db` before replacing it. Without this, every streaming
|
|
request leaks a SessionDB connection (3 FDs once WAL is active)."""
|
|
src = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
|
|
|
# Find the cached-agent reuse block (where callbacks are refreshed).
|
|
# The replacement of agent._session_db must be preceded by a close().
|
|
reuse_idx = src.find("Refresh per-turn callbacks")
|
|
assert reuse_idx != -1, "cached-agent reuse block missing"
|
|
block = src[reuse_idx : reuse_idx + 2500]
|
|
|
|
# Must close before replace.
|
|
assert "agent._session_db.close()" in block, (
|
|
"cached-agent reuse path must call agent._session_db.close() before "
|
|
"replacing it. Without this, FDs leak on every streaming request "
|
|
"and the server EMFILE-crashes after ~73 messages. See PR #1421."
|
|
)
|
|
# And the close must come BEFORE the replacement (lexically).
|
|
close_idx = block.find("agent._session_db.close()")
|
|
replace_idx = block.find("agent._session_db = _session_db")
|
|
assert close_idx != -1 and replace_idx != -1
|
|
assert close_idx < replace_idx, (
|
|
"close() must lexically precede the assignment so the old connection "
|
|
"is released before the reference is rebound."
|
|
)
|
|
|
|
|
|
# ── 2: source-level pin: LRU eviction path also closes _session_db ──────────
|
|
|
|
|
|
def test_lru_eviction_closes_evicted_agent_session_db():
|
|
"""SAME LEAK SHAPE on the LRU eviction path: when SESSION_AGENT_CACHE
|
|
grows beyond SESSION_AGENT_CACHE_MAX (default 25), the LRU agent gets popped
|
|
via `popitem(last=False)`. Without explicit close, its `_session_db` waits
|
|
on GC finalization which may never run on a long-lived server.
|
|
|
|
Fix: capture the evicted entry, close its agent's `_session_db` before
|
|
dropping the reference.
|
|
"""
|
|
src = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
|
|
|
# The eviction site uses popitem(last=False). The evicted entry must be
|
|
# captured (not discarded with `_`) and the agent's _session_db closed.
|
|
eviction_idx = src.find("Evicted LRU agent from cache")
|
|
assert eviction_idx != -1, "LRU eviction debug log missing"
|
|
# Look in a window around the eviction.
|
|
block = src[max(0, eviction_idx - 1500) : eviction_idx + 200]
|
|
|
|
# Negative pattern: the old `evicted_sid, _ = ...` discard form must NOT
|
|
# be present — that's the bug shape.
|
|
assert "evicted_sid, _ = SESSION_AGENT_CACHE.popitem" not in block, (
|
|
"LRU eviction must capture the evicted entry so the agent\'s "
|
|
"_session_db can be closed. The `evicted_sid, _ = ...` discard form "
|
|
"is the original bug shape."
|
|
)
|
|
|
|
# Positive pattern: eviction must call the lifecycle helper, and the helper
|
|
# must close the evicted agent's SessionDB after provider teardown.
|
|
assert "_close_evicted_agent_at_session_boundary(_evicted_sid, _evicted_agent)" in block, (
|
|
"LRU eviction must route the evicted agent through the session-boundary "
|
|
"close helper."
|
|
)
|
|
helper_start = src.index("def _close_evicted_agent_at_session_boundary")
|
|
helper_end = src.index("\ndef _refresh_cached_agent_runtime", helper_start)
|
|
helper_block = src[helper_start:helper_end]
|
|
assert "session_db.close()" in helper_block, (
|
|
"LRU eviction helper must close the evicted agent's _session_db. "
|
|
"(Opus pre-release follow-up to PR #1421.)"
|
|
)
|
|
|
|
|
|
# ── 3: behavioral: SessionDB.close() is idempotent + safe ──────────────────
|
|
|
|
|
|
def test_session_db_close_is_idempotent():
|
|
"""`SessionDB.close()` must be safe to call multiple times. The fix
|
|
relies on this — if a future code path closes the same `_session_db`
|
|
after we've swapped it, the second close is a benign no-op.
|
|
|
|
Skipped when hermes_state is not on the import path (e.g. on the GH
|
|
Actions runner that only has the WebUI repo, not the agent repo).
|
|
The source-level pin in test_cached_agent_reuse_closes_old_session_db
|
|
catches revert of the close() call; this test only adds runtime
|
|
confirmation when both repos are co-located.
|
|
"""
|
|
import importlib.util
|
|
if importlib.util.find_spec("hermes_state") is None:
|
|
pytest.skip("hermes_state not on import path (CI-only — agent repo not present)")
|
|
from hermes_state import SessionDB # type: ignore
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmpd:
|
|
db_path = Path(tmpd) / "test.db"
|
|
db = SessionDB(db_path=db_path)
|
|
# Force connection open by issuing a query.
|
|
with db._lock:
|
|
db._conn.execute("SELECT 1")
|
|
# First close
|
|
db.close()
|
|
assert db._conn is None
|
|
# Second close — must not raise.
|
|
db.close()
|
|
assert db._conn is None
|
|
# Third close — still safe.
|
|
db.close()
|
|
|
|
|
|
# ── 4: behavioral: cached-agent reuse with mock agent ───────────────────────
|
|
|
|
|
|
def test_cached_agent_reuse_calls_close_on_old_session_db():
|
|
"""End-to-end: simulate the cached-agent reuse code path with a mock
|
|
agent and verify the mock SessionDB.close() is called when _session_db
|
|
is replaced. Pins the runtime behavior, not just the source pattern."""
|
|
import sys
|
|
sys.path.insert(0, str(REPO))
|
|
|
|
class MockSessionDB:
|
|
def __init__(self, name):
|
|
self.name = name
|
|
self.close_calls = 0
|
|
def close(self):
|
|
self.close_calls += 1
|
|
|
|
class MockAgent:
|
|
def __init__(self, db):
|
|
self._session_db = db
|
|
self.stream_delta_callback = None
|
|
self.tool_progress_callback = None
|
|
self._api_call_count = 0
|
|
self._interrupted = False
|
|
self._interrupt_message = None
|
|
|
|
# Simulate the inner block of the cached-agent reuse path. We replicate
|
|
# the pattern manually rather than importing _run_agent_streaming
|
|
# directly because that function has many other side effects.
|
|
old_db = MockSessionDB("old")
|
|
new_db = MockSessionDB("new")
|
|
agent = MockAgent(old_db)
|
|
|
|
# Mirror the production code path:
|
|
if hasattr(agent, "_session_db") and agent._session_db is not None:
|
|
try:
|
|
agent._session_db.close()
|
|
except Exception:
|
|
pass
|
|
agent._session_db = new_db
|
|
|
|
assert old_db.close_calls == 1, (
|
|
"old SessionDB must be closed exactly once when replaced on cached agent"
|
|
)
|
|
assert new_db.close_calls == 0, "new SessionDB should not be closed"
|
|
assert agent._session_db is new_db
|
|
|
|
|
|
# ── 5: behavioral: LRU eviction with mock agents ────────────────────────────
|
|
|
|
|
|
def test_lru_eviction_closes_evicted_session_db():
|
|
"""End-to-end: simulate LRU eviction and verify the evicted agent's
|
|
SessionDB.close() is called."""
|
|
import collections
|
|
|
|
class MockSessionDB:
|
|
def __init__(self, name):
|
|
self.name = name
|
|
self.close_calls = 0
|
|
def close(self):
|
|
self.close_calls += 1
|
|
|
|
class MockAgent:
|
|
def __init__(self, db):
|
|
self._session_db = db
|
|
|
|
cache = collections.OrderedDict()
|
|
db1, db2, db3 = MockSessionDB("a"), MockSessionDB("b"), MockSessionDB("c")
|
|
cache["sid-a"] = (MockAgent(db1), "sig1")
|
|
cache["sid-b"] = (MockAgent(db2), "sig2")
|
|
cache["sid-c"] = (MockAgent(db3), "sig3")
|
|
|
|
# Mirror the production eviction path with MAX=2:
|
|
MAX = 2
|
|
while len(cache) > MAX:
|
|
evicted_sid, evicted_entry = cache.popitem(last=False)
|
|
try:
|
|
_evicted_agent = evicted_entry[0] if isinstance(evicted_entry, tuple) else None
|
|
if _evicted_agent is not None and getattr(_evicted_agent, "_session_db", None) is not None:
|
|
_evicted_agent._session_db.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# First-inserted entry (sid-a) was evicted.
|
|
assert "sid-a" not in cache
|
|
assert db1.close_calls == 1, "evicted agent's SessionDB must be closed exactly once"
|
|
assert db2.close_calls == 0, "remaining agents' SessionDBs must not be touched"
|
|
assert db3.close_calls == 0
|