221 lines
7.6 KiB
Python
221 lines
7.6 KiB
Python
"""Tests for stdio MCP helper children in the spawn ledger (#61514).
|
|
|
|
Covers ``register_child`` (the ledger mirror of ``register_self`` for
|
|
subprocesses that never import Hermes code), the live-spawner protection
|
|
contract, dead-spawner reap eligibility through BOTH consumers (the updater's
|
|
``_ledger_reapable_backend_pids`` rung and the startup
|
|
``reap_orphaned_mcp_helpers`` sweep), and prune-on-write of exited children.
|
|
|
|
Uses REAL subprocesses (``sleep``) and the real psutil so the
|
|
``(pid, create_time)`` identity pair is exercised end-to-end, with the ledger
|
|
redirected to a tmp path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
import psutil
|
|
import pytest
|
|
|
|
from hermes_cli import process_identity as pi
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
sys.platform == "win32", reason="uses POSIX sleep children"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def ledger(tmp_path):
|
|
path = tmp_path / pi.LEDGER_FILENAME
|
|
with patch.object(pi, "_ledger_path", return_value=path):
|
|
yield path
|
|
|
|
|
|
@pytest.fixture
|
|
def child():
|
|
"""A real live child process of THIS test process."""
|
|
proc = subprocess.Popen(["sleep", "300"])
|
|
try:
|
|
yield proc
|
|
finally:
|
|
proc.kill()
|
|
proc.wait()
|
|
|
|
|
|
def _dead_process_identity() -> tuple[int, float]:
|
|
"""(pid, create_time) of a real process that is provably dead."""
|
|
proc = subprocess.Popen(["sleep", "300"])
|
|
create = psutil.Process(proc.pid).create_time()
|
|
proc.kill()
|
|
proc.wait()
|
|
# Ensure the corpse is fully reaped (no zombie ambiguity).
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline and psutil.pid_exists(proc.pid):
|
|
time.sleep(0.05)
|
|
return proc.pid, create
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# register_child — entry shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_register_child_entry_shape(ledger, child):
|
|
assert pi.register_child(child.pid, "mcp-helper") is True
|
|
entries = json.loads(ledger.read_text(encoding="utf-8"))
|
|
assert len(entries) == 1
|
|
e = entries[0]
|
|
assert e["pid"] == child.pid
|
|
assert e["purpose"] == "mcp-helper"
|
|
assert e["install"] == pi.install_id()
|
|
assert e["spawner_pid"] == os.getpid()
|
|
assert e["spawner_create"] == pytest.approx(
|
|
psutil.Process(os.getpid()).create_time(), abs=2.0
|
|
)
|
|
assert e["create_time"] == pytest.approx(
|
|
psutil.Process(child.pid).create_time(), abs=2.0
|
|
)
|
|
assert e["registered_at"] == pytest.approx(time.time(), abs=30.0)
|
|
# Visible through the live-verified reader too.
|
|
live = pi.ledger_entries()
|
|
assert [x["pid"] for x in live] == [child.pid]
|
|
|
|
|
|
def test_register_child_rejects_dead_or_invalid_pids(ledger):
|
|
dead_pid, _ = _dead_process_identity()
|
|
assert pi.register_child(dead_pid, "mcp-helper") is False
|
|
assert pi.register_child(0, "mcp-helper") is False
|
|
assert pi.register_child(-5, "mcp-helper") is False
|
|
assert pi.register_child("junk", "mcp-helper") is False # type: ignore[arg-type]
|
|
assert not ledger.exists() or json.loads(ledger.read_text()) == []
|
|
|
|
|
|
def test_mcp_helper_is_reapable_purpose():
|
|
assert "mcp-helper" in pi.REAPABLE_PURPOSES
|
|
# Interactive purposes still excluded.
|
|
assert "chat" not in pi.REAPABLE_PURPOSES
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spawner liveness gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_live_spawner_protection(ledger, child):
|
|
"""A helper whose spawner (this process) lives is never reap-eligible."""
|
|
pi.register_child(child.pid, "mcp-helper")
|
|
(entry,) = pi.ledger_entries()
|
|
assert pi.spawner_is_dead(entry) is False
|
|
|
|
killed: list[int] = []
|
|
reaped = pi.reap_orphaned_mcp_helpers(kill_fn=killed.append)
|
|
assert reaped == [] and killed == []
|
|
assert psutil.pid_exists(child.pid)
|
|
|
|
|
|
def _orphan_entry_for(child_pid: int) -> None:
|
|
"""Rewrite the child's ledger entry so its spawner is a real dead process."""
|
|
path = pi._ledger_path()
|
|
dead_pid, dead_create = _dead_process_identity()
|
|
entries = json.loads(path.read_text(encoding="utf-8"))
|
|
for e in entries:
|
|
if e["pid"] == child_pid:
|
|
e["spawner_pid"] = dead_pid
|
|
e["spawner_create"] = dead_create
|
|
path.write_text(json.dumps(entries), encoding="utf-8")
|
|
|
|
|
|
def test_dead_spawner_reap_eligibility(ledger, child):
|
|
pi.register_child(child.pid, "mcp-helper")
|
|
_orphan_entry_for(child.pid)
|
|
(entry,) = pi.ledger_entries()
|
|
assert pi.spawner_is_dead(entry) is True
|
|
|
|
killed: list[int] = []
|
|
assert pi.reap_orphaned_mcp_helpers(kill_fn=killed.append) == [child.pid]
|
|
assert killed == [child.pid]
|
|
|
|
|
|
def test_reap_selection_mixes_live_and_dead_spawners(ledger):
|
|
"""Only the orphan is selected; the live-spawner helper is untouched."""
|
|
live_proc = subprocess.Popen(["sleep", "300"])
|
|
orphan_proc = subprocess.Popen(["sleep", "300"])
|
|
try:
|
|
pi.register_child(live_proc.pid, "mcp-helper")
|
|
pi.register_child(orphan_proc.pid, "mcp-helper")
|
|
_orphan_entry_for(orphan_proc.pid)
|
|
|
|
killed: list[int] = []
|
|
reaped = pi.reap_orphaned_mcp_helpers(kill_fn=killed.append)
|
|
assert reaped == [orphan_proc.pid]
|
|
assert live_proc.pid not in killed
|
|
finally:
|
|
for p in (live_proc, orphan_proc):
|
|
p.kill()
|
|
p.wait()
|
|
|
|
|
|
def test_reap_actually_kills_orphan(ledger, child):
|
|
"""Default kill path really terminates the orphaned helper."""
|
|
pi.register_child(child.pid, "mcp-helper")
|
|
_orphan_entry_for(child.pid)
|
|
assert pi.reap_orphaned_mcp_helpers() == [child.pid]
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline and child.poll() is None:
|
|
time.sleep(0.05)
|
|
assert child.poll() is not None
|
|
|
|
|
|
def test_reap_ignores_non_mcp_purposes(ledger, child):
|
|
pi.register_child(child.pid, "serve-child-not-helper")
|
|
_orphan_entry_for(child.pid)
|
|
assert pi.reap_orphaned_mcp_helpers() == []
|
|
assert psutil.pid_exists(child.pid)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Updater rung (_ledger_reapable_backend_pids) flow-through
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_updater_ledger_rung_flows_mcp_helper(ledger, child):
|
|
from hermes_cli import update_cmd
|
|
|
|
pi.register_child(child.pid, "mcp-helper")
|
|
matches = [(child.pid, "python", "sleep 300")]
|
|
|
|
# Live spawner (this process) → never selected.
|
|
assert update_cmd._ledger_reapable_backend_pids(matches) == []
|
|
|
|
# Provably dead spawner → positively identified as reapable.
|
|
_orphan_entry_for(child.pid)
|
|
assert update_cmd._ledger_reapable_backend_pids(matches) == [child.pid]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prune-on-write of exited children
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ledger_prunes_exited_children_on_next_write(ledger):
|
|
doomed = subprocess.Popen(["sleep", "300"])
|
|
pi.register_child(doomed.pid, "mcp-helper")
|
|
doomed.kill()
|
|
doomed.wait()
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline and psutil.pid_exists(doomed.pid):
|
|
time.sleep(0.05)
|
|
|
|
survivor = subprocess.Popen(["sleep", "300"])
|
|
try:
|
|
pi.register_child(survivor.pid, "mcp-helper")
|
|
entries = json.loads(ledger.read_text(encoding="utf-8"))
|
|
pids = [e["pid"] for e in entries]
|
|
assert survivor.pid in pids
|
|
assert doomed.pid not in pids
|
|
finally:
|
|
survivor.kill()
|
|
survivor.wait()
|