test: the session teardown stops daemons left running against this run's homes

A test that runs a real `hermes chat -q` child gets a grandchild
`gateway run` (detached, its own session) that no per-test subprocess patch
can see, and pytest's tmp cleanup removes directories, never processes.
`scripts/run_tests.sh` already SIGKILLs its attempt's leftovers
(3b0dc776b6), but a plain `pytest` run (an IDE, a lane probe, CI jobs that
call pytest directly) leaked one ~300 MB gateway per such test.

pytest_unconfigure now scans /proc/*/environ (Linux; a no-op elsewhere) for
processes whose HERMES_HOME is under this session's basetemp or session
sandbox home and SIGTERMs them before the basetemp is removed. Nothing
outside those roots is ever signalled.

Live: `pytest tests/hermes_cli/test_cli_first_message.py` on r7-base left
the test's gateway running 3 s after exit; with this conftest it is gone.

Review: https://github.com/NousResearch/hermes-agent/pull/106742#issuecomment-6072628635
(cherry picked from commit dbbba15a85df841c6f68d026fc8d4353bab709ad)
This commit is contained in:
Teknium committed 2026-10-09 02:10:48 -07:00
1 parent ac6545eeaa
commit 78eff4628a
2 files changed
+100 -1

No files matched your search

+55 -1
View File
@@ -250,7 +250,7 @@ from tests._fixtures.live_system_guard import (
_LIVE_SYSTEM_GUARD_BYPASS_MARK,
_live_system_guard,
)
from tests._fixtures.model_peer import model_peer # noqa: F401 — fixture registers here
from tests._fixtures.model_peer import model_peer # fixture registers here
from tests._fixtures.platform_gating import _platforms_gate_reason, _reject_contradictory_platform_marks
@@ -1057,6 +1057,59 @@ def _remove_relocated_basetemp(config) -> None:
shutil.rmtree(safe, ignore_errors=True)
def _processes_with_hermes_home_under(roots) -> list[int]:
"""PIDs (never this process) whose environment names a ``HERMES_HOME`` inside one of *roots*.
Linux only (``/proc/<pid>/environ``; unreadable entries are another user's and skipped).
Elsewhere the scan is empty: a test-spawned daemon there is the per-test guard's problem.
"""
if not sys.platform.startswith("linux") or not os.path.isdir("/proc"):
return []
resolved = [Path(r).resolve() for r in roots if r]
found = []
for entry in os.scandir("/proc"):
if not entry.name.isdigit() or int(entry.name) == os.getpid():
continue
try:
with open(f"/proc/{entry.name}/environ", "rb") as handle:
environ = handle.read().split(b"\0")
except OSError:
continue
home = next((item[12:] for item in environ if item.startswith(b"HERMES_HOME=")), None)
if not home:
continue
try:
path = Path(os.fsdecode(home)).resolve()
except (OSError, ValueError):
continue
if any(path == root or path.is_relative_to(root) for root in resolved):
found.append(int(entry.name))
return found
def _reap_session_hermes_processes(config) -> list[int]:
"""SIGTERM every process still running against a Hermes home this session created.
Tests that run a real ``hermes chat -q`` child get a grandchild ``gateway run`` (detached, own
session) that no per-test subprocess patch can see, and pytest's tmp cleanup removes only
directories: each one used to outlive the run at ~250 MB. The session basetemp and the session
sandbox home bound exactly what this run created, so nothing else is ever signalled.
"""
import signal
factory = getattr(config, "_tmp_path_factory", None)
# The basetemp pytest already created (never create one at teardown just to scan it).
roots = [getattr(config, "_hermes_relocated_basetemp", None), os.environ.get("HERMES_TEST_SANDBOX_HOME"),
getattr(factory, "_basetemp", None)]
pids = _processes_with_hermes_home_under(roots)
for pid in pids:
try:
os.kill(pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError):
pass
return pids
def _pinned_mcp_sdk_version() -> str:
"""The ``mcp==X`` pin carried by the ``[mcp]`` extra in pyproject.toml."""
import tomllib
@@ -1092,6 +1145,7 @@ def require_mcp_2_sdk():
def pytest_unconfigure(config):
_reap_session_hermes_processes(config)
_remove_relocated_basetemp(config)
+45
View File
@@ -0,0 +1,45 @@
"""The session teardown stops processes left running against this run's Hermes homes.
A test that runs a real ``hermes chat -q`` child gets a grandchild ``gateway run`` in its own
session; no per-test subprocess patch sees it, and pytest's tmp cleanup removes only directories.
"""
from __future__ import annotations
import os
import subprocess
import sys
import time
from types import SimpleNamespace
import pytest
from tests import conftest as suite_conftest
def _detached(env_home, marker):
code = "import time\nwhile True: time.sleep(0.2)\n"
env = {**os.environ, "HERMES_HOME": str(env_home), "LIVE_REAP_MARKER": marker}
return subprocess.Popen([sys.executable, "-c", code], env=env, start_new_session=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@pytest.mark.platforms("linux")
@pytest.mark.live_system_guard_bypass # signals only the two children this test spawned
def test_session_teardown_terminates_daemons_under_the_session_basetemp_only(tmp_path):
basetemp, elsewhere = tmp_path / "basetemp", tmp_path / "elsewhere"
(basetemp / "t0" / "hermes").mkdir(parents=True)
elsewhere.mkdir()
inside = _detached(basetemp / "t0" / "hermes", "inside")
outside = _detached(elsewhere, "outside")
try:
time.sleep(0.3)
config = SimpleNamespace(_tmp_path_factory=SimpleNamespace(_basetemp=basetemp))
reaped = suite_conftest._reap_session_hermes_processes(config)
assert inside.pid in reaped and outside.pid not in reaped
assert inside.wait(timeout=5) is not None
assert outside.poll() is None, "a process outside this session's homes was signalled"
finally:
for proc in (inside, outside):
if proc.poll() is None:
proc.kill()
proc.wait(timeout=5)