Files
hermes-agent/tests/tools/test_browser_process_tree.py
Teknium b82d35571c test: browser tree-kill unit tests no longer SIGKILL real processes under PID 999
Two mocked tests drive _legacy_kill_process_tree down its shared-group
branch with a MagicMock proc whose pid is 999. That branch snapshots
psutil.Process(proc.pid).children(recursive=True) and SIGKILLs each
descendant, and psutil was never patched: the tests killed the real
descendants of whatever the machine runs as PID 999. On macOS runners
PIDs are allocated system-wide, and when 999 was an ancestor of the
test worker the file died mid-run (CRASHED, killed by SIGKILL), which
is how it surfaced on a PR whose diff never touches the browser tool.

Both tests now run against a fake process table, and the shared-group
test asserts the snapshotted descendant is killed individually (the
contract it was written for).

Live repro: inside an unprivileged user+PID namespace with PID 999 an
ancestor of the test runner, main's file reports CRASHED (killed by
SIGKILL); with this change it passes 9/9.
2026-10-06 04:36:02 -07:00

218 lines
7.8 KiB
Python

"""Browser cleanup fallback contracts, retained after retiring the npx warmer."""
import os
import signal
import subprocess
import time
from unittest.mock import MagicMock, patch
import pytest
from tools.browser_tool_lifecycle import _kill_process_tree, _legacy_kill_process_tree
@pytest.fixture
def fake_process_table(monkeypatch):
"""psutil for a MagicMock proc: the shared-group branch snapshots
psutil.Process(proc.pid).children() and SIGKILLs each one. Against the real
table, pid=999 is whatever the machine runs there, and when that is an
ancestor of the test worker the test SIGKILLs its own interpreter (seen as
a CRASHED file on macOS runners, whose PIDs are allocated system-wide)."""
import psutil
child = MagicMock()
queried = []
def process(pid):
queried.append(pid)
return MagicMock(children=MagicMock(return_value=[child]))
monkeypatch.setattr(psutil, "Process", process)
return queried, child
@pytest.mark.platforms("posix")
def test_posix_kills_process_group_term_then_kill(monkeypatch):
proc = MagicMock(pid=999)
monkeypatch.setattr(os, "getpgid", lambda pid: 999)
calls = []
monkeypatch.setattr(os, "killpg", lambda pgid, sig: calls.append((pgid, sig)))
_legacy_kill_process_tree(proc)
assert calls == [(999, signal.SIGTERM), (999, signal.SIGKILL)]
@pytest.mark.platforms("posix")
def test_posix_missing_process_returns_silently(monkeypatch, fake_process_table):
def missing(pid):
raise ProcessLookupError()
monkeypatch.setattr(os, "getpgid", missing)
_legacy_kill_process_tree(MagicMock(pid=999))
assert fake_process_table[0] == [999]
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("kill_error", [None, OSError("already reaped")])
def test_missing_killpg_falls_back_to_proc_kill(monkeypatch, kill_error):
proc = MagicMock(pid=999)
proc.kill.side_effect = kill_error
monkeypatch.delattr(os, "killpg", raising=False)
_legacy_kill_process_tree(proc)
proc.kill.assert_called_once()
@pytest.mark.platforms("posix")
def test_permission_denied_does_not_attempt_sigkill(monkeypatch):
monkeypatch.setattr(os, "getpgid", lambda pid: 999)
calls = []
def denied(pgid, sig):
calls.append((pgid, sig))
raise PermissionError()
monkeypatch.setattr(os, "killpg", denied)
_legacy_kill_process_tree(MagicMock(pid=999))
assert calls == [(999, signal.SIGTERM)]
@pytest.mark.platforms("windows")
@pytest.mark.parametrize("error", [None, OSError("taskkill missing")])
def test_windows_taskkill_targets_tree_and_is_best_effort(error):
with patch("subprocess.run", side_effect=error) as run:
_legacy_kill_process_tree(MagicMock(pid=4321))
assert run.call_args.args[0] == ["taskkill", "/PID", "4321", "/T", "/F"]
# ---------------------------------------------------------------------------
# Group-leadership guard (#119149): a child that does not lead its own group
# shares OURS — killpg would signal the whole Hermes/test-runner tree.
# ---------------------------------------------------------------------------
@pytest.mark.platforms("posix")
def test_posix_shared_group_is_never_killpgd(monkeypatch, fake_process_table):
"""A child spawned without start_new_session shares OUR process group, so
os.getpgid returns the caller's own pgid — killpg would signal the whole
Hermes/test-runner tree. Only a group leader may be killpg'd; the direct
child still gets proc.kill()."""
proc = MagicMock()
proc.pid = 999
monkeypatch.setattr(os, "getpgid", lambda pid: 555) # child's group != its pid
killpg_calls = []
monkeypatch.setattr(os, "killpg", lambda pgid, sig: killpg_calls.append((pgid, sig)))
_legacy_kill_process_tree(proc)
assert killpg_calls == []
proc.kill.assert_called_once()
queried, descendant = fake_process_table
assert queried == [999]
descendant.kill.assert_called_once()
@pytest.mark.platforms("posix")
def test_real_shared_group_child_does_not_signal_us(monkeypatch):
"""End-to-end through _kill_process_tree with the deadline helper forced
to fail: a real child in our own process group must be proc.kill()ed
without any killpg — if the group signal fired, this test process would
be dead before the assertion."""
from tools.browser_tool_lifecycle import _kill_process_tree
proc = subprocess.Popen(
["sleep", "60"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
assert os.getpgid(proc.pid) == os.getpgid(0) # shared group precondition
monkeypatch.setattr(
"agent.deadline.kill_process_tree",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("deadline unavailable")),
)
_kill_process_tree(proc)
proc.wait(timeout=5)
assert proc.returncode is not None
finally:
if proc.poll() is None:
proc.kill()
@pytest.mark.platforms("posix")
@pytest.mark.live_system_guard_bypass # the grandchild reparents to init once the
# parent dies; signalling a reparented descendant is exactly the contract under
# test, and the guard's subtree walk races with the reparenting (blocked kills
# surfaced as a 1-2% CI flake: psutil's os.kill routes through the guard after
# the parent kill has landed).
def test_real_shared_group_child_descendants_are_killed(tmp_path):
"""A shared-group child's descendants can hold the capture pipe's write
end open past proc.kill() (the #68915 communicate() hang), so the
non-leader path must kill them individually. The grandchild is in OUR
process group: if the implementation regressed to killpg this test
process would die before the assertion."""
pid_file = tmp_path / "grandchild.pid"
proc = subprocess.Popen(
["sh", "-c", f"sleep 60 & echo $! > {pid_file}; wait"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
gcpid = None
try:
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
text = pid_file.read_text().strip() if pid_file.exists() else ""
if text:
gcpid = int(text)
break
time.sleep(0.05)
assert gcpid is not None, "grandchild never wrote its pid file"
assert os.getpgid(proc.pid) == os.getpgid(0) # shared group precondition
_legacy_kill_process_tree(proc)
proc.wait(timeout=5)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
try:
os.kill(gcpid, 0)
except ProcessLookupError:
break
time.sleep(0.05)
else:
pytest.fail(f"grandchild pid {gcpid} survived the non-leader tree kill")
finally:
if proc.poll() is None:
proc.kill()
if gcpid is not None:
try:
os.kill(gcpid, signal.SIGKILL)
except OSError:
pass
@pytest.mark.platforms("posix")
def test_real_group_leader_child_is_tree_killed(monkeypatch):
"""Control: a child leading its own group (process_group=0) still gets the
group signal through the same fallback path."""
from tools.browser_tool_lifecycle import _kill_process_tree
proc = subprocess.Popen(
["sleep", "60"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
process_group=0,
)
try:
assert os.getpgid(proc.pid) == proc.pid # leader precondition
monkeypatch.setattr(
"agent.deadline.kill_process_tree",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("deadline unavailable")),
)
_kill_process_tree(proc)
proc.wait(timeout=5)
assert proc.returncode is not None
finally:
if proc.poll() is None:
proc.kill()