mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
LSP, the code kernel and the slash worker each had their own "release the group once nothing in it is left" (a killpg(pgid, 0) probe; the kernel's version also waited for reaps). They are now one helper, process_identity.release_process_group_once_empty(pgid, proc=, timeout=). It polls the leader so a zombie leader does not keep the group alive, waits up to `timeout` for killed members to be reaped by init, and keeps covering a group that still has a member. LSP's async close path probes once (timeout=0), as before. The slash worker used to probe exactly once right after SIGKILL. On a loaded macOS host (load 25+) init had not reaped the killed members yet, so a group that was about to be empty stayed registered (test_close_stops_the_whole_group_then_releases_it, flaky). It now waits up to 1 s, like the kernel did. The kernel survivor test raced too: under load the cell child's `trap '' TERM` could land after the teardown's SIGTERM, and the 5 s escalation could SIGKILL it. The child now signals once its trap is installed, and the test turns escalation off (the escalation is not what it pins). New tests/hermes_cli/test_process_identity_release_once_empty.py: an unreaped empty group is released, a live member keeps it covered, a late exit is waited for, and Windows never releases. Teeth: forcing the helper to always release, or to never release, turns 5 tests red in the 4 touched files. macOS: the 9 owned-child files, 78 tests, 3 runs in a row, 0 flaky.
160 lines
7.3 KiB
Python
160 lines
7.3 KiB
Python
"""POSIX session kernels: the kernel's whole process group dies with its owner, however the owner dies.
|
|
|
|
The owners are real host processes importing the real ``tools.code_kernel._spawn``; each starts a kernel
|
|
whose cell spawns a subprocess of its own (it stays in the kernel's group, as every cell child does).
|
|
One owner is SIGKILLed so no Python cleanup of ours can run; the other is the live-owner control.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
import psutil
|
|
import pytest
|
|
|
|
from tests.waiting import wait_until
|
|
|
|
pytestmark = pytest.mark.platforms("posix")
|
|
|
|
_OWNER = r'''
|
|
import json, sys, time
|
|
from pathlib import Path
|
|
root, role = Path(sys.argv[1]), sys.argv[2]
|
|
from tools.code_kernel import SessionKernel, _spawn
|
|
k = SessionKernel((role,))
|
|
_spawn(k, task_id=role, child_python=sys.executable, child_cwd="", sandbox_tools=frozenset(),
|
|
max_tool_calls=1)
|
|
receipt = str(root / (role + ".json"))
|
|
code = (
|
|
"import json, os, subprocess, sys, time, psutil\n"
|
|
"c = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)'])\n"
|
|
"ids = {n: {'pid': p.pid, 'created': p.create_time()} for n, p in"
|
|
" (('kernel', psutil.Process()), ('cell_child', psutil.Process(c.pid)))}\n"
|
|
"open(%r + '.tmp', 'w').write(json.dumps(ids)); os.replace(%r + '.tmp', %r)\n"
|
|
"time.sleep(120)\n") % (receipt, receipt, receipt)
|
|
k.proc.stdin.write((json.dumps({"id": "x", "code": code}) + "\n").encode())
|
|
k.proc.stdin.flush()
|
|
while True:
|
|
time.sleep(1)
|
|
'''
|
|
|
|
|
|
def _alive(identity):
|
|
"""Zombie-aware: a killed non-child lingers as a zombie until its reaper collects it."""
|
|
try:
|
|
proc = psutil.Process(identity["pid"])
|
|
return proc.create_time() == identity["created"] and proc.status() != psutil.STATUS_ZOMBIE
|
|
except psutil.NoSuchProcess:
|
|
return False
|
|
|
|
|
|
def _read(path):
|
|
wait_until(path.exists, timeout=30, what=f"kernel receipt {path.name}")
|
|
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
|
|
|
|
# The kernel and its cell child reparent to init once their owner dies, so cleaning them up leaves
|
|
# this test's subtree; real signal delivery to a real orphan is the point of the test.
|
|
@pytest.mark.live_system_guard_bypass
|
|
def test_owner_sigkill_reaps_the_kernel_group_not_a_live_owners(tmp_path):
|
|
script = tmp_path / "disposable owner.py"
|
|
script.write_text(_OWNER, encoding="utf-8")
|
|
env = dict(os.environ, HOME=str(tmp_path), HERMES_HOME=str(tmp_path / "home"),
|
|
PYTHONPATH=str(Path(__file__).resolve().parents[2]))
|
|
ids = {}
|
|
with (tmp_path / "owners.log").open("w", encoding="utf-8") as log:
|
|
owners = {role: subprocess.Popen([sys.executable, str(script), str(tmp_path), role], env=env,
|
|
stdin=subprocess.DEVNULL, stdout=log, stderr=log)
|
|
for role in ("control", "victim")}
|
|
try:
|
|
ids = {role: _read(tmp_path / f"{role}.json") for role in owners}
|
|
for role in owners:
|
|
assert os.getpgid(ids[role]["cell_child"]["pid"]) == ids[role]["kernel"]["pid"], (
|
|
"the cell's subprocess left the kernel's group", role)
|
|
|
|
owners["victim"].kill() # SIGKILL: no atexit, no finally, nothing of ours runs
|
|
owners["victim"].wait(timeout=10)
|
|
|
|
wait_until(lambda: not any(_alive(i) for i in ids["victim"].values()), timeout=15,
|
|
what="the dead owner's kernel and its cell child to exit")
|
|
assert all(_alive(i) for i in ids["control"].values()), (
|
|
"a live owner's kernel group was killed", ids["control"])
|
|
finally:
|
|
for identity in (i for group in ids.values() for i in group.values()):
|
|
if _alive(identity):
|
|
psutil.Process(identity["pid"]).kill()
|
|
for owner in owners.values():
|
|
if owner.poll() is None:
|
|
owner.kill()
|
|
owner.wait(timeout=10)
|
|
|
|
|
|
def _kernel_with_cell_child(monkeypatch, tmp_path, child_argv):
|
|
"""A real kernel (spawn registration captured) running a cell that spawned *child_argv*."""
|
|
from hermes_cli import process_identity
|
|
from tools.code_kernel import SessionKernel, _spawn
|
|
|
|
calls = []
|
|
for verb, name in (("register", "own_process_groups"), ("unregister", "release_process_groups")):
|
|
monkeypatch.setattr(process_identity, name, lambda pgids, verb=verb: calls.append((verb, list(pgids))))
|
|
monkeypatch.setattr(process_identity, "register_child", lambda pid, purpose: None)
|
|
kernel = SessionKernel(("graceful",))
|
|
_spawn(kernel, task_id="graceful", child_python=sys.executable, child_cwd="",
|
|
sandbox_tools=frozenset(), max_tool_calls=1)
|
|
receipt = tmp_path / "cell_child.json"
|
|
code = ("import json, os, subprocess, time, psutil\n"
|
|
f"c = subprocess.Popen({child_argv!r})\n"
|
|
"ids = {'pid': c.pid, 'created': psutil.Process(c.pid).create_time()}\n"
|
|
f"open({str(receipt)!r} + '.tmp', 'w').write(json.dumps(ids))\n"
|
|
f"os.replace({str(receipt)!r} + '.tmp', {str(receipt)!r})\n"
|
|
"time.sleep(60)\n")
|
|
kernel.proc.stdin.write((json.dumps({"id": "x", "code": code}) + "\n").encode())
|
|
kernel.proc.stdin.flush()
|
|
return kernel, _read(receipt), calls
|
|
|
|
|
|
def _kill(identity):
|
|
if _alive(identity):
|
|
psutil.Process(identity["pid"]).kill()
|
|
wait_until(lambda: not _alive(identity), what="cell child cleanup")
|
|
|
|
|
|
def test_teardown_releases_the_kernels_group(monkeypatch, tmp_path):
|
|
kernel, child, calls = _kernel_with_cell_child(
|
|
monkeypatch, tmp_path, [sys.executable, "-c", "import time; time.sleep(60)"])
|
|
try:
|
|
assert calls == [("register", [kernel.proc.pid])]
|
|
kernel.teardown()
|
|
assert not _alive(child), "teardown left the cell's subprocess running"
|
|
assert calls[-1] == ("unregister", [kernel.proc.pid]), (
|
|
"a kernel group this process tore down itself stayed registered with the owner-death supervisor")
|
|
finally:
|
|
kernel.teardown()
|
|
_kill(child)
|
|
|
|
|
|
# The surviving cell child is orphaned once its kernel exits; killing it leaves the test subtree.
|
|
@pytest.mark.live_system_guard_bypass
|
|
def test_teardown_keeps_covering_a_cell_child_that_survived_it(monkeypatch, tmp_path):
|
|
# Ignores the teardown's SIGTERM. Escalation is off so a kernel slow to exit under load cannot
|
|
# turn the TERM into a tree SIGKILL that takes the child too (the precondition, not the point).
|
|
from tools import code_execution_tool
|
|
real_kill = code_execution_tool._kill_process_group
|
|
monkeypatch.setattr(code_execution_tool, "_kill_process_group",
|
|
lambda proc, escalate=False: real_kill(proc, escalate=False))
|
|
# The ready file is written only once the trap is in place: the receipt alone races a loaded
|
|
# host, where the teardown's SIGTERM can land before sh has installed it.
|
|
ready = tmp_path / "trapped"
|
|
kernel, child, calls = _kernel_with_cell_child(
|
|
monkeypatch, tmp_path, ["sh", "-c", f"trap '' TERM; : > '{ready}'; exec sleep 60"])
|
|
try:
|
|
wait_until(ready.exists, what="the cell child to ignore SIGTERM")
|
|
kernel.teardown()
|
|
assert _alive(child), "precondition: the TERM-ignoring cell child survived teardown"
|
|
assert calls == [("register", [kernel.proc.pid])], (
|
|
"the group was released while a cell child was still in it")
|
|
finally:
|
|
_kill(child)
|