When poll/wait/list saw the direct child exit while the reader was still behind, the reconcile asked the reader to finish and flipped the session to exited at once. The reader then stopped after ONE more 4 KiB chunk, so the rest of the pipe - up to a whole pipe buffer, the job's final lines - was never read, and the caller had already been handed a snapshot without it (wait() also marked that snapshot consumed, suppressing the completion). After a finish request the reader now drains what is already buffered (zero-timeout select, capped so a grandchild that floods the pipe still cannot pin it), and the reconcile waits briefly for the reader to publish before falling back to marking the exit itself. (cherry picked from commit f8266be8ccc682482f18aaeeccb4d397b5310a9b)
194 lines
8.0 KiB
Python
194 lines
8.0 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,
|
|
)
|
|
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]))
|