feat(ci): run L0/L1 offline checks, mutation suites and yamllint in bounded parallel pools
- conformance/lib/run_l1_offline.py: checks run in a thread pool (--jobs / CONF_OFFLINE_JOBS,
default min(8, usable CPUs)); results consumed in submission order, so log lines, JUnit,
step summary and the 0/1/3 aggregation are unchanged. Exports a smaller MUTATION_JOBS to
the children when parallel, so nested pools do not oversubscribe.
- keycloak-{passkey,client-secret}-reconciler/mutations.py: anchors checked first (serial),
then the --selftest subprocesses run in a pool (MUTATION_JOBS, default min(8, CPUs));
verdicts reported in MUTATIONS order. Output byte-identical to the serial loop.
- ci/lint.sh: yamllint over LINT_JOBS contiguous slices concurrently, per-slice output
buffered and printed in order; stage rc = highest slice rc.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
1a33814522
commit
a775082ae1
4 files changed
+312
-65
No files matched your search
+38
-2
@@ -25,8 +25,44 @@ if [ "${#files[@]}" -eq 0 ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
yamllint -c "${CI_DIR}/yamllint.yaml" -f "${YAMLLINT_FORMAT:-standard}" "${files[@]}"
|
# yamllint is single-threaded, so the file list is split into LINT_JOBS CONTIGUOUS slices of the
|
||||||
ok "yaml/lint passed (${#files[@]} files)"
|
# sorted list, one yamllint per slice, run concurrently. Each slice's output goes to its own file
|
||||||
|
# and the files are printed in slice order afterwards: the log is the same text, in the same
|
||||||
|
# file order, as one serial `yamllint "${files[@]}"`, and a finding is never interleaved with
|
||||||
|
# another process's output (yamllint's standard format puts the FILE NAME on its own line above
|
||||||
|
# that file's `line:col` findings, so interleaving would misattribute them). The stage's exit
|
||||||
|
# code is the highest any slice returned (yamllint: 1 = error, 2 = warning under strict), so a
|
||||||
|
# single failing slice fails the stage exactly as the serial call did. LINT_JOBS=1 is serial.
|
||||||
|
lint_jobs="${LINT_JOBS:-$(n="$(nproc 2>/dev/null || echo 1)"; [ "$n" -gt 8 ] && n=8; echo "$n")}"
|
||||||
|
case "$lint_jobs" in ''|*[!0-9]*|0) err "LINT_JOBS must be a positive integer, got '${lint_jobs}'"; exit 2 ;; esac
|
||||||
|
[ "$lint_jobs" -gt "${#files[@]}" ] && lint_jobs="${#files[@]}"
|
||||||
|
lint_tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$lint_tmp"' EXIT
|
||||||
|
per=$(( (${#files[@]} + lint_jobs - 1) / lint_jobs ))
|
||||||
|
pids=()
|
||||||
|
for (( i = 0; i < lint_jobs; i++ )); do
|
||||||
|
slice=("${files[@]:$(( i * per )):${per}}")
|
||||||
|
[ "${#slice[@]}" -gt 0 ] || break
|
||||||
|
( rc=0
|
||||||
|
yamllint -c "${CI_DIR}/yamllint.yaml" -f "${YAMLLINT_FORMAT:-standard}" "${slice[@]}" \
|
||||||
|
>"${lint_tmp}/${i}.out" 2>&1 || rc=$?
|
||||||
|
echo "$rc" >"${lint_tmp}/${i}.rc" ) &
|
||||||
|
pids+=("$!")
|
||||||
|
done
|
||||||
|
wait "${pids[@]}" || true
|
||||||
|
lint_rc=0
|
||||||
|
for (( i = 0; i < ${#pids[@]}; i++ )); do
|
||||||
|
cat "${lint_tmp}/${i}.out"
|
||||||
|
# A slice that left no rc file died before yamllint returned: that is a failure, not a pass.
|
||||||
|
slice_rc="$(cat "${lint_tmp}/${i}.rc" 2>/dev/null || true)"
|
||||||
|
case "$slice_rc" in ''|*[!0-9]*) slice_rc=1 ;; esac
|
||||||
|
[ "$slice_rc" -gt "$lint_rc" ] && lint_rc="$slice_rc"
|
||||||
|
done
|
||||||
|
if [ "$lint_rc" -ne 0 ]; then
|
||||||
|
err "yaml/lint failed (yamllint exit ${lint_rc}; ${#files[@]} files in ${#pids[@]} parallel slice(s))"
|
||||||
|
exit "$lint_rc"
|
||||||
|
fi
|
||||||
|
ok "yaml/lint passed (${#files[@]} files, ${#pids[@]} parallel slice(s))"
|
||||||
|
|
||||||
# A workflow's `configurationFile:` must name a file that exists. Renovate's did not after #79
|
# A workflow's `configurationFile:` must name a file that exists. Renovate's did not after #79
|
||||||
# renamed renovate.json -> renovate.json5, and every scheduled run failed with ENOENT for 11 days
|
# renamed renovate.json -> renovate.json5, and every scheduled run failed with ENOENT for 11 days
|
||||||
|
|||||||
@@ -43,13 +43,35 @@ Exit codes: 0 all selected checks passed / 1 at least one FAILED / 3 nothing fai
|
|||||||
scope-declared check was UNWIRED. conformance/run.sh propagates 3 verbatim; ci/l0.sh turns it
|
scope-declared check was UNWIRED. conformance/run.sh propagates 3 verbatim; ci/l0.sh turns it
|
||||||
into the gate's own rc 3. This is the ONE place the "unwired, not green" rule lives now,
|
into the gate's own rc 3. This is the ONE place the "unwired, not green" rule lives now,
|
||||||
instead of once per hand-written stage.
|
instead of once per hand-written stage.
|
||||||
|
|
||||||
|
PARALLELISM (--jobs, env CONF_OFFLINE_JOBS)
|
||||||
|
-------------------------------------------
|
||||||
|
The checks are independent processes, so they run in a bounded thread pool (one `bash` child
|
||||||
|
per worker). Default: min(8, usable CPUs); `--jobs 1` is the old serial runner exactly. Each
|
||||||
|
check's stdout+stderr is CAPTURED and only reported after every check has finished, in the
|
||||||
|
ORDER THE DIRS WERE GIVEN, so the log, the JUnit and the step summary are byte-for-byte
|
||||||
|
independent of which check finished first (only the trailing "[N.Ns]" wall-time on each
|
||||||
|
status line varies). The 0/1/3 aggregation runs over the same ordered case list as before.
|
||||||
|
|
||||||
|
Parallel-safety is a property of the CHECKS, not of this runner: every check must keep its
|
||||||
|
scratch state in a private mktemp/mkdtemp dir and must not write into the tree or a fixed
|
||||||
|
path. The audit that established that for the current set is recorded in
|
||||||
|
decisions/2026-11-08-parallel-l0-checks.md; a new check that needs shared state must say so.
|
||||||
|
|
||||||
|
Nested pools: the keycloak reconciler checks run their own mutation suites in a pool
|
||||||
|
(mutations.py, env MUTATION_JOBS). When this runner is itself parallel it exports a SMALLER
|
||||||
|
MUTATION_JOBS (unless the caller already set one), so the two suites share the CPUs the
|
||||||
|
other jobs-2 checks are not using instead of each taking min(8, CPUs). See inner_jobs().
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.dom import minidom
|
from xml.dom import minidom
|
||||||
@@ -97,6 +119,46 @@ def collect_meta(test_yaml: Path, prefix: str):
|
|||||||
return name, reqs
|
return name, reqs
|
||||||
|
|
||||||
|
|
||||||
|
def usable_cpus() -> int:
|
||||||
|
"""CPUs this process may run on (the cgroup/affinity view, not the host's count)."""
|
||||||
|
try:
|
||||||
|
return max(1, len(os.sched_getaffinity(0)))
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
return max(1, os.cpu_count() or 1)
|
||||||
|
|
||||||
|
|
||||||
|
def positive_int(text: str) -> int:
|
||||||
|
n = int(text)
|
||||||
|
if n < 1:
|
||||||
|
raise argparse.ArgumentTypeError(f"must be >= 1, got {n}")
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def inner_jobs(cpus: int, outer: int) -> int:
|
||||||
|
"""MUTATION_JOBS to hand the checks when this runner runs `outer` of them at once.
|
||||||
|
|
||||||
|
Two checks (the keycloak client-secret and passkey reconcilers) fan out internally; the
|
||||||
|
other outer-2 workers are single processes. Give the two suites an equal share of the CPUs
|
||||||
|
those others leave free, never fewer than 2 each (a 1-wide pool would make the 40-mutation
|
||||||
|
passkey suite the critical path of the whole stage), never more than 8. Peak process count
|
||||||
|
is then about (outer-2) + 2*inner, i.e. <= cpus on any machine with cpus >= outer + 2, and
|
||||||
|
at worst ~1.25-1.5x cpus on a small runner — CPU-bound oversubscription of that size costs
|
||||||
|
latency per process, not throughput.
|
||||||
|
"""
|
||||||
|
if outer <= 1:
|
||||||
|
return min(8, cpus)
|
||||||
|
return max(2, min(8, (cpus - outer + 2) // 2))
|
||||||
|
|
||||||
|
|
||||||
|
def run_check(check: Path, env: dict) -> tuple[int, str, float]:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
proc = subprocess.run(
|
||||||
|
["bash", str(check)], capture_output=True, text=True,
|
||||||
|
stdin=subprocess.DEVNULL, env=env,
|
||||||
|
)
|
||||||
|
return proc.returncode, (proc.stdout + proc.stderr).strip(), time.monotonic() - t0
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
ap.add_argument("--junit", type=Path, required=True)
|
ap.add_argument("--junit", type=Path, required=True)
|
||||||
@@ -108,26 +170,54 @@ def main() -> int:
|
|||||||
"(makes this runner exit 3); repeatable")
|
"(makes this runner exit 3); repeatable")
|
||||||
ap.add_argument("--summary-out", type=Path, default=None,
|
ap.add_argument("--summary-out", type=Path, default=None,
|
||||||
help="write one Markdown line per case here, for the CI step summary")
|
help="write one Markdown line per case here, for the CI step summary")
|
||||||
|
ap.add_argument("--jobs", type=positive_int, default=None,
|
||||||
|
help="checks to run at once (default: env CONF_OFFLINE_JOBS, else "
|
||||||
|
"min(8, usable CPUs)); 1 = serial")
|
||||||
ap.add_argument("dirs", nargs="+", type=Path)
|
ap.add_argument("dirs", nargs="+", type=Path)
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
strict_scopes = set(args.unwired_scope)
|
strict_scopes = set(args.unwired_scope)
|
||||||
|
|
||||||
cases = [] # (name, reqs, dir, rc, output, scope)
|
cpus = usable_cpus()
|
||||||
|
if args.jobs is not None:
|
||||||
|
jobs = args.jobs
|
||||||
|
elif os.environ.get("CONF_OFFLINE_JOBS", "").strip():
|
||||||
|
try:
|
||||||
|
jobs = positive_int(os.environ["CONF_OFFLINE_JOBS"].strip())
|
||||||
|
except (ValueError, argparse.ArgumentTypeError) as e:
|
||||||
|
ap.error(f"CONF_OFFLINE_JOBS={os.environ['CONF_OFFLINE_JOBS']!r}: {e}")
|
||||||
|
else:
|
||||||
|
jobs = min(8, cpus)
|
||||||
|
child_env = dict(os.environ)
|
||||||
|
if not child_env.get("MUTATION_JOBS", "").strip():
|
||||||
|
child_env["MUTATION_JOBS"] = str(inner_jobs(cpus, jobs))
|
||||||
|
print(f"[offline] running {len(args.dirs)} check(s), jobs={jobs} "
|
||||||
|
f"(cpus={cpus}, MUTATION_JOBS={child_env['MUTATION_JOBS']})", file=sys.stderr)
|
||||||
|
|
||||||
|
# Metadata first, in the given order; then the checks themselves through the pool. The
|
||||||
|
# results are consumed in SUBMISSION order, so `cases` is identical to the serial runner's.
|
||||||
|
planned = [] # (name, reqs, dir, scope, check-or-None)
|
||||||
for d in args.dirs:
|
for d in args.dirs:
|
||||||
check = next((d / s for s in CHECK_SCRIPTS if (d / s).exists()), None)
|
check = next((d / s for s in CHECK_SCRIPTS if (d / s).exists()), None)
|
||||||
name, reqs = collect_meta(d / "chainsaw-test.yaml", args.label_prefix)
|
name, reqs = collect_meta(d / "chainsaw-test.yaml", args.label_prefix)
|
||||||
scope = scope_of(d / "chainsaw-test.yaml", args.scope_annotation)
|
scope = scope_of(d / "chainsaw-test.yaml", args.scope_annotation)
|
||||||
if check is None:
|
planned.append((name, reqs, d, scope, check))
|
||||||
cases.append((name, reqs, d, 3,
|
|
||||||
f"no offline check script ({' or '.join(CHECK_SCRIPTS)}) in {d}",
|
cases = [] # (name, reqs, dir, rc, output, scope)
|
||||||
scope))
|
elapsed = [] # wall seconds per case, same order (None: nothing ran)
|
||||||
continue
|
with ThreadPoolExecutor(max_workers=jobs) as pool:
|
||||||
proc = subprocess.run(
|
futures = [pool.submit(run_check, check, child_env) if check is not None else None
|
||||||
["bash", str(check)], capture_output=True, text=True
|
for (_n, _r, _d, _s, check) in planned]
|
||||||
)
|
for (name, reqs, d, scope, check), fut in zip(planned, futures):
|
||||||
out = (proc.stdout + proc.stderr).strip()
|
if fut is None:
|
||||||
cases.append((name, reqs, d, proc.returncode, out, scope))
|
cases.append((name, reqs, d, 3,
|
||||||
|
f"no offline check script ({' or '.join(CHECK_SCRIPTS)}) in {d}",
|
||||||
|
scope))
|
||||||
|
elapsed.append(None)
|
||||||
|
continue
|
||||||
|
rc, out, secs = fut.result()
|
||||||
|
cases.append((name, reqs, d, rc, out, scope))
|
||||||
|
elapsed.append(secs)
|
||||||
|
|
||||||
failures = sum(1 for c in cases if c[3] not in (0, 3))
|
failures = sum(1 for c in cases if c[3] not in (0, 3))
|
||||||
skipped = sum(1 for c in cases if c[3] == 3)
|
skipped = sum(1 for c in cases if c[3] == 3)
|
||||||
@@ -145,7 +235,7 @@ def main() -> int:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
summary_lines = []
|
summary_lines = []
|
||||||
for name, reqs, d, rc, out, scope in cases:
|
for (name, reqs, d, rc, out, scope), secs in zip(cases, elapsed):
|
||||||
tc = ET.SubElement(
|
tc = ET.SubElement(
|
||||||
suite,
|
suite,
|
||||||
"testcase",
|
"testcase",
|
||||||
@@ -164,8 +254,9 @@ def main() -> int:
|
|||||||
fail.text = out
|
fail.text = out
|
||||||
status = "PASS" if rc == 0 else ("SKIP" if rc == 3 else "FAIL")
|
status = "PASS" if rc == 0 else ("SKIP" if rc == 3 else "FAIL")
|
||||||
mark = f" scope={scope}" if scope else ""
|
mark = f" scope={scope}" if scope else ""
|
||||||
|
took = f" [{secs:.1f}s]" if secs is not None else ""
|
||||||
print(f"[offline] {layer_of(d):3s} {status:4s} {name} "
|
print(f"[offline] {layer_of(d):3s} {status:4s} {name} "
|
||||||
f"({','.join(reqs) or 'untagged'}){mark}",
|
f"({','.join(reqs) or 'untagged'}){mark}{took}",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
if rc not in (0, 3):
|
if rc not in (0, 3):
|
||||||
print(out, file=sys.stderr)
|
print(out, file=sys.stderr)
|
||||||
|
|||||||
@@ -25,15 +25,19 @@
|
|||||||
#
|
#
|
||||||
# python3 mutations.py <path-to-extracted-reconciler.py>
|
# python3 mutations.py <path-to-extracted-reconciler.py>
|
||||||
#
|
#
|
||||||
# Runtime is ~20s (one subprocess per mutation, each running the full selftest
|
# Runtime is ~11s serial (one subprocess per mutation, each running the full selftest
|
||||||
# against an in-process stub Keycloak; no network, no cluster).
|
# against an in-process stub Keycloak; no network, no cluster). The selftests run in a
|
||||||
|
# bounded pool (env MUTATION_JOBS, default min(8, CPUs); 1 = serial); output order and
|
||||||
|
# verdicts are independent of the pool size.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -136,43 +140,99 @@ MUTATIONS: list[tuple[str, str, str, str]] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_jobs() -> int:
|
||||||
|
"""Selftests to run at once: env MUTATION_JOBS, else min(8, usable CPUs).
|
||||||
|
|
||||||
|
conformance/lib/run_l1_offline.py exports a smaller MUTATION_JOBS when it is itself
|
||||||
|
running several checks in parallel, so the nested pools do not oversubscribe the host.
|
||||||
|
MUTATION_JOBS=1 reproduces the old serial loop exactly.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get("MUTATION_JOBS", "").strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
n = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
n = 0
|
||||||
|
if n < 1:
|
||||||
|
raise ValueError("MUTATION_JOBS must be a positive integer, got %r" % raw)
|
||||||
|
return n
|
||||||
|
try:
|
||||||
|
cpus = len(os.sched_getaffinity(0))
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
cpus = os.cpu_count() or 1
|
||||||
|
return max(1, min(8, cpus))
|
||||||
|
|
||||||
|
|
||||||
|
def run_selftest(mutated_src: str) -> subprocess.CompletedProcess:
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
|
||||||
|
fh.write(mutated_src)
|
||||||
|
path = fh.name
|
||||||
|
try:
|
||||||
|
return subprocess.run([sys.executable, path, "--selftest"],
|
||||||
|
capture_output=True, text=True, timeout=240,
|
||||||
|
stdin=subprocess.DEVNULL)
|
||||||
|
finally:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
|
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
src = Path(sys.argv[1]).read_text()
|
src = Path(sys.argv[1]).read_text()
|
||||||
|
|
||||||
problems: list[str] = []
|
try:
|
||||||
killed = 0
|
jobs = mutation_jobs()
|
||||||
|
except ValueError as e:
|
||||||
|
print("mutations.py: %s" % e, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Pass 1, serial and cheap: every anchor is checked BEFORE anything runs, exactly as the
|
||||||
|
# serial loop did, and each mutation's source is built here. None = anchor failure.
|
||||||
|
planned: list[tuple[str, str, str | None, str | None]] = []
|
||||||
for name, why, old, new in MUTATIONS:
|
for name, why, old, new in MUTATIONS:
|
||||||
old, new = dedent4(old), dedent4(new)
|
old, new = dedent4(old), dedent4(new)
|
||||||
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
|
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
|
||||||
# moved and this mutation silently stopped proving anything.
|
# moved and this mutation silently stopped proving anything.
|
||||||
n = src.count(old)
|
n = src.count(old)
|
||||||
if n != 1:
|
if n != 1:
|
||||||
problems.append("%s: anchor matches %d times (expected exactly 1) -- the "
|
planned.append((name, why,
|
||||||
|
"%s: anchor matches %d times (expected exactly 1) -- the "
|
||||||
"code moved and this mutation no longer proves anything. "
|
"code moved and this mutation no longer proves anything. "
|
||||||
"Re-anchor it against the current source." % (name, n))
|
"Re-anchor it against the current source." % (name, n), None))
|
||||||
continue
|
continue
|
||||||
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
|
planned.append((name, why, None, src.replace(old, new, 1)))
|
||||||
fh.write(src.replace(old, new, 1))
|
|
||||||
path = fh.name
|
# Pass 2: the selftests, `jobs` at a time. Each child gets its own temp file, its own
|
||||||
try:
|
# mkdtemp dirs and its own 127.0.0.1:0 stub port, so they share nothing. Results are
|
||||||
proc = subprocess.run([sys.executable, path, "--selftest"],
|
# consumed in MUTATIONS order, so every line below is printed in the same order, with the
|
||||||
capture_output=True, text=True, timeout=240)
|
# same verdict, as the serial loop produced -- whichever child finishes first.
|
||||||
finally:
|
problems: list[str] = []
|
||||||
Path(path).unlink(missing_ok=True)
|
killed = 0
|
||||||
if proc.returncode == 0:
|
pool = ThreadPoolExecutor(max_workers=jobs)
|
||||||
problems.append("%s SURVIVED -- --selftest still passed with this regression "
|
try:
|
||||||
"applied, so nothing in the suite actually checks it. The "
|
futures = [pool.submit(run_selftest, mutated) if mutated is not None else None
|
||||||
"mistake it models: %s" % (name, why))
|
for (_n, _w, _a, mutated) in planned]
|
||||||
continue
|
for (name, why, anchor_problem, _m), fut in zip(planned, futures):
|
||||||
killed += 1
|
if anchor_problem is not None:
|
||||||
out = proc.stdout + proc.stderr
|
problems.append(anchor_problem)
|
||||||
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
|
continue
|
||||||
how = ("%s assertion(s) caught it" % m.group(1) if m
|
proc = fut.result()
|
||||||
else "the selftest aborted (caught, less cleanly)")
|
if proc.returncode == 0:
|
||||||
print(" killed %-42s %s" % (name, how))
|
problems.append("%s SURVIVED -- --selftest still passed with this regression "
|
||||||
|
"applied, so nothing in the suite actually checks it. The "
|
||||||
|
"mistake it models: %s" % (name, why))
|
||||||
|
continue
|
||||||
|
killed += 1
|
||||||
|
out = proc.stdout + proc.stderr
|
||||||
|
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
|
||||||
|
how = ("%s assertion(s) caught it" % m.group(1) if m
|
||||||
|
else "the selftest aborted (caught, less cleanly)")
|
||||||
|
print(" killed %-42s %s" % (name, how))
|
||||||
|
finally:
|
||||||
|
# A timeout (or any other exception) still propagates and fails the check, as it did
|
||||||
|
# serially; queued mutations are cancelled rather than run for nothing.
|
||||||
|
pool.shutdown(wait=True, cancel_futures=True)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if problems:
|
if problems:
|
||||||
|
|||||||
@@ -40,15 +40,19 @@
|
|||||||
#
|
#
|
||||||
# python3 mutations.py <path-to-extracted-reconciler.py>
|
# python3 mutations.py <path-to-extracted-reconciler.py>
|
||||||
#
|
#
|
||||||
# Runtime is ~60s (one subprocess per mutation, each running the full selftest
|
# Runtime is ~85s serial (one subprocess per mutation, each running the full selftest
|
||||||
# against an in-process stub Keycloak; no network, no cluster).
|
# against an in-process stub Keycloak; no network, no cluster). The selftests run in a
|
||||||
|
# bounded pool (env MUTATION_JOBS, default min(8, CPUs); 1 = serial); output order and
|
||||||
|
# verdicts are independent of the pool size.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# (name, the mistake being modelled, anchor, replacement)
|
# (name, the mistake being modelled, anchor, replacement)
|
||||||
@@ -403,42 +407,98 @@ MUTATIONS: list[tuple[str, str, str, str]] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_jobs() -> int:
|
||||||
|
"""Selftests to run at once: env MUTATION_JOBS, else min(8, usable CPUs).
|
||||||
|
|
||||||
|
conformance/lib/run_l1_offline.py exports a smaller MUTATION_JOBS when it is itself
|
||||||
|
running several checks in parallel, so the nested pools do not oversubscribe the host.
|
||||||
|
MUTATION_JOBS=1 reproduces the old serial loop exactly.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get("MUTATION_JOBS", "").strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
n = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
n = 0
|
||||||
|
if n < 1:
|
||||||
|
raise ValueError("MUTATION_JOBS must be a positive integer, got %r" % raw)
|
||||||
|
return n
|
||||||
|
try:
|
||||||
|
cpus = len(os.sched_getaffinity(0))
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
cpus = os.cpu_count() or 1
|
||||||
|
return max(1, min(8, cpus))
|
||||||
|
|
||||||
|
|
||||||
|
def run_selftest(mutated_src: str) -> subprocess.CompletedProcess:
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
|
||||||
|
fh.write(mutated_src)
|
||||||
|
path = fh.name
|
||||||
|
try:
|
||||||
|
return subprocess.run([sys.executable, path, "--selftest"],
|
||||||
|
capture_output=True, text=True, timeout=300,
|
||||||
|
stdin=subprocess.DEVNULL)
|
||||||
|
finally:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
|
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
src = Path(sys.argv[1]).read_text()
|
src = Path(sys.argv[1]).read_text()
|
||||||
|
|
||||||
problems: list[str] = []
|
try:
|
||||||
killed = 0
|
jobs = mutation_jobs()
|
||||||
|
except ValueError as e:
|
||||||
|
print("mutations.py: %s" % e, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Pass 1, serial and cheap: every anchor is checked BEFORE anything runs, exactly as the
|
||||||
|
# serial loop did, and each mutation's source is built here. None = anchor failure.
|
||||||
|
planned: list[tuple[str, str, str | None, str | None]] = []
|
||||||
for name, why, old, new in MUTATIONS:
|
for name, why, old, new in MUTATIONS:
|
||||||
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
|
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
|
||||||
# moved and this mutation silently stopped proving anything.
|
# moved and this mutation silently stopped proving anything.
|
||||||
n = src.count(old)
|
n = src.count(old)
|
||||||
if n != 1:
|
if n != 1:
|
||||||
problems.append("%s: anchor matches %d times (expected exactly 1) -- the "
|
planned.append((name, why,
|
||||||
|
"%s: anchor matches %d times (expected exactly 1) -- the "
|
||||||
"code moved and this mutation no longer proves anything. "
|
"code moved and this mutation no longer proves anything. "
|
||||||
"Re-anchor it against the current source." % (name, n))
|
"Re-anchor it against the current source." % (name, n), None))
|
||||||
continue
|
continue
|
||||||
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
|
planned.append((name, why, None, src.replace(old, new, 1)))
|
||||||
fh.write(src.replace(old, new, 1))
|
|
||||||
path = fh.name
|
# Pass 2: the selftests, `jobs` at a time. Each child gets its own temp file, its own
|
||||||
try:
|
# mkdtemp dirs and its own 127.0.0.1:0 stub port, so they share nothing. Results are
|
||||||
proc = subprocess.run([sys.executable, path, "--selftest"],
|
# consumed in MUTATIONS order, so every line below is printed in the same order, with the
|
||||||
capture_output=True, text=True, timeout=300)
|
# same verdict, as the serial loop produced -- whichever child finishes first.
|
||||||
finally:
|
problems: list[str] = []
|
||||||
Path(path).unlink(missing_ok=True)
|
killed = 0
|
||||||
if proc.returncode == 0:
|
pool = ThreadPoolExecutor(max_workers=jobs)
|
||||||
problems.append("%s SURVIVED -- --selftest still passed with this regression "
|
try:
|
||||||
"applied, so nothing in the suite actually checks it. The "
|
futures = [pool.submit(run_selftest, mutated) if mutated is not None else None
|
||||||
"mistake it models: %s" % (name, why))
|
for (_n, _w, _a, mutated) in planned]
|
||||||
continue
|
for (name, why, anchor_problem, _m), fut in zip(planned, futures):
|
||||||
killed += 1
|
if anchor_problem is not None:
|
||||||
out = proc.stdout + proc.stderr
|
problems.append(anchor_problem)
|
||||||
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
|
continue
|
||||||
how = ("%s assertion(s) caught it" % m.group(1) if m
|
proc = fut.result()
|
||||||
else "the selftest aborted (caught, less cleanly)")
|
if proc.returncode == 0:
|
||||||
print(" killed %-54s %s" % (name, how))
|
problems.append("%s SURVIVED -- --selftest still passed with this regression "
|
||||||
|
"applied, so nothing in the suite actually checks it. The "
|
||||||
|
"mistake it models: %s" % (name, why))
|
||||||
|
continue
|
||||||
|
killed += 1
|
||||||
|
out = proc.stdout + proc.stderr
|
||||||
|
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
|
||||||
|
how = ("%s assertion(s) caught it" % m.group(1) if m
|
||||||
|
else "the selftest aborted (caught, less cleanly)")
|
||||||
|
print(" killed %-54s %s" % (name, how))
|
||||||
|
finally:
|
||||||
|
# A timeout (or any other exception) still propagates and fails the check, as it did
|
||||||
|
# serially; queued mutations are cancelled rather than run for nothing.
|
||||||
|
pool.shutdown(wait=True, cancel_futures=True)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if problems:
|
if problems:
|
||||||
|
|||||||
Reference in new issue
Block a user