scripts.run_tests_streaming
Reference › scripts › run_tests_streaming
scripts/run_tests_streaming.py is a sanctioned (RUL-009) streaming parallel test runner: it runs test modules concurrently and prints a PASS or FAIL line for each module as it finishes, rather than waiting for a whole batch. A human runs it when live feedback matters more than the full orchestration of run_tests; tests/scripts/test_run_tests_streaming.py covers it. It builds on orchestrata_ultimate.core.threading.test_executor.
Options: positional paths (default tests/; _expand turns directories into modules), -j/--workers (default 4), -t/--timeout (per module, default 420 seconds, matching the authoritative runner's per-batch budget because the slowest module measured about 300 seconds alone) and -a/--pytest-arg (repeatable). _run_one runs one module in a subprocess and _summary prints the totals. Before dispatching workers main calls _ensure_run_identity_env from the test executor, which sets the run id and head commit only if an outer caller has not already, so duration records carry a run id. As RUL-009 requires of every sanctioned runner, it injects the live pytest observer plugin into the pytest arguments, so observe_pytest can attach and per-test durations are recorded for the scheduler cache.
At a glance
| Source | scripts/run_tests_streaming.py |
| Lines of code | 305 |
| Classes | 0 |
| Functions | 5 |
| Imports (internal) | orchestrata_ultimate.core.threading.test_executor |
| Imported by | none |
| Tests that import it | tests/scripts/test_run_tests_streaming.py |
Class diagram
No classes; see Functions.
Dependency diagram
API
Functions
| Function | Signature | Summary |
|---|---|---|
_to_text |
_to_text(v: bytes | str | None) -> str |
TimeoutExpired carries partial output as BYTES even under text=True (CPython raises before the final decode) — normalize for the str-only consumers (_SUMMARY_RE, the failure printer). |
_run_one |
_run_one(cmd: list[str], timeout: float, slot: int) -> dict |
|
_expand |
_expand(paths: list[str]) -> list[str] |
|
_summary |
_summary(res: dict) -> str |
|
main |
main() -> int |
Source
#!/usr/bin/env python3
# mypy: ignore-errors
"""Streaming parallel test runner — live per-module PASS/FAIL.
Uses subprocess per module (for real-time per-module feedback).
For CI/bulk runs use ``scripts/run_tests_threadpool.py`` which uses
in-process threads.
Usage:
python scripts/run_tests_streaming.py tests/ -j 4 2>&1 | tee /tmp/stream.log
"""
from __future__ import annotations
import argparse
import glob
import os
import queue
import re
import signal
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
_SUMMARY_RE = re.compile(
r"\d+ (?:passed|failed|error|errors|skipped|deselected|xfailed|xpassed)[^\n]*"
)
def _to_text(v: bytes | str | None) -> str:
"""TimeoutExpired carries partial output as BYTES even under text=True
(CPython raises before the final decode) — normalize for the str-only
consumers (_SUMMARY_RE, the failure printer)."""
if isinstance(v, bytes):
return v.decode("utf-8", errors="replace")
return v or ""
def _run_one(cmd: list[str], timeout: float, slot: int) -> dict:
env = dict(os.environ)
env["ORCHESTRATA_WORKER_SLOT"] = f"slot{slot}"
# Use Popen with start_new_session=True to create a process group, so
# timeout kills the WHOLE tree (pytest + grandchildren like gateway/kernel).
# Bare subprocess.run(capture_output=True) only kills the direct child,
# leaving orphaned grandchildren holding stdout/stderr pipes open, which
# blocks communicate() forever after the kill (the 40-minute hang at
# session 270: a gateway test spawned a long-lived subprocess that inherited
# the pipe; pytest was killed but the grandchild kept the pipe open).
try:
with subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
start_new_session=True,
) as proc:
try:
stdout, stderr = proc.communicate(timeout=timeout)
return {
"returncode": proc.returncode,
"stdout": stdout,
"stderr": stderr,
}
except subprocess.TimeoutExpired:
# Kill the whole process group, not just the direct child.
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError, PermissionError:
proc.kill()
# Drain pipes after kill (with a short timeout to avoid another hang).
try:
stdout, stderr = proc.communicate(timeout=30)
except subprocess.TimeoutExpired:
stdout, stderr = "", ""
return {
"returncode": -1,
"stdout": stdout or "",
"stderr": stderr or "",
"error": f"Timeout after {timeout}s",
}
except Exception as exc:
return {"returncode": -1, "stdout": "", "stderr": str(exc), "error": str(exc)}
def _expand(paths: list[str]) -> list[str]:
mods: list[str] = []
for p in paths:
# A pytest node id (``tests/foo_test.py::test_bar``) is a single
# literal target, not a directory — globbing it as one silently
# matched ZERO files (the ``::`` segment doesn't exist on disk) and
# `main()` printed "no test modules matched", exit 0: a false green
# for the exact single-test-during-debugging invocation the
# route_pytest_to_threads.py guard now routes here (2026-08-19, the
# single-module/nodeid exemption it used to grant was the loophole
# that shipped duration records with no run_id/batch_id/head_commit
# and skipped the required kernel gRPC verification).
if p.endswith(".py") or "::" in p:
mods.append(p)
else:
mods.extend(
sorted(glob.glob(f"{p.rstrip('/')}/**/test_*.py", recursive=True))
)
seen: set[str] = set()
out: list[str] = []
for m in mods:
if m not in seen:
seen.add(m)
out.append(m)
return out
def _summary(res: dict) -> str:
text = (res.get("stdout") or "") + (res.get("stderr") or "")
hits = _SUMMARY_RE.findall(text)
return hits[-1] if hits else res.get("error") or "no summary"
def main() -> int:
ap = argparse.ArgumentParser(
description="Streaming parallel pytest runner (live per-module results)."
)
ap.add_argument(
"paths", nargs="*", default=["tests/"], help="test files or directories"
)
ap.add_argument("-j", "--workers", type=int, default=4)
# Default matches the authoritative runner's per-batch budget (420s): the
# suite's slowest module measured 298s SOLO (test_gen_brief_pack.py,
# 2026-09-10), so 300s here was a guaranteed false timeout under load.
ap.add_argument("-t", "--timeout", type=float, default=420.0)
ap.add_argument("-a", "--pytest-arg", action="append", default=[])
args = ap.parse_args()
# Defect found 2026-08-19: profiling reports/test-durations.ndjson's
# `kind: run` headers found ~19% missing run_id entirely -- every OTHER
# caller of the batch-subprocess machinery besides run_tests.py's own
# main() never established ORCH_RUN_ID/ORCH_HEAD_COMMIT, including this
# "sanctioned" (RUL-009, below) streaming runner. Same fix, same idiom
# as ParallelTestExecutor.__init__: establish it once here, before any
# worker subprocess is dispatched -- setdefault, so an outer caller
# (e.g. run_tests.py) that already set ORCH_RUN_ID keeps it. Imported
# locally to avoid pulling core.threading.test_executor's heavier
# import chain into every invocation of this script's --help.
from orchestrata_ultimate.core.threading.test_executor import (
_ensure_run_identity_env,
)
_ensure_run_identity_env()
# RUL-009: every sanctioned runner must inject the live observer, exactly as
# ``core.threading.test_executor`` does for the batched runner. Without it
# this runner printed its own per-module PASS/FAIL but produced NO observer
# session (``observe_pytest.py attach`` had nothing to attach to) and NO
# per-test duration records -- so ``reports/test-durations.ndjson`` and the
# LPT scheduler cache never learned anything from a streaming run, while the
# force-test-observation hook still accepted the command as "observed".
pytest_args = [
"-q",
"--no-header",
"--timeout=120",
"-p",
"orchestrata_ultimate.core.threading.pytest_live_observer",
# Same marker expression as scripts/run_tests.py: override
# pyproject.toml's default `-m "not slow and not llm_integration and not
# incus_live"` so SLOW tests RUN, while llm_integration/incus_live stay
# opt-in lanes. Without this override the runner inherited the default
# and silently DESELECTED every @pytest.mark.slow test (47 tests across
# 20 modules) while still reporting the module green -- a false green,
# and a different test set from the authoritative gate.
"-m",
"not llm_integration and not incus_live",
*args.pytest_arg,
]
modules = _expand(args.paths)
total = len(modules)
print(
f"[streaming] {total} modules · {args.workers} workers · timeout {args.timeout:.0f}s",
flush=True,
)
if not total:
print("[streaming] no test modules matched", flush=True)
return 0
slot_q: queue.Queue[int] = queue.Queue()
for i in range(args.workers):
slot_q.put(i)
start = time.monotonic()
done = passed = failed = 0
failures: list[tuple[str, dict]] = []
with ThreadPoolExecutor(max_workers=args.workers) as ex:
futs = {}
submitted = 0
last_heartbeat = time.monotonic()
pending = set()
# Submit modules and process results concurrently (not submit-all-then-wait).
# The submit loop blocks on slot_q.get() when all workers are busy, so we
# interleave submission with result processing to print per-module output
# as soon as each completes (the original 40-minute hang at session 270
# was caused by the submit loop blocking while 1843 modules were queued).
for m in modules:
# Check for completed futures while waiting for a slot
while True:
try:
slot = slot_q.get(timeout=1.0)
break
except queue.Empty:
# No slot available — process any completed futures
completed = [f for f in pending if f.done()]
for fut in completed:
pending.remove(fut)
module, slot = futs[fut]
res = fut.result()
done += 1
# Exit 5 = "no tests collected": a fully-deselected module (e.g.
# every test carries an excluded marker like llm_integration) is
# a legitimate empty outcome, not a failure — same rule as the
# batched executor's _pytest_no_tests_collected.
rc = res.get("returncode")
ok = rc == 0 or rc == 5
if ok:
passed += 1
else:
failed += 1
failures.append((module, res))
tag = ("PASS" if rc == 0 else "EMPTY") if ok else "FAIL"
elapsed = time.monotonic() - start
print(
f"[{done:>4}/{total}] {tag} {module} ({_summary(res)}) t+{elapsed:5.0f}s",
flush=True,
)
# Heartbeat: print progress every 30s if nothing completed
now = time.monotonic()
if now - last_heartbeat > 30:
elapsed = now - start
print(
f"[streaming] heartbeat: {done}/{total} done, "
f"{submitted - done} running, t+{elapsed:.0f}s",
flush=True,
)
last_heartbeat = now
# Submit the next module
cmd = [sys.executable, "-m", "pytest", m, *pytest_args]
fut = ex.submit(_run_one, cmd, args.timeout, slot)
fut.add_done_callback(lambda _f, _slot=slot: slot_q.put(_slot))
futs[fut] = (m, slot)
pending.add(fut)
submitted += 1
# Drain any remaining pending futures
while pending:
completed = [f for f in pending if f.done()]
if not completed:
# Wait a bit for futures to complete
time.sleep(0.5)
# Heartbeat while waiting
now = time.monotonic()
if now - last_heartbeat > 30:
elapsed = now - start
print(
f"[streaming] heartbeat: {done}/{total} done, "
f"{len(pending)} running, t+{elapsed:.0f}s",
flush=True,
)
last_heartbeat = now
continue
for fut in completed:
pending.remove(fut)
module, slot = futs[fut]
res = fut.result()
done += 1
rc = res.get("returncode")
ok = rc == 0 or rc == 5
if ok:
passed += 1
else:
failed += 1
failures.append((module, res))
tag = ("PASS" if rc == 0 else "EMPTY") if ok else "FAIL"
elapsed = time.monotonic() - start
print(
f"[{done:>4}/{total}] {tag} {module} ({_summary(res)}) t+{elapsed:5.0f}s",
flush=True,
)
elapsed = time.monotonic() - start
print(
f"\n[streaming] DONE in {elapsed:.0f}s · total={total} passed={passed} failed={failed}",
flush=True,
)
for module, res in failures:
out = (res.get("stdout") or "") + (res.get("stderr") or "")
print(f"\n--- FAIL {module} (rc={res.get('returncode')}) ---", flush=True)
print(out[-1200:], flush=True)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())