mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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, which3b9052c9already 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), as132e8fc3did 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>
657 lines
23 KiB
Bash
Executable File
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"
|