Files
hermes-agent/tests/tools/test_process_registry_list_exit.py
2026-10-10 15:20:13 +02:00

195 lines
8.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
from types import SimpleNamespace
from typing import Any, cast
import pytest
@pytest.mark.platforms("posix")
def test_list_leaves_live_reader_as_completion_owner():
from tools.process_registry import ProcessRegistry, ProcessSession
registry = ProcessRegistry()
session = ProcessSession(
id="proc_owned_reader",
command="owned-reader",
task_id="owner-task",
owner_task_id="owner-owner",
session_key="owner-session",
started_at=time.time(),
notify_on_complete=True,
)
session.process = cast(Any, SimpleNamespace(
poll=lambda: 0, stdout=None, stderr=None, stdin=None,
))
session._reader_selectable = True
session._reader_thread = cast(Any, SimpleNamespace(is_alive=lambda: True))
registry._running[session.id] = session
listed = registry.list_sessions(session_key="owner-session")
assert listed[0]["status"] == "exited"
assert session._reader_finish_requested.is_set()
assert session.id in registry._running
assert registry.completion_queue.empty()
session.append_output("owner-output")
registry._move_to_finished(session)
event = registry.completion_queue.get_nowait()
assert (event["owner_task_id"], event["output"]) == (
"owner-owner", "owner-output",
)
@pytest.mark.platforms("posix")
def test_poll_during_the_readers_final_drain_keeps_the_whole_output(tmp_path):
"""A child that ends on a burst exits with its tail still in the pipe. A poll landing while the reader
is behind must report, and leave in the completion, the whole output — not the chunk read so far."""
import threading
import tools.process_registry as module
module._SYSTEMD_SCOPE_AVAILABLE = False
registry = module.ProcessRegistry()
stalled, release = threading.Event(), threading.Event()
def slow_sink(_session, _chunk): # a live-output consumer that falls behind for a moment
if not stalled.is_set():
stalled.set()
release.wait(10)
registry.on_output = slow_sink
burst = "import sys; sys.stdout.write('x' * 99 + '\\n'); sys.stdout.write(('y' * 99 + '\\n') * 100 + 'FINAL\\n')"
session = registry.spawn_local(f"{shlex.quote(sys.executable)} -c {shlex.quote(burst)}", cwd=str(tmp_path))
session.notify_on_complete = True
try:
assert stalled.wait(10)
assert session.process.wait(timeout=10) == 0
threading.Timer(0.3, release.set).start()
polled = registry.poll(session.id)
assert session._completion_event.wait(10)
assert polled["status"] == "exited"
assert polled["output_preview"].rstrip().endswith("FINAL"), polled["output_preview"][-80:]
event = registry.completion_queue.get(timeout=2)
assert event["output"].rstrip().endswith("FINAL"), event["output"][-80:]
finally:
release.set()
registry.kill_all(source="test")
@pytest.mark.platforms("linux")
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,
check=False,
)
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(name + "-output" in s.output_buffer for name, s in zip(("owner", "sibling"), 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]))