Files
hermes-agent/tests/gateway/test_session_db_handle_sharing.py
joaomarcos e8c41568ca fix(state): bound state.db read connections per FILE, and stop opening two gateway handles
Issue #98573 reports a long-lived gateway holding ~20 `state.db` descriptors
that never shrink, walking into the 256 soft RLIMIT_NOFILE a launchd/systemd
service manager hands the process. The cause named there — a per-thread
`threading.local()` read connection — is already gone (87aedbe7b6 pooled the
read connections, 0472c31aa1 added the peak permit). Measured on main: one
SessionDB with 40 concurrent reader threads peaks at 9 live connections, not 40.

The symptom survives one layer up. `_READ_POOL_MAX` was enforced by a
BoundedSemaphore owned by each SessionDB, which bounds the wrong noun: the
descriptors are spent on a FILE, so every additional handle on one state.db got
its own allowance and peak scaled as `instances x (1 + _READ_POOL_MAX)`.

Two changes, both needed:

* The permits move to a per-path `_PathReadBudget`, shared by every SessionDB
  in the process that points at that file. A permit miss first reclaims an IDLE
  pooled connection from a peer handle before degrading to the writer lock —
  without that, whichever handle warmed up first would pin the whole budget and
  permanently demote every later one (a cron job's transient handle, a second
  profile's store) to the locked writer connection.

* `GatewayRunner` borrows `SessionStore`'s handle instead of opening its own.
  Both caches resolve the same `_default_db_path()`, so the process was holding
  two writer connections and two read pools against one file for no reason, and
  doubling again per profile on a multiplexed gateway. The store owns the
  connection and sweeps it at shutdown; the runner's cache now holds only the
  async wrapper and its sweep skips borrowed handles.

Measured peak live connections against one file, 40 reader threads, by handle
count 1/2/4/8:

  before: 9 / 18 / 36 / 51   (51 not 72 only because the sample window ended
                              before every pool filled)
  after:  9 / 10 / 12 / 16   (read connections capped at 8 in total; the
                              remainder is one writer per handle, and the
                              gateway's per-profile pair is now one)

Fixes #98573

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:23:48 -07:00

159 lines
6.0 KiB
Python

"""One gateway process must not hold two SessionDB handles on one state.db.
Regression coverage for #98573. ``SessionStore`` and ``GatewayRunner`` each
cached a handle per resolved path, and both resolve the SAME
``_default_db_path()``. The process therefore held two writer connections and
two independent read pools against one file, so the descriptor budget doubled
for nothing -- and doubled again per profile on a multiplexed gateway, until a
long-lived process passed the 256 soft ``RLIMIT_NOFILE`` a service manager
hands it and unrelated code paths started failing with EMFILE while the
process stayed alive.
The runner now borrows the store's handle and caches only the async wrapper.
Ownership follows: the store closes the connection, the runner does not.
"""
import threading
from pathlib import Path
from unittest.mock import patch
import pytest
from gateway.config import GatewayConfig
from gateway.run import GatewayRunner, _SESSION_DB_UNPINNED
from gateway.session import SessionStore
from gateway.session_db_recovery import RecoverableHandleCache
def _live_count(path) -> int:
"""Live-connection count the tracking registry holds for *path*."""
import hermes_cli.sqlite_safe_read as mod
with mod._live_lock:
return mod._live_connections.get(mod._key(path), 0)
@pytest.fixture
def home(tmp_path, monkeypatch):
"""A gateway home under tmp_path, with path resolution going through it."""
import hermes_state
root = tmp_path / "hermes"
root.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(root))
# The suite-wide fixture re-points DEFAULT_DB_PATH, which trips the
# deliberate escape hatch in _default_db_path() and would pin every lookup
# to one fixed path. Restore the import-time snapshot so resolution runs
# through get_hermes_home() the way production does; HERMES_HOME above
# keeps it inside tmp_path. Same reasoning as
# test_multiplex_session_db_profile_scope.py.
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", hermes_state._IMPORT_DEFAULT_DB_PATH
)
return root
@pytest.fixture
def store(home):
with patch("gateway.session.SessionStore._ensure_loaded"):
s = SessionStore(sessions_dir=home / "sessions", config=GatewayConfig())
s._loaded = True
yield s
s.close_all_db_handles()
def _runner_with(store) -> GatewayRunner:
"""A GatewayRunner with only what the handle path touches.
Built with ``object.__new__`` deliberately: ``GatewayRunner.__init__``
starts platforms, executors and schedulers, none of which this contract
involves, and the method under test already supports this shape (it
rebuilds the cache when ``__init__`` did not).
"""
runner = object.__new__(GatewayRunner)
runner.session_store = store
runner._session_db_pinned = _SESSION_DB_UNPINNED
runner._session_db_handles = {}
runner._session_db_handles_lock = threading.Lock()
runner._session_db_handle_cache = RecoverableHandleCache(
handles=runner._session_db_handles,
lock=runner._session_db_handles_lock,
)
runner._session_db_init_error = None
return runner
def test_runner_borrows_the_stores_handle_instead_of_opening_a_second(store):
"""The runner's SessionDB must BE the store's, not a twin of it."""
store_db = store._db
assert store_db is not None, "fixture must have a usable SQLite handle"
path = Path(store_db.db_path)
before = _live_count(path)
assert before >= 1, "the store's writer connection should be live"
runner = _runner_with(store)
wrapper = runner._open_session_db_for_active_scope()
assert wrapper is not None
assert wrapper._db is store_db, (
"the runner opened its own SessionDB; one process now holds two writer "
"connections and two read pools against one state.db"
)
assert _live_count(path) == before, (
f"live connections went {before} -> {_live_count(path)}; resolving the "
f"runner's handle must not cost a descriptor"
)
def test_runner_shutdown_sweep_leaves_the_borrowed_handle_open(store):
"""Ownership: the store closes its connection, the runner must not.
The shutdown sequence sweeps the store first and the runner second, so a
runner that closed the borrowed handle would be closing an already-closed
connection -- harmless today, and a use-after-close the moment the two
sweeps are reordered or one of them is made conditional.
"""
store_db = store._db
path = Path(store_db.db_path)
runner = _runner_with(store)
assert runner._open_session_db_for_active_scope() is not None
runner.close_all_session_db_handles()
assert _live_count(path) >= 1, "the runner closed the handle the store owns"
assert store_db._conn is not None, "borrowed writer connection was closed"
# The wrapper cache is still drained -- not closing is not the same as not
# forgetting.
assert runner._session_db_handles == {}
def test_runner_without_a_session_store_still_opens_its_own(home):
"""Lightweight runners (no store wired) keep the standalone behaviour."""
runner = object.__new__(GatewayRunner)
runner._session_db_pinned = _SESSION_DB_UNPINNED
runner._session_db_handles = {}
runner._session_db_handles_lock = threading.Lock()
runner._session_db_handle_cache = RecoverableHandleCache(
handles=runner._session_db_handles,
lock=runner._session_db_handles_lock,
)
runner._session_db_init_error = None
wrapper = runner._open_session_db_for_active_scope()
try:
assert wrapper is not None
assert wrapper._db is not None
finally:
runner.close_all_session_db_handles()
def test_unavailable_store_handle_does_not_resurrect_a_second_open(store):
"""When the store has no handle, the runner reports it -- it does not open one."""
store._db = None # pins the store's handle to "unavailable"
runner = _runner_with(store)
assert runner._open_session_db_for_active_scope() is None
assert runner._session_db_handles == {}, (
"a duplicate handle was cached on the store's failure path"
)