Files
Martin Vogel a654acbc88 fix(test-infra): grant the build-dir ACL by SID, not by name (#1532 follow-up)
The local Windows leg caught this within hours of merging #1532: on a machine
that is not domain-joined, USERDOMAIN is the literal string WORKGROUP, and
`WORKGROUP\test` is not a resolvable principal —

    WARN: build-dir DACL stamp (pre-wave) failed (user=WORKGROUP\test ...):
    WORKGROUP\test: No mapping between account names and security IDs was done.

so the grant fails outright and the tree keeps its inherited
`Authenticated Users:(M)`. The guard then correctly reports that cross-account
mutation is still possible, and the whole leg stops. Since most Windows
machines are not domain-joined, that is the common case, not the exotic one.

#1532 was fixing a real bug in the other direction (a bare name resolves
against the machine first, so a host named like its user grants to an empty
principal). Both failures are name RESOLUTION failures, so this stops resolving
names: the account is identified by SID, which is unambiguous everywhere. The
SYSTEM and Administrators grants in these same commands have always used that
form — the current user was the odd one out. Name lookup survives only as a
fallback where PowerShell is unavailable.

Applied to all three scripts carrying the stamp (run-tests-parallel, soak-test,
memlab), since they share the failure and would otherwise drift apart.

Co-Authored-By: Kiborgik <Kiborgik@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 11:57:35 +02:00

354 lines
17 KiB
Bash

#!/usr/bin/env bash
# run-tests-parallel.sh — run every registered test suite as parallel
# processes of the already-built test-runner.
#
# ZERO-LOSS CONTRACT (gate quality must be identical to the sequential run):
# 1. The suite list comes from `test-runner --list-suites`, which is printed
# by the SAME macro table that executes suites — the list cannot drift
# from reality by construction.
# 2. UNION GUARD: after the run, the set of suites that actually produced a
# result is compared against that list; any difference (a suite that
# never ran, or ran twice) fails the gate loudly. A newly added suite is
# picked up automatically on the next invocation.
# 3. Per-suite pass/fail/skip counts are summed and reported in the same
# "N passed[, M failed][, K skipped]" shape as the sequential runner, so
# before/after totals are directly comparable.
# 4. ANY suite failing, crashing (nonzero exit), or missing ⇒ exit 1.
#
# Usage: run-tests-parallel.sh <path-to-test-runner> [jobs]
# jobs defaults to CBM_TEST_PAR_JOBS, then the CPU count.
set -uo pipefail
RUNNER="${1:?usage: run-tests-parallel.sh <path-to-test-runner> [jobs]}"
JOBS="${2:-${CBM_TEST_PAR_JOBS:-}}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCHEDULER="$SCRIPT_DIR/run-test-wave.py"
if [ -z "$JOBS" ]; then
if command -v nproc >/dev/null 2>&1; then
JOBS=$(nproc)
elif command -v sysctl >/dev/null 2>&1; then
JOBS=$(sysctl -n hw.ncpu 2>/dev/null || echo 4)
else
JOBS=4
fi
fi
LOGDIR="$(dirname "$RUNNER")/test-logs"
rm -rf "$LOGDIR"
mkdir -p "$LOGDIR"
# Windows: shape the build directory like a real user checkout BEFORE the
# suites run. MSYS2's Cygwin layer writes POSIX-emulating DACLs — including
# CREATOR OWNER (S-1-3-0) mutation grants — onto directories its tools
# touch, and workspace drive roots additionally inherit Authenticated-Users
# Modify; the activation transaction's source-directory policy correctly
# refuses both, which would fail the install-flow tests on the environment,
# not the code. Stamping must happen AFTER the build (the builders are the
# ones re-writing the DACL), so it lives here rather than in workflow
# setup. Two idempotent steps: protect the DIRECTORY (inheritance flags are
# directory-only — a /T re-root leaves files with empty deny-all DACLs),
# then /reset the children to re-inherit the clean set.
stamp_windows_build_dir() {
local when="$1"
case "$(uname -s 2>/dev/null)" in
MINGW* | MSYS*) ;;
*) return 0 ;;
esac
local runner_dir_w me norm_out stamp_out reset_out
runner_dir_w="$(cygpath -w "$(dirname "$RUNNER")")"
# Qualify the account with its domain. Git Bash resolves `whoami` to
# coreutils, which prints a bare name, and icacls resolves a bare name
# against the machine first: on a host whose name equals the user's
# (COMPUTERNAME=BUILD, user build) the grant lands on an empty principal
# (BUILD\) and, combined with /inheritance:r above, locks this script out
# of its own log directory.
# Identify the account by SID, never by name. icacls resolves a bare name
# against the machine first, so a host whose name equals the user's grants
# to an empty principal (#1532); and a USERDOMAIN-qualified name is
# UNRESOLVABLE on a workgroup machine — "WORKGROUP\test: No mapping between
# account names and security IDs was done" — which fails the grant outright
# and silently leaves the tree writable by Authenticated Users. A SID has
# neither ambiguity, and the SYSTEM/Administrators grants below already use
# this form. Name lookup remains only as a fallback where PowerShell is
# unavailable.
me="$(powershell.exe -NoProfile -NonInteractive -Command \
'[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value' 2>/dev/null |
tr -d '\r\n')"
case "${me}" in
S-1-*) me="*${me}" ;;
*)
me="$(whoami | tr -d '\r')"
if [ -n "${USERDOMAIN:-}" ]; then
me="${USERDOMAIN}\\${me}"
fi
;;
esac
# Normalize FIRST: some runner images stamp EXPLICIT (non-inherited)
# Authenticated-Users ACEs onto the workspace tree, which /inheritance:r
# cannot strip and /grant:r does not touch (it replaces only the granted
# SIDs' own entries). /reset drops every explicit ACE and restores pure
# inheritance, so the protect-and-grant below starts from a known shape
# regardless of image provisioning.
norm_out=$(MSYS2_ARG_CONV_EXCL='*' icacls "$runner_dir_w" /reset /Q 2>&1) ||
echo "WARN: build-dir DACL normalize ($when) failed: $norm_out"
stamp_out=$(MSYS2_ARG_CONV_EXCL='*' icacls "$runner_dir_w" /inheritance:r \
/grant:r "${me}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' \
/Q 2>&1) || echo "WARN: build-dir DACL stamp ($when) failed (user=$me dir=$runner_dir_w): $stamp_out"
reset_out=$(MSYS2_ARG_CONV_EXCL='*' icacls "${runner_dir_w}\\*" /reset /T /C /Q 2>&1) ||
echo "WARN: build-dir child DACL reset ($when) failed: $(printf '%s' "$reset_out" | tail -2)"
# The stamp is load-bearing for the install-flow suites: verify it and say
# so, in either direction — a silent stamp once cost a full CI round to
# even see WHETHER it had run.
if MSYS2_ARG_CONV_EXCL='*' icacls "$runner_dir_w" 2>/dev/null |
grep -qE 'Authenticated Users|CREATOR OWNER'; then
echo "FAIL: build-dir DACL still grants cross-account mutation after $when stamp:"
MSYS2_ARG_CONV_EXCL='*' icacls "$runner_dir_w" 2>&1 | head -8
exit 1
else
echo "build-dir DACL stamped clean ($when, $runner_dir_w, user=$me)"
fi
}
stamp_windows_build_dir pre-wave
SUITES_FILE="$LOGDIR/suites.txt"
RESULTS_FILE="$LOGDIR/results.txt"
: > "$RESULTS_FILE"
# tr strips the CR that the Windows CRT appends to every stdout line — a
# suites file with CRLF endings made the runner reject every name
# ("arena\r" is an unknown suite) and fail all 104 suites on CI.
if ! "$RUNNER" --list-suites | tr -d '\r' > "$SUITES_FILE"; then
echo "FAIL: test-runner --list-suites exited nonzero" >&2
exit 1
fi
NSUITES=$(wc -l < "$SUITES_FILE" | tr -d ' ')
if [ "$NSUITES" -lt 1 ] || grep -qvE '^[a-z0-9_]+$' "$SUITES_FILE"; then
echo "FAIL: suite list empty or malformed (runner too old for --list-suites?)" >&2
exit 1
fi
# CBM_TEST_SHARD="i/N" runs this invocation's deterministic slice of the
# suite list so CI can spread one platform's suites across N runner jobs.
# Unset (or "1/1") selects everything — the sharding path is inert unless a
# workflow opts in. The slice is a pure function of (--list-suites, i, N):
# every shard recomputes the same assignment, so N jobs with indices 1..N
# cover the full list by construction, and each job's union guard below
# proves it ran exactly its slice.
SHARD_INDEX=1
SHARD_TOTAL=1
if [ -n "${CBM_TEST_SHARD:-}" ]; then
if ! printf '%s' "$CBM_TEST_SHARD" | grep -qE '^[0-9]+/[0-9]+$'; then
echo "FAIL: CBM_TEST_SHARD must be i/N, got '$CBM_TEST_SHARD'" >&2
exit 1
fi
SHARD_INDEX="${CBM_TEST_SHARD%%/*}"
SHARD_TOTAL="${CBM_TEST_SHARD##*/}"
if [ "$SHARD_TOTAL" -lt 1 ] || [ "$SHARD_INDEX" -lt 1 ] ||
[ "$SHARD_INDEX" -gt "$SHARD_TOTAL" ]; then
echo "FAIL: CBM_TEST_SHARD out of range: $CBM_TEST_SHARD" >&2
exit 1
fi
fi
# Deal the known-heavy suites round-robin FIRST so no shard receives a
# second heavy suite before every shard holds one — shard wall time is
# bounded by its heaviest member, and naive modulo can stack store_arch and
# daemon_runtime (the two slowest sanitized suites) onto one job.
shard_filter() {
awk -v idx="$SHARD_INDEX" -v total="$SHARD_TOTAL" '
BEGIN {
nh = split("store_arch daemon_runtime incremental cli extraction " \
"watcher daemon_ipc subprocess httpd py_lsp_stress " \
"grammar_regression mcp daemon_frontend pipeline", h, " ")
}
{ present[$0] = NR; lines[NR] = $0 }
END {
# Deal heavies in WEIGHT order (the static list above), not file
# order: the point is that the two slowest suites land on
# different shards, which file-order dealing does not guarantee.
n = 0
for (i = 1; i <= nh; i++)
if (h[i] in present) { order[++n] = h[i]; taken[h[i]] = 1 }
for (i = 1; i <= NR; i++)
if (!(lines[i] in taken)) order[++n] = lines[i]
for (i = 1; i <= n; i++) if ((i - 1) % total == idx - 1) print order[i]
}
'
}
# Timing-sensitive suites run SEQUENTIALLY after the parallel wave: they
# spawn subprocesses / watch the filesystem / bind ports with fixed
# deadlines, and a saturated 4-core CI runner starves those deadlines into
# flakes (3 cli-suite failures on the ubuntu legs of the first CI run).
# Same suites, same tests, same gates — only the schedule differs; the
# union guard below still checks the COMBINED result set.
# stack_overflow_a/b/c: their giant-recursion ASan allocations stall ~100x
# when co-STARTED with a large wave on Apple Silicon (2s staggered vs ~230s
# simultaneous — a local scheduler/zone quirk, not contention: job count
# does not change it). Staggered in the tail they cost seconds.
# The daemon-family suites spawn coordinated worker subprocesses (a re-exec
# of this ASan runner plus the full admission handshake) and bind local
# endpoints under fixed readiness deadlines (3 s marker waits in
# index_supervisor); the saturated 3-core macOS CI runners starve those
# deadlines into deterministic failures while an idle machine passes 6/6.
# They also all rendezvous through the shared per-account runtime namespace,
# which the quiet tail keeps free of cross-suite admission traffic.
# extraction carries the wide-flat SCALING-RATIO guard, which grows the input
# 20x and asserts the time grows ~20x (linear) rather than ~128x (quadratic),
# with a bound of 40x between them. Contention does not cancel out of that
# ratio: the 400k-node measurement loses far more to memory pressure and
# scheduling than the 20k one, so oversubscription inflates the ratio itself.
# Measured on the Windows arm64 VM: 18.5x alone (passes) vs 53.8x and 55x in
# the 18-job wave (fails) — reproducible, 3 of 3. The bound is deliberately NOT
# widened; see the calibration note in tests/test_extraction.c, which records
# that 40 sits >=2x from both the linear and quadratic signals, so inflating it
# would move the test toward the very thing it exists to catch. Quiet is the
# fix, and at ~22s the suite is cheap to run alone.
SERIAL_SUITES="cli subprocess watcher incremental httpd ui index_resilience mcp \
extraction \
stack_overflow_a stack_overflow_b stack_overflow_c \
index_supervisor daemon_application daemon_runtime daemon_frontend \
daemon_bootstrap daemon_ipc"
is_serial() {
case " $SERIAL_SUITES " in *" $1 "*) return 0 ;; *) return 1 ;; esac
}
PAR_FILE="$LOGDIR/suites-parallel.txt"
SER_FILE="$LOGDIR/suites-serial.txt"
: > "$PAR_FILE"
: > "$SER_FILE"
while IFS= read -r sname; do
if is_serial "$sname"; then
echo "$sname" >> "$SER_FILE"
else
echo "$sname" >> "$PAR_FILE"
fi
done < "$SUITES_FILE"
# The parallel wave and the serial tail are sharded separately: every shard
# keeps its own quiet tail for the deadline-sensitive suites (its runner is
# a whole machine, so the tail is at least as quiet as before sharding).
shard_filter < "$PAR_FILE" > "$PAR_FILE.shard" && mv "$PAR_FILE.shard" "$PAR_FILE"
shard_filter < "$SER_FILE" > "$SER_FILE.shard" && mv "$SER_FILE.shard" "$SER_FILE"
SHARD_EXPECT="$LOGDIR/suites-shard.txt"
cat "$PAR_FILE" "$SER_FILE" > "$SHARD_EXPECT"
NSHARD=$(wc -l < "$SHARD_EXPECT" | tr -d ' ')
# Machine-checkable manifest for CI's cross-shard completeness job: it
# proves at runtime that the shards of one leg agree on N and on the full
# suite list, and that the union of their slices IS that list — the guard
# against a mis-plumbed CBM_TEST_SHARD (two jobs running the same slice
# passes every per-shard check but silently drops a slice; only a
# cross-shard view catches it). Written BEFORE any suite runs: the slice is
# fully determined here, and a red run's manifest is exactly as load-bearing
# as a green one's — CI uploads it if: always().
{
echo "leg=${CBM_TEST_LEG:-local}"
echo "shard=${SHARD_INDEX}/${SHARD_TOTAL}"
echo "list_sha256=$(sort "$SUITES_FILE" | { sha256sum 2>/dev/null || shasum -a 256; } | awk '{print $1}')"
echo "--- slice ---"
cat "$SHARD_EXPECT"
} > "$LOGDIR/shard-manifest.txt"
echo "=== parallel test run: $NSHARD of $NSUITES suites (shard ${SHARD_INDEX}/${SHARD_TOTAL}, $(wc -l < "$SER_FILE" | tr -d ' ') serial-tail), $JOBS jobs ==="
# Per-suite wall-clock ceilings make a wedged child fail loudly. The
# `incremental` suite legitimately re-indexes large fixtures (minutes), while
# `daemon_runtime` measures ~610s solo on arm64 under ASan; those and
# `store_arch` receive the wider ceiling. Process ownership and result
# accounting live in one Python parent — no exported MSYS bash worker remains
# between a completed native child and its durable result line.
run_wave() {
local suite_file="$1"
local jobs="$2"
if ! python3 "$SCHEDULER" \
--suite-file "$suite_file" \
--log-dir "$LOGDIR" \
--results-file "$RESULTS_FILE" \
--jobs "$jobs" \
--timeout "${CBM_SUITE_TIMEOUT:-900}" \
--slow-timeout "${CBM_SUITE_TIMEOUT_SLOW:-3600}" \
--kill-grace 15 \
"$RUNNER"; then
echo "FAIL: parallel suite scheduler infrastructure failed" >&2
exit 1
fi
}
run_wave "$PAR_FILE" "$JOBS"
# Tail scheduling in two phases. The FLEX suites are timing-shaped but do
# not rendezvous through the shared per-account daemon runtime namespace,
# so a small fixed overlap (CBM_TAIL_JOBS, default 2) is safe and converts
# idle cores into wall time — the old fully-serial tail ran them one at a
# time on an idle machine. The EXCL group (daemon-family plus the suites
# that drive daemon one-shots or supervisor rendezvous) then runs strictly
# sequentially on a machine exactly as quiet as the old tail gave it.
# extraction is in this group for a DIFFERENT reason than the rest: it does not
# rendezvous through the daemon namespace, it measures a scaling ratio, and even
# the FLEX group's small fixed overlap is load the measurement would absorb.
# Strictly sequential is what makes its verdict a function of the code instead
# of the scheduler.
TAIL_EXCL="cli mcp index_supervisor daemon_application daemon_runtime \
daemon_frontend daemon_bootstrap daemon_ipc extraction"
is_tail_excl() {
case " $TAIL_EXCL " in *" $1 "*) return 0 ;; *) return 1 ;; esac
}
FLEX_FILE="$LOGDIR/suites-tail-flex.txt"
EXCL_FILE="$LOGDIR/suites-tail-excl.txt"
: > "$FLEX_FILE"
: > "$EXCL_FILE"
while IFS= read -r sname; do
if is_tail_excl "$sname"; then
echo "$sname" >> "$EXCL_FILE"
else
echo "$sname" >> "$FLEX_FILE"
fi
done < "$SER_FILE"
# Wave suites spawn Cygwin-family tooling that can rewrite the build
# directory's DACL behind the first stamp (observed: an arm shard whose
# tail held the install-flow suites failed the source-directory policy
# minutes after a clean pre-wave stamp, while its sibling shard passed).
# Re-stamp at the tail boundary so the deadline-sensitive tail — which
# hosts those suites — always starts from the verified-clean shape.
stamp_windows_build_dir pre-tail
run_wave "$FLEX_FILE" "${CBM_TAIL_JOBS:-2}"
run_wave "$EXCL_FILE" 1
# ── Union guard: every suite in this shard's slice produced exactly one
# result. The slice is deterministic, so N green shard jobs = full coverage;
# a shard that ran anything more, less, or twice fails here. ──
MISSING=$(comm -23 <(sort "$SHARD_EXPECT") <(awk '{print $1}' "$RESULTS_FILE" | sort -u))
EXTRA=$(comm -13 <(sort "$SHARD_EXPECT") <(awk '{print $1}' "$RESULTS_FILE" | sort -u))
DUPES=$(awk '{print $1}' "$RESULTS_FILE" | sort | uniq -d)
if [ -n "$MISSING" ] || [ -n "$EXTRA" ] || [ -n "$DUPES" ]; then
echo "FAIL: shard union does not match its --list-suites slice (GATE-QUALITY LOSS)" >&2
[ -n "$MISSING" ] && echo " never ran: $MISSING" >&2
[ -n "$EXTRA" ] && echo " outside slice: $EXTRA" >&2
[ -n "$DUPES" ] && echo " ran twice: $DUPES" >&2
exit 1
fi
TOTAL_PASS=$(awk -F'pass=' '{split($2,a," "); s+=a[1]} END{print s+0}' "$RESULTS_FILE")
TOTAL_FAIL=$(awk -F'fail=' '{split($2,a," "); s+=a[1]} END{print s+0}' "$RESULTS_FILE")
TOTAL_SKIP=$(awk -F'skip=' '{split($2,a," "); s+=a[1]} END{print s+0}' "$RESULTS_FILE")
BAD_RC=$(grep -cv ' rc=0 ' "$RESULTS_FILE" || true)
echo "── 8 slowest suites ──"
sort -t= -k6 -rn "$RESULTS_FILE" | head -8
grep -v ' rc=0 ' "$RESULTS_FILE" || true
for f in $(grep -v ' rc=0 ' "$RESULTS_FILE" | awk '{print $1}'); do
echo "──── $f: every failure site ────"
grep -B2 -A8 "FAIL" "$LOGDIR/$f.log" | head -120
echo "──── $f: last 15 lines ────"
tail -15 "$LOGDIR/$f.log"
done
echo "────────────────────────────────────────────"
echo " $TOTAL_PASS passed, $TOTAL_FAIL failed, $TOTAL_SKIP skipped ($NSUITES suites, $JOBS jobs)"
echo "────────────────────────────────────────────"
if [ "$TOTAL_FAIL" -gt 0 ] || [ "$BAD_RC" -gt 0 ]; then
exit 1
fi
exit 0