Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,20 @@ def _hermetic_environment(tmp_path, monkeypatch):
(fake_hermes_home / "skills").mkdir()
monkeypatch.setenv("HERMES_HOME", str(fake_hermes_home))

# 3b. hermes_state computes ``DEFAULT_DB_PATH = get_hermes_home() / "state.db"``
# at import time. When the module is first imported at collection (any
# test file with a top-level ``from hermes_state import ...``) that
# happens BEFORE this fixture ever runs, so every argless
# ``SessionDB()`` in every test opens the developer's REAL state.db —
# reading real sessions into assertions and writing test rows into the
# real profile. Re-pin the constant to this test's home. (Several test
# files already do this locally; this makes it an invariant.)
hermes_state_mod = sys.modules.get("hermes_state")
if hermes_state_mod is not None and hasattr(hermes_state_mod, "DEFAULT_DB_PATH"):
monkeypatch.setattr(
hermes_state_mod, "DEFAULT_DB_PATH", fake_hermes_home / "state.db"
)

# 4. Deterministic locale / timezone / hashseed. CI runs in UTC with
# C.UTF-8 locale; local dev often doesn't. Pin everything.
monkeypatch.setenv("TZ", "UTC")
Expand Down Expand Up @@ -419,6 +433,105 @@ def _isolate_hermes_home(_hermetic_environment):
# approvals from one test's session into another's.


# ── tui_gateway.server shared-module state isolation ───────────────────────
#
# ``tui_gateway.server`` registers its RPC handlers in a module-level
# ``_methods`` dict at import time and keeps per-session state in module
# globals (sessions, child-run registry, config cache, DB handle). The
# canonical per-file process isolation above hides any leakage, but a direct
# multi-file invocation (``pytest tests/tui_gateway/ tests/test_tui_gateway_server.py``,
# or plain ``pytest tests/``) shares one interpreter: a test that stubs
# ``_methods["slash.exec"]`` or leaves an active-session lease behind breaks
# unrelated tests in later files. This fixture snapshots the cheap-to-copy
# globals before each test and restores them after, so any file combination
# is order-independent. It is a near no-op (one sys.modules lookup) while
# the module has not been imported.
#
# The case this cannot cover — the module is first imported *during* a test
# that also mutates ``_methods`` — is handled by the importing files' own
# ``server`` fixtures (tests/tui_gateway/test_protocol.py and friends), which
# snapshot immediately after the import.

_TUI_SERVER_MODULE = "tui_gateway.server"


def _teardown_tui_server_sessions(mod) -> None:
"""Close leftover sessions through the production teardown boundary.

Besides returning active-session leases, this finalizes the session,
unregisters notification state, and closes its agent and slash worker.
"""
sessions = getattr(mod, "_sessions", None)
if not isinstance(sessions, dict):
return
for sid in list(sessions):
mod._close_session_by_id(sid, end_reason="test_cleanup")


@pytest.fixture(autouse=True)
def _reset_tui_gateway_server_state():
mod = sys.modules.get(_TUI_SERVER_MODULE)
snapshot = None
if mod is not None:
snapshot = {
"methods": dict(mod._methods),
"cfg": (mod._cfg_cache, mod._cfg_mtime, mod._cfg_path),
"db": (mod._db, mod._db_error),
"real_stdout": mod._real_stdout,
}

yield

mod = sys.modules.get(_TUI_SERVER_MODULE)
if mod is None:
return

# This finalizer can run before the test's own monkeypatch undo, so a
# global may still be replaced with a non-dict test double — skip those
# (monkeypatch restores the real, pre-test object afterwards anyway).
sessions = mod._sessions
if isinstance(sessions, dict):
_teardown_tui_server_sessions(mod)
for name in (
"_pending",
"_pending_prompt_payloads",
"_answers",
"_child_mirrors",
"_active_child_runs",
):
obj = getattr(mod, name, None)
if isinstance(obj, dict):
obj.clear()

if snapshot is not None:
mod._methods.clear()
mod._methods.update(snapshot["methods"])
mod._cfg_cache, mod._cfg_mtime, mod._cfg_path = snapshot["cfg"]
mod._db, mod._db_error = snapshot["db"]
mod._real_stdout = snapshot["real_stdout"]
else:
# First imported during this test — reset to import-time defaults
# for the globals we could not snapshot (``_methods`` is left to
# the importing file's fixture, see block comment above).
mod._cfg_cache = None
mod._cfg_mtime = None
mod._cfg_path = None
mod._db = None
mod._db_error = None

# A leaked context-local Hermes home override redirects every later
# ``get_hermes_home()`` call (active-session registry, config paths)
# to a stale per-test tmpdir. Force the main-thread ContextVar back
# to its default.
try:
from hermes_constants import get_hermes_home_override, set_hermes_home_override

if get_hermes_home_override() is not None:
set_hermes_home_override(None)
except Exception:
pass


@pytest.fixture()
def tmp_dir(tmp_path):
"""Provide a temporary directory that is cleaned up automatically."""
Expand Down
5 changes: 4 additions & 1 deletion tests/tui_gateway/test_compaction_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

@pytest.fixture()
def server():
# Mocks are scoped to the initial import only (see
# tests/tui_gateway/test_protocol.py for the rationale).
with patch.dict(
"sys.modules",
{
Expand All @@ -28,7 +30,8 @@ def server():
"hermes_state": MagicMock(),
},
):
yield importlib.import_module("tui_gateway.server")
mod = importlib.import_module("tui_gateway.server")
yield mod


def _capture(server, monkeypatch):
Expand Down
25 changes: 13 additions & 12 deletions tests/tui_gateway/test_goal_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ def hermes_home(tmp_path, monkeypatch):

@pytest.fixture()
def server(hermes_home):
# Mocks are scoped to the initial import only (see
# tests/tui_gateway/test_protocol.py for the rationale).
with patch.dict(
"sys.modules",
{
Expand All @@ -43,18 +45,17 @@ def server(hermes_home):
},
):
mod = importlib.import_module("tui_gateway.server")
yield mod
# Reset module-level session state without re-importing. importlib.reload
# would re-register the module's atexit hooks (ThreadPoolExecutor
# shutdown, _shutdown_sessions); the duplicates race the stderr
# buffer at interpreter shutdown and surface as Fatal Python error:
# _enter_buffered_busy. Clearing the per-session dicts gives the
# next test a clean slate; _methods is NOT cleared because it's
# populated at module import time and re-registration only happens
# via reload (which we don't do).
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()

yield mod
# Reset module-level session state without re-importing. importlib.reload
# would re-register the module's atexit hooks (ThreadPoolExecutor
# shutdown, _shutdown_sessions); the duplicates race the stderr
# buffer at interpreter shutdown and surface as Fatal Python error:
# _enter_buffered_busy. Clearing the per-session dicts gives the
# next test a clean slate.
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()


@pytest.fixture()
Expand Down
20 changes: 16 additions & 4 deletions tests/tui_gateway/test_inline_rpc_gil_starvation.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ def _restore_stdout():

@pytest.fixture()
def server():
# Mocks are scoped to the initial import only — keeping them active for
# the whole test would poison modules first imported inside test bodies
# (see tests/tui_gateway/test_protocol.py for the full rationale).
with patch.dict("sys.modules", {
"hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
"hermes_cli.env_loader": MagicMock(),
Expand All @@ -42,10 +45,19 @@ def server():
}):
import importlib
mod = importlib.import_module("tui_gateway.server")
yield mod
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()

# Tests below stub handlers ("session.list", "prompt.submit", ...) in
# the module-level _methods dict shared with every other test file in
# the process — snapshot and restore it around each test.
methods = dict(mod._methods)
real_stdout = mod._real_stdout
yield mod
mod._methods.clear()
mod._methods.update(methods)
mod._real_stdout = real_stdout
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()


@pytest.fixture()
Expand Down
7 changes: 5 additions & 2 deletions tests/tui_gateway/test_moa_reference_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

@pytest.fixture()
def server():
# Mocks are scoped to the initial import only (see
# tests/tui_gateway/test_protocol.py for the rationale).
with patch.dict(
"sys.modules",
{
Expand All @@ -30,8 +32,9 @@ def server():
import importlib

mod = importlib.import_module("tui_gateway.server")
yield mod
mod._sessions.clear()

yield mod
mod._sessions.clear()


@pytest.fixture()
Expand Down
70 changes: 58 additions & 12 deletions tests/tui_gateway/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ def _restore_stdout():

@pytest.fixture()
def server():
# The sys.modules mocks only need to cover the *initial* import — once
# tui_gateway.server is cached, they are inert. Keeping them active for
# the whole test poisons any module first imported inside a test body:
# e.g. hermes_cli.active_sessions would bind the mocked get_hermes_home
# (a fixed shared path) forever, leaking active-session registry entries
# across every later test in the process. Scope the patch to the import.
with patch.dict("sys.modules", {
"hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
"hermes_cli.env_loader": MagicMock(),
Expand All @@ -29,18 +35,58 @@ def server():
}):
import importlib
mod = importlib.import_module("tui_gateway.server")
yield mod
# Reset module-level session state without re-importing. importlib.reload
# would re-register the module's atexit hooks (ThreadPoolExecutor
# shutdown, _shutdown_sessions); the duplicates race the stderr
# buffer at interpreter shutdown and surface as Fatal Python error:
# _enter_buffered_busy. Clearing the per-session dicts gives the
# next test a clean slate; _methods is NOT cleared because it's
# populated at module import time and re-registration only happens
# via reload (which we don't do).
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()

# Snapshot the RPC registry: several tests below stub handlers
# ("slash.exec", "fast.ping", ...) directly in the module-level dict,
# which is shared with every other test file in the process.
methods = dict(mod._methods)
real_stdout = mod._real_stdout
yield mod
# Reset module-level state without re-importing. importlib.reload
# would re-register the module's atexit hooks (ThreadPoolExecutor
# shutdown, _shutdown_sessions); the duplicates race the stderr
# buffer at interpreter shutdown and surface as Fatal Python error:
# _enter_buffered_busy. Restoring the dicts in place gives the next
# test a clean slate.
mod._methods.clear()
mod._methods.update(methods)
mod._real_stdout = real_stdout
for sid in list(mod._sessions):
mod._close_session_by_id(sid, end_reason="test_cleanup")
mod._pending.clear()
mod._answers.clear()


def test_shared_fixture_cleanup_uses_full_session_teardown(server, monkeypatch):
"""The cross-file autouse cleanup must close every retained resource."""
from tests import conftest

closed = {"worker": 0, "agent": 0, "lease": 0}

class _Closable:
def __init__(self, key):
self.key = key

def close(self):
closed[self.key] += 1

class _Lease:
def release(self):
closed["lease"] += 1

monkeypatch.setattr(server, "_get_db", lambda: None)
server._sessions["leaked"] = {
"session_key": "leaked",
"agent": _Closable("agent"),
"slash_worker": _Closable("worker"),
"active_session_lease": _Lease(),
"history": [],
}

conftest._teardown_tui_server_sessions(server)

assert server._sessions == {}
assert closed == {"worker": 1, "agent": 1, "lease": 1}


@pytest.fixture()
Expand Down
25 changes: 13 additions & 12 deletions tests/tui_gateway/test_review_summary_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

@pytest.fixture()
def server():
# Mocks are scoped to the initial import only (see
# tests/tui_gateway/test_protocol.py for the rationale).
with patch.dict(
"sys.modules",
{
Expand All @@ -32,18 +34,17 @@ def server():
import importlib

mod = importlib.import_module("tui_gateway.server")
yield mod
# Reset module-level session state without re-importing. importlib.reload
# would re-register the module's atexit hooks (ThreadPoolExecutor
# shutdown, _shutdown_sessions); the duplicates race the stderr
# buffer at interpreter shutdown and surface as Fatal Python error:
# _enter_buffered_busy. Clearing the per-session dicts gives the
# next test a clean slate; _methods is NOT cleared because it's
# populated at module import time and re-registration only happens
# via reload (which we don't do).
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()

yield mod
# Reset module-level session state without re-importing. importlib.reload
# would re-register the module's atexit hooks (ThreadPoolExecutor
# shutdown, _shutdown_sessions); the duplicates race the stderr
# buffer at interpreter shutdown and surface as Fatal Python error:
# _enter_buffered_busy. Clearing the per-session dicts gives the
# next test a clean slate.
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()


def test_init_session_attaches_background_review_callback(server, monkeypatch):
Expand Down
15 changes: 9 additions & 6 deletions tests/tui_gateway/test_subagent_child_mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

@pytest.fixture()
def server():
# Mocks are scoped to the initial import only (see
# tests/tui_gateway/test_protocol.py for the rationale).
with patch.dict(
"sys.modules",
{
Expand All @@ -31,12 +33,13 @@ def server():
import importlib

mod = importlib.import_module("tui_gateway.server")
yield mod
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
mod._child_mirrors.clear()
mod._active_child_runs.clear()

yield mod
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
mod._child_mirrors.clear()
mod._active_child_runs.clear()


@pytest.fixture()
Expand Down
Loading
Loading