Files
hermes-agent/tests/tools/test_process_registry_list_exit.py
Indigo Karasu 41380ccef9 fix(process): list refreshes no longer leave exited children running
Carve the direct-child list reconciliation from Indigo Karasu's earliest
PR #60506 (2a96ae2cbf806ccdc3e9b584911774f32622f421), corroborated by
fangliquanflq's narrow #81385 (50ffd243d92627e4a03a3ee8427ad0f5e090c3ab).
Run the existing helper after task/session filtering and reuse the
idempotent owner-stamped completion path. Do not import cross-session
disclosure, bare-PID healing, forget RPCs, or reader rewrites.

A real-child regression fails before this change and passes after it:
the direct child exits while its descendant keeps writing to stdout;
listing reports exit without consuming the result or waiting for EOF.

Co-authored-by: fangliquanflq <fangliquan@qq.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-09-12 22:06:03 -07:00

120 lines
5.1 KiB
Python

"""List refresh observes the child, not its descendants' capture-pipe lifetime."""
import ctypes
import json
import os
from pathlib import Path
import shlex
import signal
import subprocess
import sys
import time
import pytest
@pytest.mark.linux_only
def test_list_reconciles_real_exit_without_consuming_owned_result(tmp_path):
# A disposable subreaper owns even the orphaned writer; no global pytest
# process state is changed, and every fixture child is reaped on failure.
result = subprocess.run(
[sys.executable, str(Path(__file__).resolve()), "probe", str(tmp_path)],
cwd=Path(__file__).resolve().parents[2],
env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[2])},
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
def _probe(root):
import tools.process_registry as module
assert ctypes.CDLL(None).prctl(36, 1, 0, 0, 0) == 0 # PR_SET_CHILD_SUBREAPER
module._SYSTEMD_SCOPE_AVAILABLE = False # Own the test process tree, not a host service.
registry = module.ProcessRegistry()
sessions = []
command = f"exec {shlex.quote(sys.executable)} {shlex.quote(__file__)}"
try:
for name in ("owner", "sibling"):
session = registry.spawn_local(
f"{command} child {shlex.quote(str(root / name))}",
cwd=str(root), task_id=name + "-task", owner_task_id=name + "-owner",
session_key=name + "-session",
)
sessions.append(session)
session.notify_on_complete = True
owner, sibling = sessions
deadline = time.monotonic() + 5
while not all(s.output_buffer for s in sessions):
assert time.monotonic() < deadline, "writers did not become ready"
time.sleep(0.01)
assert all(s.process.poll() is None for s in sessions)
(root / "owner-exit").touch()
assert owner.process.wait(timeout=5) == 0
assert owner._reader_thread.is_alive() # Writer is still producing output.
started = time.monotonic()
listed = registry.list_sessions(session_key="owner-session")
elapsed = time.monotonic() - started
print(json.dumps({"direct_child": owner.process.returncode, "listed": listed,
"elapsed": elapsed, "reader_alive": owner._reader_thread.is_alive()}), flush=True)
assert elapsed < 2, "list waited for the descendant's pipe lifetime"
assert [entry["session_id"] for entry in listed] == [owner.id]
assert listed[0]["status"] == "exited"
assert listed[0]["exit_code"] == 0
event = registry.completion_queue.get(timeout=2)
assert (event["session_id"], event["session_key"], event["task_id"], event["owner_task_id"]) == (
owner.id, "owner-session", "owner-task", "owner-owner")
assert event["exit_code"] == 0 and "owner-output" in event["output"]
assert registry.unread_completions_owned_by("owner-owner") == [owner]
assert registry.unread_completions_owned_by("sibling-owner") == []
for _ in range(3):
assert registry.list_sessions(session_key="owner-session")[0]["status"] == "exited"
foreign = registry.list_sessions(session_key="sibling-session")
assert [(row["session_id"], row["status"]) for row in foreign] == [(sibling.id, "running")]
assert sibling.process.poll() is None
(root / "owner-stop").touch()
owner._reader_thread.join(timeout=5)
assert not owner._reader_thread.is_alive()
assert registry.completion_queue.empty(), "reader and list emitted duplicate completions"
assert not registry.is_completion_consumed(owner.id)
assert "owner-output" in registry.read_log(owner.id)["output"]
assert registry.is_completion_consumed(owner.id)
print("PASS: list-only exit; one exact-owner event; running sibling isolated; unread output retained", flush=True)
finally:
for name in ("owner", "sibling"):
(root / (name + "-stop")).touch()
(root / (name + "-exit")).touch()
for session in sessions:
try:
os.killpg(session.pid, signal.SIGKILL)
except ProcessLookupError:
pass
session.process.wait(timeout=5)
session._reader_thread.join(timeout=5)
# This subprocess contains only our two child trees.
while True:
try:
os.waitpid(-1, 0)
except ChildProcessError:
break
def _child(gate):
subprocess.Popen(
[sys.executable, __file__, "writer", str(gate)], stdin=subprocess.DEVNULL,
)
deadline = time.monotonic() + 15
while not Path(str(gate) + "-exit").exists() and time.monotonic() < deadline:
time.sleep(0.01)
def _writer(gate):
deadline = time.monotonic() + 15
while not Path(str(gate) + "-stop").exists() and time.monotonic() < deadline:
print(gate.name + "-output", flush=True)
time.sleep(0.02)
if __name__ == "__main__":
{"probe": _probe, "child": _child, "writer": _writer}[sys.argv[1]](Path(sys.argv[2]))