Files
codebase-memory-mcp/tests/test_parallel_harness_contract.sh
T
Martin Vogel 0d783bcca4 test(harness): hold the scheduler contract's fixtures by construction
The parallel-scheduler contract failed twice on test-windows CLANG64 while
running fake suites only:

- PR #2394 (run 36364479687): "Windows timeout race refused without a
  surviving descendant to refuse over". The refusal came 38s after the
  forced leader exit (a cold powershell/CIM descendant probe, once in the
  wave loop and again in the cleanup pass), while the fixture's descendant
  was a `time.sleep(30)` that had already exited on its own.
- PR #2345 (run 36199749518): "suite 'hang_after_summary' taskkill could
  not prove process-tree cleanup". The scheduler bounded taskkill.exe with
  --kill-grace, which the contract passes as 1s, so a slow cold start of
  the tool was reported as a failed cleanup.

Both verdicts were a race between a clock and runner latency. An audit of
the fixture found more of the same shape:

- Every hanging fixture ended on its own after 30s. A starved scheduler
  could therefore see a "hung" leader exit 0, and a descendant the
  scheduler failed to kill could vanish before the leak check looked.
- The 1s --timeout could kill a slow leader before it had printed its
  summary (asserted pass=1), spawned its descendant or written the
  descendant pid. That pid was then read back with int() from a file
  written create-then-write, so a reader could see no file or an empty one.
  The empty-file read recorded earlier was the scheduler's ready file,
  which 3b9052c9 already publishes atomically; the fixture's own writer
  had the same shape.
- The descendant installed its SIGTERM-ignore during its own start-up. A
  SIGTERM that won that race skipped the SIGKILL escalation the POSIX leg
  exists to cover, silently and with the verdict unchanged.
- On Windows, the stubborn-tree leak check was a single look right after
  taskkill, but TerminateProcess returns before its target has exited.

Fix it by construction, not by budget:

- A gate. Every process that must outlive the scheduler's decision blocks
  reading stdin. That is a pipe whose only write end the contract holds,
  handed down through the scheduler, and the read returns only at EOF. The
  contract closes the pipe once the verdict is recorded. If the contract
  dies, EOF releases everything, so nothing is orphaned and no fixture
  ends on a clock.
- The three hanging scenarios run under the scheduler's existing
  pre-terminate barrier. They are released only once the fixture has
  published `<suite>.established`, which happens after the summary is
  flushed, the descendant has confirmed it is armed, and its pid is known.
  The marker is written to a temp file and moved into place with
  os.replace, so a reader sees either no file or the whole file.
- The Windows leak check waits on the descendant's process handle. That
  wait is exact, not a race: the descendant is held by the gate, so the
  scheduler's kill is the only way it can end.
- run-test-wave.py runs taskkill through windows_taskkill_tree() with its
  own stable-state budget (WINDOWS_TASKKILL_SECONDS = 15), as 132e8fc3 did
  for the descendant probe. --kill-grace keeps bounding what its name
  says. Real runs already pass --kill-grace 15, so they are unchanged.
  The contract pins this structurally, the same way it pins the probe:
  exactly one taskkill site, no kill-grace there, and a helper bounded by
  the constant that fails closed on a timeout, a start failure or a
  non-zero exit.

Every assertion keeps its contract:
- A leader that hangs after a green summary is recorded as rc=124 pass=1.
- A zero-test child is recorded as rc=97.
- The wave continues after a bounded failure.
- A SIGTERM-resistant tree is killed in full.
- A leader that exits between the timeout decision and termination is
  still cleaned up on POSIX. On Windows the scheduler refuses (rc=2,
  naming the cleanup failure) over a descendant that is really alive.
A new self-check fails loudly if a held leader or descendant is not live
while the barrier holds, that is, if the fixture no longer exercises what
those assertions claim.

Proof, on macOS arm64 and a Linux arm64 container. The Windows branch was
reasoned through; the VM is down.
- Torture hook (proof only) that delays each hanging leader 2s, past the
  1s timeout. The old fixture is RED 3/3 on every scenario (pass=0,
  FileNotFoundError on descendant.pid, "lost its bounded result"). The new
  one is green, including at 8s, and 10/10 under the hook plus CPU load.
- The #2394 mechanism: after an injected 31s refusal latency the old
  descendant is gone and the new one is live. It exits 0.02s after the
  gate closes.
- The taskkill pin is RED on the old scheduler and green on the new one.
- Stress, 20 runs each under CPU load. The old full contract passed 20/20
  on macOS and 20/20 on Linux, so these hosts do not reproduce the CI
  timing. The rebuilt held scenarios passed 20/20 on macOS and 20/20 on
  Linux. The full contract after the change passes a single run on macOS
  and on Linux, and 20/20 on each leg under CPU load (4 load workers;
  the Linux container is capped at 4 CPUs).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-28 19:45:45 +02:00

657 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
# Regression contract for the cross-platform parallel suite scheduler.
#
# On real Windows/MSYS2, a native suite completed and printed its green
# summary, but the exported `run_one` function's nested `bash -c` worker never
# returned to xargs or appended its result. The native process was gone; the
# orphaned MSYS shell could not be terminated, so the whole gate waited
# forever. Keep process ownership in one Python parent and forbid that nested
# shell-worker shape from returning.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
driver="$ROOT/scripts/run-tests-parallel.sh"
scheduler="$ROOT/scripts/run-test-wave.py"
fixture="$(mktemp -d "${TMPDIR:-/tmp}/cbm-parallel-harness.XXXXXX")"
trap 'rm -rf -- "$fixture"' EXIT
if grep -Eq '(^|[[:space:]])xargs([[:space:]]|$)|export[[:space:]]+-f|bash[[:space:]]+-c' \
"$driver"; then
echo "FAIL: parallel harness must not put native runners behind nested MSYS bash workers" >&2
exit 1
fi
if [ ! -f "$scheduler" ]; then
echo "FAIL: parent-owned parallel scheduler is missing: $scheduler" >&2
exit 1
fi
if ! grep -Fq 'run-test-wave.py' "$driver"; then
echo "FAIL: parallel harness is not wired to the parent-owned scheduler" >&2
exit 1
fi
# The Windows descendant proof must not be timed by --kill-grace. That argument
# bounds how long a *process* may resist termination (this file runs the
# scheduler with 1s); the probe is a cold PowerShell + CIM start that routinely
# costs seconds on a runner. Binding one to the other made the verdict a
# function of interpreter latency: a slow start became "assume the worst" and
# reddened an already-clean shard. Asserted structurally -- no sleeps, no timing
# thresholds -- so the contract stays deterministic on every platform.
# (Command substitution, not `| grep -q`: under pipefail an early-exiting
# reader can hand the writer EPIPE and turn a satisfied match into status 141.)
probe_sites=$(grep -n 'windows_tree_cleanup_blocker(' "$scheduler" || true)
if [[ "$probe_sites" == *kill_grace* ]]; then
echo "FAIL: the Windows descendant probe is still timed by --kill-grace" >&2
exit 1
fi
# Same defect one call earlier (#2345): taskkill /F cannot be resisted, so its
# runtime is taskkill.exe's own cold start, and bounding it with --kill-grace
# reported a slow start as a failed cleanup of an ordinary hung suite. One
# taskkill site, owned by a helper that takes no kill-grace at all.
taskkill_sites=$(grep -c '"taskkill.exe"' "$scheduler" || true)
if [ "$taskkill_sites" != 1 ]; then
echo "FAIL: the scheduler must run taskkill.exe from exactly one site (found $taskkill_sites)" >&2
exit 1
fi
kill_sites=$(grep -n 'windows_taskkill_tree(' "$scheduler" || true)
if [ -z "$kill_sites" ] || [[ "$kill_sites" == *kill_grace* ]]; then
echo "FAIL: taskkill is not run through windows_taskkill_tree(), or is still timed by --kill-grace" >&2
exit 1
fi
# Barrier files must be published atomically. Path.write_text creates and
# truncates before it writes, so a poller that saw `<suite>.ready` appear and
# then parsed the leader pid could read the zero-byte window and fail on
# int("") -- a scheduler-side race surfacing as a harness flake. Asserted
# structurally: no barrier file is written in place, and the scheduler renames
# a same-directory temp file onto the destination instead.
barrier_writes=$(grep -nE '^[[:space:]]*(ready|leader_exited)\.write_text\(' "$scheduler" || true)
if [ -n "$barrier_writes" ]; then
echo "FAIL: scheduler barrier files are written in place (non-atomic):" >&2
echo "$barrier_writes" >&2
exit 1
fi
if ! grep -Fq 'os.replace(' "$scheduler"; then
echo "FAIL: scheduler does not rename barrier files into place" >&2
exit 1
fi
python3 - "$scheduler" <<'PROBE'
from __future__ import annotations
import importlib.util
import subprocess
import sys
spec = importlib.util.spec_from_file_location("cbm_run_test_wave", sys.argv[1])
module = importlib.util.module_from_spec(spec)
# @dataclass resolves its own module out of sys.modules; register before exec.
sys.modules[spec.name] = module
spec.loader.exec_module(module)
budget = getattr(module, "WINDOWS_DESCENDANT_PROBE_SECONDS", None)
if not isinstance(budget, int) or budget < 15:
raise SystemExit(
"FAIL: the descendant probe has no independent budget "
f"(WINDOWS_DESCENDANT_PROBE_SECONDS={budget!r})"
)
probe = module.windows_tree_cleanup_blocker
original_run = subprocess.run
observed: list[object] = []
def timing_out(*args: object, **kwargs: object) -> object:
observed.append(kwargs.get("timeout"))
raise subprocess.TimeoutExpired(cmd="probe", timeout=kwargs.get("timeout"))
class _Completed:
def __init__(self, stdout: str, returncode: int = 0) -> None:
self.returncode = returncode
self.stdout = stdout
kill_budget = getattr(module, "WINDOWS_TASKKILL_SECONDS", None)
if not isinstance(kill_budget, int) or kill_budget < 15:
raise SystemExit(
"FAIL: taskkill has no independent budget "
f"(WINDOWS_TASKKILL_SECONDS={kill_budget!r})"
)
taskkill = module.windows_taskkill_tree
kill_observed: list[object] = []
def taskkill_timing_out(*args: object, **kwargs: object) -> object:
kill_observed.append(kwargs.get("timeout"))
raise subprocess.TimeoutExpired(cmd="taskkill", timeout=kwargs.get("timeout"))
def cannot_start(*args: object, **kwargs: object) -> object:
raise OSError("taskkill.exe cannot start")
try:
subprocess.run = timing_out
timed_out_reason = probe(4321)
subprocess.run = lambda *a, **k: _Completed("3\n")
live_reason = probe(4321)
subprocess.run = lambda *a, **k: _Completed("0\n")
clean_reason = probe(4321)
subprocess.run = taskkill_timing_out
kill_timed_out = taskkill(4321)
subprocess.run = lambda *a, **k: _Completed("", 0)
kill_succeeded = taskkill(4321)
subprocess.run = lambda *a, **k: _Completed("", 128)
kill_failed = taskkill(4321)
subprocess.run = cannot_start
kill_unstartable = taskkill(4321)
finally:
subprocess.run = original_run
if kill_observed != [kill_budget]:
raise SystemExit(
f"FAIL: taskkill is not bounded by its own budget (timeouts={kill_observed})"
)
if kill_succeeded is not True or (kill_timed_out, kill_failed, kill_unstartable) != (
False,
False,
False,
):
raise SystemExit(
"FAIL: taskkill stopped failing closed "
f"(ok={kill_succeeded!r}, timed_out={kill_timed_out!r}, "
f"failed={kill_failed!r}, unstartable={kill_unstartable!r})"
)
if observed != [budget] * len(observed):
raise SystemExit(
"FAIL: the descendant probe is not bounded by its own budget "
f"(timeouts={observed})"
)
if len(observed) < 2:
raise SystemExit(
"FAIL: the descendant probe does not retry a timed-out probe "
f"(attempts={len(observed)})"
)
if timed_out_reason is None or live_reason is None:
raise SystemExit(
"FAIL: the descendant probe stopped failing closed "
f"(timed_out={timed_out_reason!r}, live={live_reason!r})"
)
if clean_reason is not None:
raise SystemExit(f"FAIL: a clean tree was not proven clean ({clean_reason!r})")
if "could not complete" not in timed_out_reason:
raise SystemExit(
f"FAIL: an unfinished probe is not named as one ({timed_out_reason!r})"
)
if "3 live descendant" not in live_reason:
raise SystemExit(
f"FAIL: proven descendants are not reported with their count ({live_reason!r})"
)
if timed_out_reason == live_reason:
raise SystemExit(
"FAIL: an unfinished probe and a leaked tree are reported identically"
)
PROBE
mkdir "$fixture/barrier"
# The fixture suites. Nothing here ends on a clock: a process that must outlive
# the scheduler's decision is HELD on the gate -- the read end of a pipe whose
# only writer is held_wave.py, handed down as stdin through the scheduler -- and
# returns only at EOF, when the contract has recorded its verdict and closed
# the write end (or has itself died, so nothing is left orphaned). The previous
# `time.sleep(30)` raced that lifetime against the scheduler: on a slow Windows
# runner the leader-exit refusal took 38s, and the "surviving" descendant had
# already exited on its own before it could be observed (#2394).
cat >"$fixture/fake_runner.py" <<'PY'
from __future__ import annotations
import os
import pathlib
import subprocess
import sys
DESCENDANT = "\n".join(
(
"import os, signal, sys",
"if os.name != 'nt':",
" signal.signal(signal.SIGTERM, signal.SIG_IGN)",
"print('armed', flush=True)",
"sys.stdin.buffer.read()",
)
)
state = pathlib.Path(sys.argv[1])
suite = sys.argv[-1]
def publish(name: str, text: str) -> None:
# Rename into place: a reader sees no file or all of it, never the empty
# window between create and write that once failed a run with int('').
temporary = state / f".{name}.{os.getpid()}.tmp"
temporary.write_text(text, encoding="utf-8")
os.replace(temporary, state / name)
def hold() -> None:
sys.stdin.buffer.read()
if suite == "hang_after_summary":
print(" 1 passed", flush=True)
publish(f"{suite}.established", "none\n")
hold()
elif suite in ("stubborn_tree", "timeout_exit_race"):
child = subprocess.Popen(
[sys.executable, "-c", DESCENDANT],
stdin=0,
stdout=subprocess.PIPE,
text=True,
)
# Blocks until the descendant has installed its SIGTERM handler, so the
# POSIX legs reach the SIGKILL escalation instead of racing it.
armed = child.stdout.readline().strip() == "armed"
print(" 1 passed", flush=True)
publish(f"{suite}.established", f"{child.pid}\n" if armed else "unarmed\n")
hold()
elif suite == "no_summary":
pass
else:
print(" 1 passed", flush=True)
PY
# Drives one scheduler wave whose hanging suite is parked at its timeout by the
# pre-terminate barrier and released only once the fixture has ESTABLISHED
# everything the assertions read: summary printed, descendant spawned and
# armed, its pid published. The scheduler's 1s timeout is a wall clock, so
# releasing on `ready` alone let it kill a slow leader before any of that
# existed.
cat >"$fixture/held_wave.py" <<'PY'
from __future__ import annotations
import ctypes
import os
import pathlib
import signal
import subprocess
import sys
import time
# Liveness backstop, never a verdict. Every wait below is for a MONOTONIC state
# -- a published file stays published, an exited process stays exited -- so
# the bound only turns "never happened" into a FAIL instead of a hang, and a
# passing run never approaches it.
BACKSTOP_SECONDS = 120
POLL_SECONDS = 0.02
scheduler, fixture_arg, python, scenario = sys.argv[1:5]
fixture = pathlib.Path(fixture_arg)
barrier = fixture / "barrier"
results = fixture / "results.txt"
def process_state(pid: int) -> str:
if os.name == "nt":
handle = ctypes.windll.kernel32.OpenProcess(0x101000, False, pid)
if not handle:
return "gone"
code = ctypes.c_ulong()
active = (
ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
and code.value == 259
)
ctypes.windll.kernel32.CloseHandle(handle)
return "live" if active else "gone"
try:
os.kill(pid, 0)
except ProcessLookupError:
return "gone"
stat_path = pathlib.Path("/proc") / str(pid) / "stat"
if stat_path.exists():
state = stat_path.read_text(encoding="utf-8", errors="replace").rsplit(
")", 1
)[-1].strip()
if state.startswith("Z "):
return "zombie"
return "live"
def terminated(pid: int) -> bool:
"""Whether a process the scheduler was obliged to kill is gone.
POSIX answers in one look: the scheduler returns only after the whole
process group has vanished. Windows must wait on the handle, because
TerminateProcess returns before its target has finished exiting. That wait
is exact rather than a race: the process is held on the gate, so a kill is
the only way it can ever end -- killed, it ends; missed, it never does.
"""
if os.name != "nt":
return process_state(pid) != "live"
handle = ctypes.windll.kernel32.OpenProcess(0x100000, False, pid)
if not handle:
return True
try:
return (
ctypes.windll.kernel32.WaitForSingleObject(handle, BACKSTOP_SECONDS * 1000)
== 0
)
finally:
ctypes.windll.kernel32.CloseHandle(handle)
def force_cleanup(pid: int) -> None:
if process_state(pid) != "live":
return
if os.name == "nt":
subprocess.run(
["taskkill.exe", "/PID", str(pid), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
os.kill(pid, signal.SIGKILL)
def wait_for(process: subprocess.Popen[str], what: str, *paths: pathlib.Path) -> None:
deadline = time.monotonic() + BACKSTOP_SECONDS
while not all(path.exists() for path in paths):
if process.poll() is not None:
_, stderr = process.communicate()
raise SystemExit(
f"FAIL: scheduler exited before {what} "
f"(rc={process.returncode}, stderr={stderr!r})"
)
if time.monotonic() >= deadline:
raise SystemExit(f"FAIL: {what} never happened")
time.sleep(POLL_SECONDS)
def held_wave(suites: list[str], held: str, check, act=None) -> None:
(fixture / "suites.txt").write_text(
"".join(f"{suite}\n" for suite in suites), encoding="utf-8"
)
results.write_text("", encoding="utf-8")
# A stale marker from an earlier wave would satisfy a wait below at once.
for stale in ("ready", "established", "leader-exited", "release"):
(barrier / f"{held}.{stale}").unlink(missing_ok=True)
(barrier / f"{held}.hold").write_text("", encoding="utf-8")
ready = barrier / f"{held}.ready"
established = barrier / f"{held}.established"
release = barrier / f"{held}.release"
# The gate. This process holds the only write end (os.pipe() descriptors
# are never inherited); the scheduler, the suite leader and its descendant
# all read the other end as stdin.
gate_read, gate_write = os.pipe()
try:
process = subprocess.Popen(
[
python,
scheduler,
"--suite-file",
str(fixture / "suites.txt"),
"--log-dir",
str(fixture / "logs"),
"--results-file",
str(results),
"--jobs",
"1",
"--timeout",
"1",
"--slow-timeout",
"1",
"--kill-grace",
"1",
"--test-pre-terminate-barrier-dir",
str(barrier),
python,
str(fixture / "fake_runner.py"),
str(barrier),
],
stdin=gate_read,
stderr=subprocess.PIPE,
text=True,
)
except BaseException:
os.close(gate_write)
raise
finally:
os.close(gate_read)
descendant = None
try:
wait_for(
process,
f"{held} reached its timeout with the fixture established",
ready,
established,
)
# Both are held -- the scheduler parked at the barrier, the fixture on
# the gate -- so anything but "live" means the fixture is broken and
# would no longer exercise what the assertions below claim.
leader = int(ready.read_text(encoding="utf-8"))
marker = established.read_text(encoding="utf-8").strip()
if marker != "none":
if not marker.isdigit():
raise SystemExit(f"FAIL: {held} could not arm its descendant ({marker!r})")
descendant = int(marker)
for role, pid in (("leader", leader), ("descendant", descendant)):
state = "live" if pid is None else process_state(pid)
if state != "live":
raise SystemExit(f"FAIL: {held} {role} {pid} is not held by the gate ({state})")
if act is not None:
act(process, leader)
release.write_text("release\n", encoding="utf-8")
# Generous on purpose: on Windows a refusal spends up to the
# descendant-probe budget twice (wave loop, then the cleanup pass).
_, stderr = process.communicate(timeout=BACKSTOP_SECONDS)
check(process.returncode, stderr, descendant)
finally:
if not release.exists():
release.write_text("release\n", encoding="utf-8")
if process.poll() is None:
process.kill()
process.wait()
# Only now -- verdict recorded -- does anything held get to end.
os.close(gate_write)
if descendant is not None and not terminated(descendant):
force_cleanup(descendant)
def expect_clean_exit(returncode: int, stderr: str, descendant: int | None) -> None:
if returncode != 0:
raise SystemExit(f"FAIL: scheduler wave failed (rc={returncode}, stderr={stderr!r})")
def expect_tree_killed(returncode: int, stderr: str, descendant: int | None) -> None:
expect_clean_exit(returncode, stderr, descendant)
if not terminated(descendant):
raise SystemExit("FAIL: timed-out suite left a stubborn descendant alive")
def kill_leader_first(process: subprocess.Popen[str], leader: int) -> None:
# The race under test: the leader exits after the timeout decision but
# before the scheduler terminates its tree.
os.kill(leader, signal.SIGTERM)
wait_for(
process,
"the scheduler observed the forced leader exit",
barrier / "timeout_exit_race.leader-exited",
)
def expect_race_handled(returncode: int, stderr: str, descendant: int | None) -> None:
if os.name == "nt":
# Assert the PROPERTY, not the wording: the scheduler refused (rc=2)
# AND did not quietly leave a live descendant behind as if cleanup had
# succeeded. The descendant is held on the gate, so it is still there
# to be refused over however long the refusal took.
if returncode != 2:
raise SystemExit(
f"FAIL: Windows timeout race did not fail closed "
f"(rc={returncode}, stderr={stderr!r})"
)
if process_state(descendant) != "live":
raise SystemExit(
"FAIL: Windows timeout race refused without a surviving descendant "
"to refuse over -- the fixture no longer exercises the race"
)
if "cleanup" not in stderr.lower():
raise SystemExit(
f"FAIL: Windows timeout race refused without naming a cleanup "
f"failure (stderr={stderr!r})"
)
return
if returncode != 0:
raise SystemExit(
f"FAIL: POSIX timeout race cleanup failed (rc={returncode}, stderr={stderr!r})"
)
if not results.read_text(encoding="utf-8").startswith(
"timeout_exit_race rc=124 pass=1 fail=0 skip=0 secs="
):
raise SystemExit("FAIL: POSIX timeout race lost its bounded result")
if not terminated(descendant):
raise SystemExit("FAIL: POSIX timeout race leaked the surviving descendant")
if scenario == "hang_after_summary":
held_wave(
["hang_after_summary", "no_summary", "pass_after"],
"hang_after_summary",
expect_clean_exit,
)
elif scenario == "stubborn_tree":
held_wave(["stubborn_tree"], "stubborn_tree", expect_tree_killed)
elif scenario == "timeout_exit_race":
held_wave(
["timeout_exit_race"],
"timeout_exit_race",
expect_race_handled,
act=kill_leader_first,
)
else:
raise SystemExit(f"FAIL: unknown held-wave scenario {scenario!r}")
PY
seq 1 32 | sed 's/^/pass_/' >"$fixture/suites.txt"
: >"$fixture/results.txt"
python3 "$scheduler" \
--suite-file "$fixture/suites.txt" \
--log-dir "$fixture/logs" \
--results-file "$fixture/results.txt" \
--jobs 8 \
--timeout 5 \
--slow-timeout 5 \
--kill-grace 1 \
"$(command -v python3)" "$fixture/fake_runner.py" "$fixture/barrier"
if [ "$(wc -l <"$fixture/results.txt" | tr -d ' ')" -ne 32 ] ||
grep -qvE '^pass_[0-9]+ rc=0 pass=1 fail=0 skip=0 secs=[0-9]+$' \
"$fixture/results.txt"; then
echo "FAIL: scheduler lost or corrupted a completed child's result" >&2
cat "$fixture/results.txt" >&2
exit 1
fi
python3 "$fixture/held_wave.py" "$scheduler" "$fixture" "$(command -v python3)" \
hang_after_summary
if ! grep -qE '^hang_after_summary rc=124 pass=1 fail=0 skip=0 secs=[0-9]+$' \
"$fixture/results.txt"; then
echo "FAIL: scheduler did not bound and record a child that stayed alive after its summary" >&2
cat "$fixture/results.txt" >&2
exit 1
fi
if ! grep -qE '^no_summary rc=97 pass=0 fail=0 skip=0 secs=[0-9]+$' \
"$fixture/results.txt"; then
echo "FAIL: scheduler accepted a zero-test child" >&2
cat "$fixture/results.txt" >&2
exit 1
fi
if ! grep -qE '^pass_after rc=0 pass=1 fail=0 skip=0 secs=[0-9]+$' \
"$fixture/results.txt"; then
echo "FAIL: scheduler did not continue after a bounded child failure" >&2
cat "$fixture/results.txt" >&2
exit 1
fi
python3 "$fixture/held_wave.py" "$scheduler" "$fixture" "$(command -v python3)" \
stubborn_tree
printf '%s\n' pass_after >"$fixture/suites.txt"
: >"$fixture/results.txt"
: >"$fixture/barrier/pass_after.hold"
python3 - "$scheduler" "$fixture" "$(command -v python3)" <<'PY'
from __future__ import annotations
import pathlib
import subprocess
import sys
import time
scheduler = sys.argv[1]
fixture = pathlib.Path(sys.argv[2])
python = sys.argv[3]
results = fixture / "results.txt"
barrier = fixture / "barrier"
process = subprocess.Popen(
[
python,
scheduler,
"--suite-file",
str(fixture / "suites.txt"),
"--log-dir",
str(fixture / "logs"),
"--results-file",
str(results),
"--jobs",
"1",
"--timeout",
"5",
"--slow-timeout",
"5",
"--kill-grace",
"1",
"--test-post-exit-barrier-dir",
str(barrier),
python,
str(fixture / "fake_runner.py"),
str(barrier),
]
)
try:
deadline = time.monotonic() + 5
while not (barrier / "pass_after.ready").exists():
if process.poll() is not None:
raise SystemExit(
f"FAIL: scheduler exited before exposing the post-exit barrier "
f"(rc={process.returncode})"
)
if time.monotonic() >= deadline:
raise SystemExit("FAIL: scheduler never reached the post-exit barrier")
time.sleep(0.02)
if results.read_text(encoding="utf-8"):
raise SystemExit("FAIL: scheduler recorded a result before the forced barrier released")
(barrier / "pass_after.release").write_text("release\n", encoding="utf-8")
returncode = process.wait(timeout=5)
if returncode != 0:
raise SystemExit(f"FAIL: scheduler failed after the post-exit release (rc={returncode})")
finally:
if process.poll() is None:
process.kill()
process.wait()
expected = "pass_after rc=0 pass=1 fail=0 skip=0 secs="
if not results.read_text(encoding="utf-8").startswith(expected):
raise SystemExit("FAIL: exited child result was lost after the forced barrier")
PY
python3 "$fixture/held_wave.py" "$scheduler" "$fixture" "$(command -v python3)" \
timeout_exit_race
echo "Parallel harness contract passed"