Files
hermes-webui/tests/test_issue1494_state_db_fd_leak.py
Anthony Ruiz ef9341b2ab fix(sqlite): keep state.db readers strictly read-only and stop idle polling
Readers never upgrade to writers. open_state_db_readonly() opens
file:...?mode=ro only; a read-only open failure propagates instead of
silently retrying with a write-capable handle on the multi-GiB WAL
database the agent is streaming into. The session listing no longer
self-heals idx_messages_session with CREATE INDEX (a writer lock held for
minutes on a large messages table) — a missing index degrades to the
pre-aggregated path. Cron sidebar info, insights and deep health checks in
api/routes.py use the same read-only opener.

Watcher (api/gateway_watcher.py): _poll_once returns without touching the
database when nobody is subscribed, and forces a fresh full projection on
the next subscription.

Maintenance (scripts/ensure_state_db_read_indexes.py): the covering read
indexes (idx_messages_session, idx_messages_session_role with NOCASE,
idx_sessions_webui_fingerprint) are created only under --confirm-drained,
optionally holding an exclusive flock on --lock-file for deployments that
serialise agent turns on a lock file; existing indexes are verified for
shape/collation and coverage (EXPLAIN QUERY PLAN), never silently
replaced.

Tests: tests/test_state_db_readonly_readers.py (read-only failure never
retries writable; no poll without subscribers; listing creates no index;
index tool requires drain, is idempotent, works without a lock file and
refuses a held lock; cron sidebar read is mode=ro). Neighbouring tests
that asserted the retired writable fallback / self-heal writer
(test_issue3887_index_prime, test_issue5455_listing_readonly_connection,
test_issue5455_lineage_readonly_reads, test_issue2628_cli_sessions_perf,
test_issue1494_state_db_fd_leak) now assert the read-only contract;
test_issue3506 subscribes before polling.
2026-09-18 00:59:17 +00:00

237 lines
8.5 KiB
Python

"""Regression test for #1494: state.db connection FD leak via context-manager use.
The bug: Python's sqlite3 connection context manager (`with sqlite3.connect(...) as
conn:`) commits or rolls back on exit. It does NOT close the connection. In a
long-running server with sidebar polling (`/api/sessions` calls
`read_importable_agent_session_rows` and `read_session_lineage_metadata` on every
poll), every poll leaked one or more open file descriptors against `~/.hermes/state.db`.
In production this drove the WebUI process past macOS's 256-FD soft limit, after
which new requests reset before producing a response (see #1458, #1494) — the
process stayed alive, the port stayed listening, but every connection RST'd because
sqlite3.connect() in a freshly accepted handler raised on FD exhaustion before any
bytes were written.
The fix wraps each `sqlite3.connect(...)` in `contextlib.closing(...)` so the
connection is explicitly closed on scope exit (in addition to the auto-commit /
rollback semantics).
This file pins all four production callsites the issue reporter (insecurejezza)
audited as still leaking on master @ 7fddc33:
* api/agent_sessions.py:read_importable_agent_session_rows
* api/agent_sessions.py:read_session_lineage_metadata
* api/models.py:get_cli_session_messages
* api/models.py:delete_cli_session
Each test monkeypatches sqlite3.connect to track every connection the function
opens, then asserts every connection is .close()'d after the call returns.
"""
import sqlite3
import pytest
def _make_state_db(path):
"""Minimal state.db schema sufficient for the four functions under test.
Bypasses sqlite3.connect (uses sqlite3.Connection directly) so the
seed-data setup is not counted by the _TrackingConn monkeypatch — only
connections opened by the function under test should appear in
`_TrackingConn.instances`.
"""
conn = sqlite3.Connection(str(path))
conn.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT,
message_count INTEGER DEFAULT 0,
started_at TEXT,
source TEXT,
parent_session_id TEXT,
ended_at TEXT,
end_reason TEXT
);
CREATE INDEX idx_sessions_parent ON sessions(parent_session_id);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
role TEXT,
content TEXT,
timestamp TEXT
);
INSERT INTO sessions (id, title, model, message_count, started_at, source)
VALUES ('s1', 'cli session', 'gpt-x', 2, '2026-01-01T00:00:00Z', 'cli');
INSERT INTO messages (session_id, role, content, timestamp)
VALUES ('s1', 'user', 'hi', '2026-01-01T00:00:01Z'),
('s1', 'assistant', 'hello', '2026-01-01T00:00:02Z');
"""
)
conn.commit()
conn.close()
class _TrackingConn:
"""Wraps a real sqlite3.Connection to record open/close lifecycle.
Mirrors the lightweight wrapper pattern already in
test_pr1370_lineage_metadata_perf_and_orphan.py — keeping it inline here so
this regression test stays self-contained and survives refactors there.
"""
instances: list = []
def __init__(self, *args, **kwargs):
self._real = sqlite3.Connection(*args, **kwargs)
self.closed = False
_TrackingConn.instances.append(self)
# Connection-shaped delegation
def cursor(self):
return self._real.cursor()
def execute(self, *a, **kw):
return self._real.execute(*a, **kw)
def executescript(self, *a, **kw):
return self._real.executescript(*a, **kw)
def commit(self):
return self._real.commit()
def rollback(self):
return self._real.rollback()
def close(self):
self.closed = True
return self._real.close()
# row_factory needs to round-trip onto the real connection
@property
def row_factory(self):
return self._real.row_factory
@row_factory.setter
def row_factory(self, value):
self._real.row_factory = value
# Context-manager protocol — the bug only triggers if a caller relies on
# __exit__ to close. Defer to the real Connection's CM (commit/rollback)
# so we faithfully reproduce the leak shape that prompted the fix.
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return self._real.__exit__(exc_type, exc, tb)
@pytest.fixture
def tracking_sqlite(monkeypatch):
"""Monkeypatch sqlite3.connect to use _TrackingConn and reset the instance log."""
_TrackingConn.instances = []
def _connect(*args, **kwargs):
return _TrackingConn(*args, **kwargs)
monkeypatch.setattr(sqlite3, "connect", _connect)
return _TrackingConn
def _assert_all_closed(tracking, fn_name):
assert tracking.instances, (
f"{fn_name}: no sqlite connections were opened — test setup is wrong"
)
leaked = [c for c in tracking.instances if not c.closed]
assert not leaked, (
f"{fn_name} leaked {len(leaked)} of {len(tracking.instances)} sqlite "
f"connection(s) — context-manager-only `with sqlite3.connect()` does "
f"not close. Wrap in contextlib.closing(). See #1494."
)
def test_read_importable_agent_session_rows_closes_connection(tmp_path, tracking_sqlite):
"""`read_importable_agent_session_rows` must close every sqlite connection."""
db = tmp_path / "state.db"
_make_state_db(db)
from api.agent_sessions import read_importable_agent_session_rows
# Call repeatedly — under the bug each call leaked a connection.
for _ in range(5):
read_importable_agent_session_rows(db)
_assert_all_closed(tracking_sqlite, "read_importable_agent_session_rows")
# #5455: the listing is strictly read-only, including when indexes are
# missing — five calls, five read-only connections, no self-heal writer.
assert len(tracking_sqlite.instances) == 5
def test_read_session_lineage_metadata_closes_connection(tmp_path, tracking_sqlite):
"""`read_session_lineage_metadata` must close every sqlite connection."""
db = tmp_path / "state.db"
_make_state_db(db)
from api.agent_sessions import read_session_lineage_metadata
for _ in range(5):
read_session_lineage_metadata(db, ["s1"])
_assert_all_closed(tracking_sqlite, "read_session_lineage_metadata")
assert len(tracking_sqlite.instances) == 5
def test_get_cli_session_messages_closes_connection(tmp_path, tracking_sqlite, monkeypatch):
"""`get_cli_session_messages` must close every sqlite connection."""
db = tmp_path / "state.db"
_make_state_db(db)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Stub get_active_hermes_home so the tmp_path is used regardless of profile state.
import api.profiles
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: str(tmp_path))
from api.models import get_cli_session_messages
for _ in range(5):
rows = get_cli_session_messages("s1")
# Sanity: the seeded messages are returned (proves we hit the real query path).
assert len(rows) == 2
_assert_all_closed(tracking_sqlite, "get_cli_session_messages")
assert len(tracking_sqlite.instances) == 5
def test_delete_cli_session_closes_connection(tmp_path, tracking_sqlite, monkeypatch):
"""The scoped delete transaction must close its sqlite connection."""
db = tmp_path / "state.db"
_make_state_db(db)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import api.profiles
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: str(tmp_path))
from api.models import delete_cli_session
deleted = delete_cli_session("s1")
assert deleted is True, "delete_cli_session should report the row was removed"
# Second call is idempotent; its connection must still close cleanly.
deleted_again = delete_cli_session("s1")
assert deleted_again is True
_assert_all_closed(tracking_sqlite, "delete_cli_session")
# First call commits; second call short-circuits but still opens+closes a connection.
assert len(tracking_sqlite.instances) == 2
# Verify the commit semantics survived the closing() change — row really is gone.
real = sqlite3.Connection(str(db))
try:
cur = real.execute("SELECT COUNT(*) FROM sessions WHERE id = ?", ("s1",))
assert cur.fetchone()[0] == 0
cur = real.execute("SELECT COUNT(*) FROM messages WHERE session_id = ?", ("s1",))
assert cur.fetchone()[0] == 0
finally:
real.close()