Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""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"
|
||||
)
|
||||
Reference in New Issue
Block a user