159 lines
6.0 KiB
Python
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"
|
|
)
|