mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
ac6545eeaa
commit
78eff4628a
2 files changed
+100
-1
No files matched your search
+55
-1
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user