hermes-agent/tests/gateway/test_replace_child_reap.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

251 lines
8.4 KiB
Python

"""Tests for --replace child-process reaping (POSIX taskkill /T parity).
On Windows, ``terminate_pid(force=True)`` tree-kills via ``taskkill /T``.
On POSIX, ``--replace`` historically signalled only the recorded gateway PID,
so adapter subprocesses that survived their parent kept holding scoped token
locks and blocked the replacement gateway. ``_snapshot_gateway_children`` /
``reap_gateway_children`` close that gap: the replacer snapshots the old
gateway's descendants while it is still alive, and reaps them (best-effort,
identity-aware) only after the main PID is confirmed dead.
Also asserts the takeover/reap machinery stays gated on explicit --replace.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from gateway import status
from gateway.config import GatewayConfig
class _FakeChild:
"""Minimal psutil.Process stand-in for reap tests."""
def __init__(self, pid, *, running=True, ppid=1, zombie=False):
self.pid = pid
self._running = running
self._ppid = ppid
self._zombie = zombie
self.terminated = False
self.killed = False
def is_running(self):
return self._running
def status(self):
return "zombie" if self._zombie else "sleeping"
def ppid(self):
return self._ppid
def terminate(self):
self.terminated = True
def kill(self):
self.killed = True
def _fake_psutil(monkeypatch, *, wait_gone=None, wait_alive=None):
"""Install a stub psutil module for gateway.status's local imports."""
fake = MagicMock()
fake.STATUS_ZOMBIE = "zombie"
fake.NoSuchProcess = type("NoSuchProcess", (Exception,), {})
fake.wait_procs = MagicMock(
side_effect=lambda live, timeout: (
wait_gone if wait_gone is not None else list(live),
wait_alive if wait_alive is not None else [],
)
)
monkeypatch.setitem(sys.modules, "psutil", fake)
return fake
class TestReapGatewayChildren:
def test_reaps_orphaned_children_sigterm_then_wait(self, monkeypatch):
monkeypatch.setattr(status, "_IS_WINDOWS", False)
fake = _fake_psutil(monkeypatch)
orphans = [_FakeChild(101, ppid=1), _FakeChild(102, ppid=1)]
reaped = status.reap_gateway_children(orphans, parent_pid=42)
assert reaped == 2
assert all(c.terminated for c in orphans)
assert not any(c.killed for c in orphans)
fake.wait_procs.assert_called_once()
def test_survivors_of_sigterm_get_sigkill(self, monkeypatch):
monkeypatch.setattr(status, "_IS_WINDOWS", False)
stubborn = _FakeChild(103, ppid=1)
_fake_psutil(monkeypatch, wait_gone=[], wait_alive=[stubborn])
reaped = status.reap_gateway_children([stubborn], parent_pid=42)
assert stubborn.terminated
assert stubborn.killed
assert reaped == 1
class TestSnapshotGatewayChildren:
def test_snapshot_walks_descendants_recursively(self, monkeypatch):
monkeypatch.setattr(status, "_IS_WINDOWS", False)
fake = _fake_psutil(monkeypatch)
kids = [_FakeChild(201), _FakeChild(202)]
fake.Process.return_value.children.return_value = kids
assert status._snapshot_gateway_children(42) == kids
fake.Process.assert_called_once_with(42)
fake.Process.return_value.children.assert_called_once_with(recursive=True)
class TestScopedLockTakeoverReapsChildren:
"""take_over_scoped_lock_holder reaps the dead owner's orphans (POSIX)."""
@staticmethod
def _owner_record(target_home: Path, *, pid: int = 4242, start_time: int = 123):
target_home.mkdir(parents=True, exist_ok=True)
record = {
"pid": pid,
"kind": "hermes-gateway",
"argv": ["python", "-m", "hermes_cli.main", "gateway", "run"],
"start_time": start_time,
"hermes_home": str(target_home),
}
(target_home / "gateway.pid").write_text(json.dumps(record))
return record
def _verified_owner_env(self, tmp_path, monkeypatch, *, alive_polls):
replacer_home = tmp_path / "replacer"
target_home = tmp_path / "target"
replacer_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(replacer_home))
record = self._owner_record(target_home)
alive = iter(alive_polls)
monkeypatch.setattr(status, "_pid_exists", lambda _pid: next(alive))
monkeypatch.setattr(status, "_get_process_start_time", lambda _pid: 123)
monkeypatch.setattr(
status,
"_read_process_cmdline",
lambda _pid: "python -m hermes_cli.main gateway run",
)
return record
def test_successful_takeover_snapshots_then_reaps(self, tmp_path, monkeypatch):
record = self._verified_owner_env(
tmp_path, monkeypatch, alive_polls=[True, True, False]
)
kids = [_FakeChild(301, ppid=1)]
events = []
monkeypatch.setattr(
status,
"_snapshot_gateway_children",
lambda pid: events.append(("snapshot", pid)) or kids,
)
monkeypatch.setattr(
status,
"reap_gateway_children",
lambda children, *, parent_pid, timeout=5.0: events.append(
("reap", parent_pid, children)
)
or len(children),
)
monkeypatch.setattr(
status,
"terminate_pid",
lambda pid, *, force=False: events.append(("terminate", pid, force)),
)
assert status.take_over_scoped_lock_holder(record, graceful_attempts=1) == 4242
# Snapshot taken while owner alive, BEFORE terminate; reap after exit.
assert events == [
("snapshot", 4242),
("terminate", 4242, False),
("reap", 4242, kids),
]
@pytest.mark.asyncio
async def test_start_gateway_replace_reaps_old_gateway_children_posix(
monkeypatch, tmp_path
):
"""--replace snapshots the old gateway's children before SIGTERM and
reaps them after the main PID is confirmed dead (POSIX path)."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
events = []
kids = [_FakeChild(401, ppid=1)]
class _CleanExitRunner:
def __init__(self, config):
self.config = config
self.should_exit_cleanly = True
self.exit_reason = None
self.exit_code = None
self.adapters = {}
async def start(self):
assert self._platform_lock_takeover_on_start is True
return True
async def stop(self):
return None
_pid_state = {"alive": True}
monkeypatch.setattr(
"gateway.status.get_running_pid",
lambda: 42 if _pid_state["alive"] else None,
)
monkeypatch.setattr(
"gateway.status.remove_pid_file",
lambda: _pid_state.update(alive=False),
)
monkeypatch.setattr(
"gateway.status.release_all_scoped_locks", lambda **kwargs: 0
)
monkeypatch.setattr(
"gateway.status._snapshot_gateway_children",
lambda pid: events.append(("snapshot", pid)) or kids,
)
monkeypatch.setattr(
"gateway.status.reap_gateway_children",
lambda children, *, parent_pid, timeout=5.0: events.append(
("reap", parent_pid, children)
)
or len(children),
)
def _mock_terminate_pid(pid, force=False):
events.append(("terminate", pid, force))
_pid_state["alive"] = False
monkeypatch.setattr("gateway.status.terminate_pid", _mock_terminate_pid)
monkeypatch.setattr(
"gateway.status._pid_exists", lambda pid: _pid_state["alive"]
)
monkeypatch.setattr("gateway.run.os.getpid", lambda: 100)
monkeypatch.setattr("time.sleep", lambda _: None)
monkeypatch.setattr("tools.skills_sync.sync_skills", lambda quiet=True: None)
monkeypatch.setattr(
"hermes_logging.setup_logging", lambda hermes_home, mode: tmp_path
)
monkeypatch.setattr(
"hermes_logging._add_rotating_handler", lambda *args, **kwargs: None
)
monkeypatch.setattr("gateway.run.GatewayRunner", _CleanExitRunner)
from gateway.run import start_gateway
ok = await start_gateway(config=GatewayConfig(), replace=True, verbosity=None)
assert ok is True
# Snapshot precedes the SIGTERM; reap runs only after the PID is dead.
assert events == [
("snapshot", 42),
("terminate", 42, False),
("reap", 42, kids),
]