"""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, ) 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]))