From a8beff7d2c33b451651fafecb2b1a51f2723a9dd Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Thu, 13 Aug 2026 18:35:18 +0000 Subject: [PATCH] perf(kyberforge): parallelize the bats and shell test runners Both runners spawned one process at a time and let it finish before starting the next, so wall time scaled with file count even though each file's own work (bats: mostly repeated apm/git subprocess startup; the vale-heavy shell scripts: repeated vale binary startup) rarely used a full core. run-bats.sh now backgrounds one `bats` invocation per file, bounded by core count, buffering each file's TAP output so concurrent streams can't interleave, then flushes in stable sorted order once every job finishes. run-tests.sh does the same for test-*.sh in fixed-size batches (plain `wait`, not `wait -n`, to stay on the bash-3.2-safe path test-vale-wrap.sh already enforces for this file) -- verified beforehand that every test-*.sh keeps its fixtures under its own mktemp dir rather than mutating the live repo tree, so running them concurrently is safe. Confirmed correct on both the pass and fail paths (a deliberately failing bats test and a scratch TEST_DIR with pass/fail/skip-77 scripts) before measuring: full bats suite 26-30s -> 7.5s, tests/run-tests.sh 68s -> ~45-50s, full pre-push gate 2m40s -> 1m12s. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT --- tests/run-bats.sh | 51 +++++++++++++++++++++++++++++++++++++++++++++- tests/run-tests.sh | 38 +++++++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/tests/run-bats.sh b/tests/run-bats.sh index 5aa0221..9059661 100755 --- a/tests/run-bats.sh +++ b/tests/run-bats.sh @@ -29,4 +29,53 @@ if [[ ${#TEST_FILES[@]} -eq 0 ]]; then exit 0 fi -"$BATS" "${TEST_FILES[@]}" +# Each file gets its own `bats` process, run concurrently (bounded by core +# count) instead of one `bats` invocation working through all files serially. +# A single test file is still serial internally -- this only overlaps the +# fixed per-process startup cost (git/apm subprocess spawns dominate several +# of these suites) across files, which is where the wall-clock actually goes. +# Output is buffered per file so concurrent TAP streams can't interleave, then +# flushed in stable sorted order once every job has finished. +SCRATCH_ROOT="$(mktemp -d)" +trap 'rm -rf "$SCRATCH_ROOT"' EXIT + +# A failing test is the normal case a CI runner must handle, and a failing +# background job makes `wait`/`wait -n` return non-zero -- under `set -e` that +# would abort the script right here, before the per-file report below ever +# runs. Every wait in this dispatcher is therefore explicitly guarded. +JOBS="$(nproc 2>/dev/null || echo 4)" +running=0 +i=0 +for f in "${TEST_FILES[@]}"; do + i=$((i + 1)) + ( "$BATS" "$f" >"$SCRATCH_ROOT/$i.log" 2>&1; echo $? >"$SCRATCH_ROOT/$i.status" ) & + running=$((running + 1)) + if [[ "$running" -ge "$JOBS" ]]; then + wait -n || true + running=$((running - 1)) + fi +done +wait || true + +FAIL=0 +TOTAL_OK=0 +TOTAL_NOT_OK=0 +i=0 +for f in "${TEST_FILES[@]}"; do + i=$((i + 1)) + rel="${f#"$REPO_ROOT"/}" + echo "=== $rel ===" + cat "$SCRATCH_ROOT/$i.log" + echo "" + file_ok="$(grep -c '^ok ' "$SCRATCH_ROOT/$i.log" || true)" + file_not_ok="$(grep -c '^not ok ' "$SCRATCH_ROOT/$i.log" || true)" + status="$(cat "$SCRATCH_ROOT/$i.status" 2>/dev/null || echo 1)" + TOTAL_OK=$((TOTAL_OK + file_ok)) + TOTAL_NOT_OK=$((TOTAL_NOT_OK + file_not_ok)) + if [[ "$file_not_ok" -gt 0 || "$status" -ne 0 ]]; then + FAIL=1 + fi +done + +echo "$((TOTAL_OK + TOTAL_NOT_OK)) tests, $TOTAL_NOT_OK failures" +exit "$FAIL" diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 1e73765..26de1d4 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -49,14 +49,42 @@ done < <( | sort ) -# bash before 4.4 treats "${arr[@]}" on an empty array as unbound under -# `set -u`, so every array expansion here uses the ${arr[@]+"${arr[@]}"} guard, -# including the SKIPPED/FAILED loops already fenced by a count check. +# Each test-*.sh is independent (fixtures live under its own mktemp dir, none +# write back into the live repo tree -- verified before adding this), so they +# run concurrently in fixed-size batches instead of one at a time. Batches +# (not a rolling pool) because a bounded rolling pool needs `wait -n`, which +# is bash 4.3+ and this script is explicitly bash-3.2-safe (see the hazard +# scan in test-vale-wrap.sh); plain `wait` -- waiting for every job in the +# current batch -- is available since ancient bash. `getconf` over `nproc` +# for the same reason: `nproc` doesn't exist on macOS. +SCRATCH_ROOT="$(mktemp -d)" +trap 'rm -rf "$SCRATCH_ROOT"' EXIT +JOBS_LIMIT="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" + +running=0 +idx=0 for script in ${SCRIPTS[@]+"${SCRIPTS[@]}"}; do + idx=$((idx + 1)) + ( + rc=0 + bash "$script" >"$SCRATCH_ROOT/$idx.log" 2>&1 || rc=$? + echo "$rc" >"$SCRATCH_ROOT/$idx.status" + ) & + running=$((running + 1)) + if [[ $running -ge $JOBS_LIMIT ]]; then + wait + running=0 + fi +done +wait + +idx=0 +for script in ${SCRIPTS[@]+"${SCRIPTS[@]}"}; do + idx=$((idx + 1)) rel="${script#"$SEARCH_ROOT/"}" echo "=== $rel ===" - rc=0 - bash "$script" || rc=$? + cat "$SCRATCH_ROOT/$idx.log" + rc="$(cat "$SCRATCH_ROOT/$idx.status" 2>/dev/null || echo 1)" if [[ $rc -eq 0 ]]; then PASSED=$((PASSED + 1)) elif [[ $rc -eq $SKIP_EXIT ]]; then