547f985286
Per-site decisions:
1. hermes_cli/_subprocess_compat.py kill_process_tree(proc) -> None:
MIGRATED. Body now delegates to agent.deadline.kill_process_tree(proc.pid)
via a function-local import; keeps the swallow-everything fail-open
contract and the (proc) -> None signature (agent/shell_hooks.py imports
it by name; _kill_git_process_tree alias preserved). The old body is kept
verbatim as _legacy_kill_process_tree and used as fallback when the
delegation import/call fails. A final proc.kill() is retained on the
happy path so Popen bookkeeping sees the exit (matches old behavior).
2. tools/browser_tool.py _kill_process_tree(proc): MIGRATED, same pattern
(delegate + _legacy_kill_process_tree fallback). Behavior delta: the old
body sent SIGTERM then SIGKILL with zero grace between them; the shared
primitive sends SIGKILL only. With no grace period the observable effect
is identical, and the psutil descendant sweep now also reaches
agent-browser's setsid'd daemon grandchild, which killpg alone missed.
tests/tools/test_browser_npx_warmup.py's TestKillProcessTree repointed at
the legacy fallback (its assertions describe the fallback's internals).
3. tools/code_execution_tool.py _kill_process_group(proc, escalate):
MIGRATED. It was a plain parent+descendants terminate (then wait 5s +
kill when escalate=True) — expressed as two delegated calls:
kill_process_tree(pid, sig=SIGTERM), then on escalate-timeout
kill_process_tree(pid, sig=SIGKILL). Delegation failure degrades to
proc.kill(), mirroring the old psutil-failure fallback. Delta: the old
body terminated children before the parent; the shared primitive
signals the group atomically (child is a session leader via
start_new_session=True) plus an identity-aware descendant sweep —
strictly wider coverage, same signals.
4. gateway/status.py: KEPT BOTH SITES.
- terminate_pid (~l305) taskkill wrapper: NOT migrated. Its contract is
incompatible with the shared primitive — it must RAISE OSError with
taskkill's stderr on non-zero exit (callers branch on that), falls back
to os.kill on FileNotFoundError, and its POSIX branch is deliberately a
single-PID SIGTERM/SIGKILL, not a tree kill. Wrapping the bool-returning
fail-soft primitive would invert the error contract.
- reap_gateway_children (~l2029): NOT migrated. It operates on a
pre-snapshotted child list from a parent that is already dead
(psutil.Process(pid) on the parent would fail), and every signal is
wrapped in identity/ownership checks the primitive lacks: is_running()
identity, zombie skip, and the skip-if-ppid-still-equals-parent guard,
plus SIGTERM -> wait_procs -> SIGKILL staging and a reaped-count return.
The coupling is the feature; migrating would delete the safety logic.
5. scripts/run_tests_parallel.py _kill_process_tree (~l253): NOT migrated.
Dev tooling that intentionally kills by CAPTURED pgid because the direct
child is usually already reaped (psutil/pid-based primitive cannot find
it), and it avoids the psutil import on the test-runner hot path. Its
docstring already documents why psutil is the wrong tool there.
New tests: tests/agent/test_treekill_consolidation.py — delegation +
raise-swallowing tests per migrated wrapper, consumer-identity checks, and
a live end-to-end probe (setsid grandchild dies through the compat wrapper,
zero survivors).
231 lines
8.3 KiB
Python
231 lines
8.3 KiB
Python
"""#85125 Phase 4d — site-local tree-kills delegate to agent.deadline.
|
|
|
|
Each migrated wrapper keeps its caller-facing contract (signature, all
|
|
failures swallowed, ``None`` return) while routing the actual tree
|
|
termination through :func:`agent.deadline.kill_process_tree`:
|
|
|
|
* ``hermes_cli._subprocess_compat.kill_process_tree(proc)`` — also consumed
|
|
by ``agent.shell_hooks`` by name; falls back to
|
|
``_legacy_kill_process_tree`` when delegation fails.
|
|
* ``tools.browser_tool._kill_process_tree(proc)`` — same pattern.
|
|
* ``tools.code_execution_tool._kill_process_group(proc, escalate=...)`` —
|
|
SIGTERM tree first, then (escalate) bounded wait + SIGKILL tree.
|
|
|
|
Plus one real end-to-end probe: a setsid'd grandchild must die through the
|
|
compat wrapper (i.e. the psutil descendant sweep in agent.deadline is
|
|
actually reached from the delegating call site).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
import agent.deadline as deadline_mod
|
|
|
|
|
|
class _FakeProc:
|
|
def __init__(self, pid=54321):
|
|
self.pid = pid
|
|
self.kill_calls = 0
|
|
|
|
def kill(self):
|
|
self.kill_calls += 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (1) hermes_cli._subprocess_compat.kill_process_tree
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSubprocessCompatDelegation:
|
|
def test_delegates_with_proc_pid(self, monkeypatch):
|
|
from hermes_cli import _subprocess_compat
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
deadline_mod, "kill_process_tree", lambda pid, **kw: calls.append(pid) or True
|
|
)
|
|
proc = _FakeProc(pid=1111)
|
|
assert _subprocess_compat.kill_process_tree(proc) is None
|
|
assert calls == [1111]
|
|
|
|
def test_swallows_delegation_raise_and_falls_back_to_legacy(self, monkeypatch):
|
|
from hermes_cli import _subprocess_compat
|
|
|
|
def _boom(pid, **kw):
|
|
raise RuntimeError("delegation broken")
|
|
|
|
monkeypatch.setattr(deadline_mod, "kill_process_tree", _boom)
|
|
legacy_calls = []
|
|
monkeypatch.setattr(
|
|
_subprocess_compat, "_legacy_kill_process_tree", lambda proc: legacy_calls.append(proc)
|
|
)
|
|
proc = _FakeProc(pid=2222)
|
|
_subprocess_compat.kill_process_tree(proc) # must not raise
|
|
assert legacy_calls == [proc]
|
|
|
|
def test_shell_hooks_consumer_still_imports_by_name(self):
|
|
import agent.shell_hooks as shell_hooks
|
|
from hermes_cli import _subprocess_compat
|
|
|
|
assert shell_hooks.kill_process_tree is _subprocess_compat.kill_process_tree
|
|
|
|
def test_backcompat_alias_preserved(self):
|
|
from hermes_cli import _subprocess_compat
|
|
|
|
assert (
|
|
_subprocess_compat._kill_git_process_tree
|
|
is _subprocess_compat.kill_process_tree
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (2) tools.browser_tool._kill_process_tree
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBrowserToolDelegation:
|
|
def test_delegates_with_proc_pid(self, monkeypatch):
|
|
from tools import browser_tool
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
deadline_mod, "kill_process_tree", lambda pid, **kw: calls.append(pid) or True
|
|
)
|
|
proc = _FakeProc(pid=3333)
|
|
assert browser_tool._kill_process_tree(proc) is None
|
|
assert calls == [3333]
|
|
|
|
def test_swallows_delegation_raise_and_falls_back_to_legacy(self, monkeypatch):
|
|
from tools import browser_tool
|
|
|
|
def _boom(pid, **kw):
|
|
raise OSError("delegation broken")
|
|
|
|
monkeypatch.setattr(deadline_mod, "kill_process_tree", _boom)
|
|
legacy_calls = []
|
|
monkeypatch.setattr(
|
|
browser_tool, "_legacy_kill_process_tree", lambda proc: legacy_calls.append(proc)
|
|
)
|
|
proc = _FakeProc(pid=4444)
|
|
browser_tool._kill_process_tree(proc) # must not raise
|
|
assert legacy_calls == [proc]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (3) tools.code_execution_tool._kill_process_group
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCodeExecutionDelegation:
|
|
def test_delegates_sigterm_tree_first(self, monkeypatch):
|
|
import signal as _signal
|
|
|
|
from tools import code_execution_tool
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
deadline_mod,
|
|
"kill_process_tree",
|
|
lambda pid, sig=None: calls.append((pid, sig)) or True,
|
|
)
|
|
proc = _FakeProc(pid=5555)
|
|
code_execution_tool._kill_process_group(proc)
|
|
assert calls == [(5555, _signal.SIGTERM)]
|
|
|
|
def test_escalate_waits_then_sigkills_tree(self, monkeypatch):
|
|
import signal as _signal
|
|
|
|
from tools import code_execution_tool
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
deadline_mod,
|
|
"kill_process_tree",
|
|
lambda pid, sig=None: calls.append((pid, sig)) or True,
|
|
)
|
|
proc = MagicMock()
|
|
proc.pid = 6666
|
|
proc.wait.side_effect = subprocess.TimeoutExpired(cmd="x", timeout=5)
|
|
code_execution_tool._kill_process_group(proc, escalate=True)
|
|
assert calls == [(6666, _signal.SIGTERM), (6666, _signal.SIGKILL)]
|
|
proc.wait.assert_called_once_with(timeout=5)
|
|
|
|
def test_swallows_delegation_raise_falls_back_to_plain_kill(self, monkeypatch):
|
|
from tools import code_execution_tool
|
|
|
|
def _boom(pid, **kw):
|
|
raise PermissionError("nope")
|
|
|
|
monkeypatch.setattr(deadline_mod, "kill_process_tree", _boom)
|
|
proc = _FakeProc(pid=7777)
|
|
code_execution_tool._kill_process_group(proc) # must not raise
|
|
assert proc.kill_calls == 1
|
|
|
|
def test_even_proc_kill_raise_is_swallowed(self, monkeypatch):
|
|
from tools import code_execution_tool
|
|
|
|
def _boom(pid, **kw):
|
|
raise PermissionError("nope")
|
|
|
|
monkeypatch.setattr(deadline_mod, "kill_process_tree", _boom)
|
|
proc = MagicMock()
|
|
proc.pid = 8888
|
|
proc.kill.side_effect = OSError("already reaped")
|
|
code_execution_tool._kill_process_group(proc) # must not raise
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: setsid grandchild dies through the compat wrapper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.live_system_guard_bypass
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX session semantics")
|
|
def test_e2e_setsid_grandchild_killed_via_compat_wrapper(tmp_path):
|
|
"""The delegating wrapper must reach descendants that setsid'd out of the
|
|
child's process group — the exact capability the shared primitive adds
|
|
over the old local killpg-only body (scaffold shape lifted from
|
|
tests/agent/test_deadline.py::test_kills_descendant_in_its_own_session).
|
|
"""
|
|
pytest.importorskip("psutil")
|
|
import psutil
|
|
|
|
from hermes_cli._subprocess_compat import kill_process_tree
|
|
|
|
started = tmp_path / "grandchild_started"
|
|
marker = tmp_path / "grandchild_survived"
|
|
grandchild_py = tmp_path / "grandchild.py"
|
|
grandchild_py.write_text(
|
|
"import pathlib, time\n"
|
|
f"pathlib.Path({str(started)!r}).write_text('x')\n"
|
|
"time.sleep(10)\n"
|
|
f"pathlib.Path({str(marker)!r}).write_text('x')\n"
|
|
)
|
|
parent_py = tmp_path / "parent.py"
|
|
parent_py.write_text(
|
|
"import subprocess, sys, time\n"
|
|
f"subprocess.Popen([sys.executable, {str(grandchild_py)!r}], start_new_session=True)\n"
|
|
"time.sleep(10)\n"
|
|
)
|
|
proc = subprocess.Popen([sys.executable, str(parent_py)], start_new_session=True)
|
|
try:
|
|
deadline = time.monotonic() + 10
|
|
while not started.exists() and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
assert started.exists(), "grandchild never spawned — test harness broken"
|
|
# Snapshot the tree BEFORE the kill so we can assert zero survivors.
|
|
descendants = psutil.Process(proc.pid).children(recursive=True)
|
|
|
|
kill_process_tree(proc)
|
|
|
|
proc.wait(timeout=5)
|
|
gone, alive = psutil.wait_procs(descendants, timeout=5)
|
|
assert not alive, f"survivors after tree kill: {alive}"
|
|
time.sleep(1.0)
|
|
assert not marker.exists()
|
|
finally:
|
|
if proc.poll() is None:
|
|
proc.kill()
|