From bd939fc49a8f4de7eba1d05b0db107b08c818c6e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 11:05:17 +0200 Subject: [PATCH 1/6] =?UTF-8?q?perf(test):=20run=20suites=20as=20parallel?= =?UTF-8?q?=20processes=20=E2=80=94=20same=20gates,=20~2.5x=20faster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner executed all 104 suites sequentially (~10 min locally, the bulk of every CI test leg). The suites are process-isolated already (per-process mkdtemp HOME sentinel; two full parallel runs produced zero cross-suite failures), so the serialization was pure convention. - test-runner --list-suites: prints every registered suite, one per line, emitted by the SAME macro table that executes suites — the list cannot drift from the run set by construction. - scripts/run-tests-parallel.sh: runs each suite as its own process (jobs = CPU count; CBM_TEST_PAR_JOBS overrides) under a ZERO-LOSS CONTRACT: a union guard fails the gate if the set of suites that produced a result differs from --list-suites (nothing can be silently dropped, a newly added suite is picked up automatically); per-suite pass/fail/skip are summed into the sequential runner's exact summary format; any suite crash, failure, or omission exits nonzero. Per-suite wall times are printed for balance tracking. - make test-par: the parallel target. make test (sequential) is unchanged and remains the escape hatch (CBM_TEST_SEQUENTIAL=1 in test.sh). - scripts/test.sh: builds, then routes through test-par — every CI test leg gets the speedup with zero workflow-topology change (same jobs, same gates, same billing; the legs just finish sooner). - The stack_overflow suite is split into a/b/c (7+6+7 of its 20 tests, pure re-registration): as one suite it was the wall-clock critical path of any parallel run — every other suite finished underneath its ~4 minutes. Measured locally (Apple Silicon, ASan runner): sequential ~10 min vs parallel 232 s, totals identical (6,361 passed / 0 failed / 1 skipped), union guard clean. The TSan leg keeps its dedicated subset runner (sequential) — out of scope here. Signed-off-by: Martin Vogel --- Makefile.cbm | 8 ++- scripts/run-tests-parallel.sh | 102 ++++++++++++++++++++++++++++++++++ scripts/test.sh | 13 ++++- tests/test_main.c | 51 +++++++++++++---- tests/test_stack_overflow.c | 20 ++++++- 5 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 scripts/run-tests-parallel.sh diff --git a/Makefile.cbm b/Makefile.cbm index b78018c11..d54b01460 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -513,7 +513,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o # ── Targets ────────────────────────────────────────────────────── -.PHONY: test test-repro test-foundation test-tsan cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security +.PHONY: test test-par test-repro test-foundation test-tsan cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security $(BUILD_DIR): mkdir -p $(BUILD_DIR) @@ -639,6 +639,12 @@ $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_ test: $(BUILD_DIR)/test-runner cd $(CURDIR) && $(BUILD_DIR)/test-runner +# Parallel variant: every suite as its own process; identical gate quality +# (see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh — union guard +# against --list-suites + aggregated pass/fail/skip totals). +test-par: $(BUILD_DIR)/test-runner + cd $(CURDIR) && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner + # Focused native development is intentionally a separate, explicit target so # an inherited TEST_SUITES value cannot silently narrow the gating test target. TEST_SUITES ?= diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh new file mode 100644 index 000000000..63302de88 --- /dev/null +++ b/scripts/run-tests-parallel.sh @@ -0,0 +1,102 @@ +#!/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 [jobs] +# jobs defaults to CBM_TEST_PAR_JOBS, then the CPU count. + +set -uo pipefail + +RUNNER="${1:?usage: run-tests-parallel.sh [jobs]}" +JOBS="${2:-${CBM_TEST_PAR_JOBS:-}}" + +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" + +SUITES_FILE="$LOGDIR/suites.txt" +RESULTS_FILE="$LOGDIR/results.txt" + +if ! "$RUNNER" --list-suites > "$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 +echo "=== parallel test run: $NSUITES suites, $JOBS jobs ===" + +export RUNNER LOGDIR RESULTS_FILE +run_one() { + s="$1" + t0=$SECONDS + "$RUNNER" "$s" > "$LOGDIR/$s.log" 2>&1 + rc=$? + secs=$((SECONDS - t0)) + summary=$(grep -E '^ [0-9]+ passed' "$LOGDIR/$s.log" | tail -1) + pass=$(printf '%s' "$summary" | sed -n 's/^ \([0-9]*\) passed.*/\1/p') + failn=$(printf '%s' "$summary" | sed -n 's/.* \([0-9]*\) failed.*/\1/p') + skip=$(printf '%s' "$summary" | sed -n 's/.* \([0-9]*\) skipped.*/\1/p') + # A single short echo line is an atomic append (< PIPE_BUF). + echo "$s rc=$rc pass=${pass:-0} fail=${failn:-0} skip=${skip:-0} secs=$secs" >> "$RESULTS_FILE" +} +export -f run_one + +xargs -P "$JOBS" -I{} bash -c 'run_one "$@"' _ {} < "$SUITES_FILE" + +# ── Union guard: every listed suite produced exactly one result ── +MISSING=$(comm -23 <(sort "$SUITES_FILE") <(awk '{print $1}' "$RESULTS_FILE" | sort -u)) +DUPES=$(awk '{print $1}' "$RESULTS_FILE" | sort | uniq -d) +if [ -n "$MISSING" ] || [ -n "$DUPES" ]; then + echo "FAIL: shard union does not match --list-suites (GATE-QUALITY LOSS)" >&2 + [ -n "$MISSING" ] && echo " never ran: $MISSING" >&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 (last 30 lines) ────" + tail -30 "$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 diff --git a/scripts/test.sh b/scripts/test.sh index 1254419a6..a2d7c56a0 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -57,8 +57,17 @@ verify_compiler "$CC" # Step 1: Clean scripts/clean.sh -# Step 2 + 3: Build and run tests (Makefile applies $ARCHFLAGS on macOS) -make -j"$NPROC" -f Makefile.cbm test $MAKE_ARGS +# Step 2 + 3: Build, then run every suite as parallel processes (identical +# gate quality — see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh: +# the suite set is enumerated from the runner itself and union-guarded, and +# pass/fail/skip totals aggregate to the same numbers as the sequential run). +# CBM_TEST_SEQUENTIAL=1 restores the single-process runner. +make -j"$NPROC" -f Makefile.cbm build/c/test-runner $MAKE_ARGS +if [ "${CBM_TEST_SEQUENTIAL:-0}" = "1" ]; then + make -f Makefile.cbm test $MAKE_ARGS +else + make -f Makefile.cbm test-par $MAKE_ARGS +fi # Step 4: C++ large-TU index-hang regression guard (#410). Runs the PROD binary # in a subprocess with a wall-clock timeout — a hang must fail, not block the run. diff --git a/tests/test_main.c b/tests/test_main.c index ffbb1fb5c..64f4b784d 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -166,11 +166,20 @@ static bool suite_requested(const char *name) { return requested; } -#define RUN_SELECTED_SUITE(name) \ - do { \ - if (suite_requested(#name)) { \ - RUN_SUITE(name); \ - } \ +/* --list-suites: print every registered suite name, one per line, without + * running anything. The list and the run share this ONE macro table, so the + * list can never drift from what actually executes — shard runners (make + * test-par, the CI shard matrix) enumerate suites from here and their union + * guard compares against it. */ +static bool g_list_only = false; + +#define RUN_SELECTED_SUITE(name) \ + do { \ + if (g_list_only) { \ + printf("%s\n", #name); \ + } else if (suite_requested(#name)) { \ + RUN_SUITE(name); \ + } \ } while (0) /* Forward declarations of suite functions */ @@ -273,7 +282,9 @@ extern void suite_semantic(void); extern void suite_ast_profile(void); extern void suite_slab_alloc(void); extern void suite_simhash(void); -extern void suite_stack_overflow(void); +extern void suite_stack_overflow_a(void); +extern void suite_stack_overflow_b(void); +extern void suite_stack_overflow_c(void); extern void suite_dump_verify(void); extern void suite_dump_verify_io(void); @@ -308,16 +319,23 @@ int main(int argc, char **argv) { return 2; } - g_suite_argc = argc; - g_suite_argv = argv; - if (argc > 1) { + if (argc == 2 && strcmp(argv[1], "--list-suites") == 0) { + g_list_only = true; + g_suite_argc = 1; /* no suite-name args to match */ + } else { + g_suite_argc = argc; + g_suite_argv = argv; + } + if (g_suite_argc > 1) { g_suite_arg_matched = calloc((size_t)argc, sizeof(*g_suite_arg_matched)); if (!g_suite_arg_matched) { fprintf(stderr, "Failed to allocate test-suite argument tracking\n"); return 1; } } - printf("\n codebase-memory-mcp C test suite\n"); + if (!g_list_only) { + printf("\n codebase-memory-mcp C test suite\n"); + } /* Foundation */ RUN_SELECTED_SUITE(arena); @@ -454,8 +472,11 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(ast_profile); RUN_SELECTED_SUITE(simhash); - /* Stack overflow regression (GitHub #199) */ - RUN_SELECTED_SUITE(stack_overflow); + /* Stack overflow regression (GitHub #199) — split a/b/c so no single + * suite serializes a parallel run (each ~1/3 of the old wall time). */ + RUN_SELECTED_SUITE(stack_overflow_a); + RUN_SELECTED_SUITE(stack_overflow_b); + RUN_SELECTED_SUITE(stack_overflow_c); /* Integration (end-to-end) */ RUN_SELECTED_SUITE(integration); @@ -480,6 +501,12 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(incremental); + if (g_list_only) { + fflush(stdout); + cbm_kind_in_set_free_cache(); + sqlite3_shutdown(); + return 0; + } bool any_suite_matched = false; for (int i = 1; i < g_suite_argc; i++) { any_suite_matched = any_suite_matched || g_suite_arg_matched[i]; diff --git a/tests/test_stack_overflow.c b/tests/test_stack_overflow.c index 88ce009ce..63ef808c4 100644 --- a/tests/test_stack_overflow.c +++ b/tests/test_stack_overflow.c @@ -750,7 +750,12 @@ TEST(lsp_kotlin_deep_nesting_no_crash) { * Suite registration * ═══════════════════════════════════════════════════════════════════ */ -SUITE(stack_overflow) { +/* Split into three sub-suites so parallel/sharded runs are not serialized + * behind one ~4-minute suite (it was the wall-clock critical path: every + * other suite finished underneath it). Pure re-registration — the 20 + * RUN_TEST entries are exactly the ones the single suite carried; the + * before/after test-count parity is asserted in the shard runner. */ +SUITE(stack_overflow_a) { cbm_init(); RUN_TEST(ts_allocator_bound_to_mimalloc_issue424); @@ -760,6 +765,13 @@ SUITE(stack_overflow) { RUN_TEST(lsp_cpp_deep_expression_no_crash); RUN_TEST(lsp_python_deep_expression_no_crash); RUN_TEST(lsp_perl_deep_expression_no_crash); + + cbm_shutdown(); +} + +SUITE(stack_overflow_b) { + cbm_init(); + RUN_TEST(perl_glr_deep_parse_recursion_capped); RUN_TEST(lsp_ts_cyclic_types_no_crash); RUN_TEST(lsp_python_deep_nesting_no_crash); @@ -767,6 +779,12 @@ SUITE(stack_overflow) { RUN_TEST(lsp_php_deep_nesting_no_crash); RUN_TEST(lsp_kotlin_deep_nesting_no_crash); + cbm_shutdown(); +} + +SUITE(stack_overflow_c) { + cbm_init(); + RUN_TEST(js_calls_exceed_512); RUN_TEST(python_calls_exceed_512); RUN_TEST(go_calls_exceed_1024); From c8e07a2f9fa62ef408d8be03fb0e4a71d33a4120 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 12:24:07 +0200 Subject: [PATCH 2/6] fix(test-par): CRLF-proof the suite list, serialize deadline-sensitive suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First CI validation round of the parallel runner, three findings: - Windows failed ALL 104 suites with "0 passed, 1 failed" each: the CRT emits --list-suites lines as CRLF, so every dispatched name carried a trailing CR ("arena\r" is an unknown suite). The union guard caught it loudly, exactly as designed. The driver now strips CR when writing the suite list. - The ubuntu-gcc legs had 3 real failures in the cli suite: it spawns subprocesses with fixed deadlines, and a fully saturated 4-core runner starves those deadlines into flakes. Deadline-sensitive suites (cli, subprocess, watcher, incremental, httpd, ui, index_resilience, and the stack_overflow family) now run SEQUENTIALLY after the parallel wave on a quiet machine — same suites, same tests, same gates, only the schedule differs; the union guard checks the combined result set. - Failing suites now print every FAIL site with context (the tail-30 of a long suite log hid which tests actually failed on CI). Local: totals unchanged (6,361 passed / 0 failed / 1 skipped), union guard clean. Signed-off-by: Martin Vogel --- scripts/run-tests-parallel.sh | 45 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index 63302de88..ed1fd8cb9 100644 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -40,7 +40,10 @@ mkdir -p "$LOGDIR" SUITES_FILE="$LOGDIR/suites.txt" RESULTS_FILE="$LOGDIR/results.txt" -if ! "$RUNNER" --list-suites > "$SUITES_FILE"; then +# 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 @@ -49,7 +52,33 @@ 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 -echo "=== parallel test run: $NSUITES suites, $JOBS jobs ===" +# 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. +SERIAL_SUITES="cli subprocess watcher incremental httpd ui index_resilience \ + stack_overflow_a stack_overflow_b stack_overflow_c" +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" +echo "=== parallel test run: $NSUITES suites ($(wc -l < "$SER_FILE" | tr -d ' ') serial-tail), $JOBS jobs ===" export RUNNER LOGDIR RESULTS_FILE run_one() { @@ -67,7 +96,11 @@ run_one() { } export -f run_one -xargs -P "$JOBS" -I{} bash -c 'run_one "$@"' _ {} < "$SUITES_FILE" +xargs -P "$JOBS" -I{} bash -c 'run_one "$@"' _ {} < "$PAR_FILE" +# Serial tail: quiet machine for the deadline-sensitive suites. +while IFS= read -r sname; do + run_one "$sname" +done < "$SER_FILE" # ── Union guard: every listed suite produced exactly one result ── MISSING=$(comm -23 <(sort "$SUITES_FILE") <(awk '{print $1}' "$RESULTS_FILE" | sort -u)) @@ -88,8 +121,10 @@ 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 (last 30 lines) ────" - tail -30 "$LOGDIR/$f.log" + 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 "────────────────────────────────────────────" From d8a78e8cf4c5ffc9cd53c25420d9e826de9838a0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 13:12:20 +0200 Subject: [PATCH 3/6] fix(test): deterministic ceiling for the GLR merge-cap regression guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit perl_glr_deep_parse_recursion_capped wandered between 2s and 218s for the IDENTICAL input: past the merge cap the ambiguity-exploded GLR parse grinds at an environment-dependent rate (allocator/scheduler state in the forked child), and the test paid for however long that grind ran. It was the critical path of every parallel run and the dominant chunk of the sequential one. The guard's actual signal is the CRASH, not the grind: when CBM_TS_STACK_MERGE_MAX_DEPTH regresses, the recursive stack-merge overflows within the first second at this depth (same profile as the sibling pre-guard probe, rc=139 in under a second). The forked child now arms a 10s alarm whose handler exits cleanly — a real SIGSEGV/SIGBUS still terminates the child by signal first and stays visible to WIFSIGNALED, so the RED path is untouched; only the pointless remainder of the grind is skipped. Windows (in-process branch) is unchanged. Suite b: 13s across three consecutive runs (was 2-218s). Full parallel run: 97s wall, 6,374 passed / 0 failed / 1 skipped, union guard clean — vs 351s sequential on the same machine (3.6x). Signed-off-by: Martin Vogel --- tests/test_stack_overflow.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_stack_overflow.c b/tests/test_stack_overflow.c index 63ef808c4..2f3612f09 100644 --- a/tests/test_stack_overflow.c +++ b/tests/test_stack_overflow.c @@ -441,6 +441,15 @@ static bool so_extract_crashes(const char *content, CBMLanguage lang, const char #endif } +#if !defined(_WIN32) +/* Child-side alarm handler: a clean exit on the deadline — never reached on + * a crash (SIGSEGV/SIGBUS terminate the child first and stay visible). */ +static void so_parse_alarm_exit(int sig) { + (void)sig; + _exit(0); +} +#endif + /* Parse `content` with tree-sitter DIRECTLY in a forked child — bypassing * cbm_extract_file's Perl pre-parse nesting guard — returning true if the child * died by signal. This is the crash-isolating regression for the vendored GLR @@ -471,6 +480,15 @@ static bool so_parse_crashes(const char *content, CBMLanguage lang) { return false; } if (pid == 0) { + /* Deterministic ceiling: past the merge cap the ambiguity-exploded + * GLR parse GRINDS (minutes, environment-dependent — measured 2s to + * 218s for the identical input). The guard's signal is the CRASH, + * which fires within the first seconds when the recursion cap + * regresses — so a clean exit after 10s proves the cap held without + * paying for the rest of the grind. SIGSEGV/SIGBUS still report as + * WIFSIGNALED; the alarm handler exits cleanly, never masking them. */ + signal(SIGALRM, so_parse_alarm_exit); + alarm(10); TSParser *parser = ts_parser_new(); if (parser) { ts_parser_set_language(parser, ts_lang); From b0a1287794f78085c8108245833d896716666995 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 13:31:18 +0200 Subject: [PATCH 4/6] test(so): document the POSIX GLR-cap branch as a bounded smoke, per falsification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up correcting d8a78e8, whose message claimed a broken merge cap crashes "within the first second" so the 10s alarm could not mask it. Falsification disproved that claim AND found the guard was vacuous on POSIX from the start: - With CBM_TS_STACK_MERGE_MAX_DEPTH deleted outright, the ORIGINAL unbounded test stayed GREEN (220s full grind, no crash) on macOS ASan. - Small thread stacks (2 MB, then 512 KB) did not change that. - Instrumenting the vendored recursion showed why: the recursive merge path is NEVER ENTERED for this input in the observable window — max depth stays 0. There is nothing for a stack bound to catch here. So the cap-regression DETECTION lives in the Windows in-process branch (real ~1 MB native stack, where #913 actually manifested — exercised by the windows CI leg). The POSIX branch is, and always was, a bounded no-crash smoke of the pathological parse; the 10s alarm makes it deterministic (12-15s across three runs, was 2-218s machine-dependent) without any detection loss, because there was no POSIX detection to lose. The experimental pthread machinery is dropped; the test comment now carries the falsification data so no future reader mistakes the POSIX branch for a cap guard. Signed-off-by: Martin Vogel --- tests/test_stack_overflow.c | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/test_stack_overflow.c b/tests/test_stack_overflow.c index 2f3612f09..6b2e8228c 100644 --- a/tests/test_stack_overflow.c +++ b/tests/test_stack_overflow.c @@ -448,6 +448,7 @@ static void so_parse_alarm_exit(int sig) { (void)sig; _exit(0); } + #endif /* Parse `content` with tree-sitter DIRECTLY in a forked child — bypassing @@ -480,13 +481,18 @@ static bool so_parse_crashes(const char *content, CBMLanguage lang) { return false; } if (pid == 0) { - /* Deterministic ceiling: past the merge cap the ambiguity-exploded - * GLR parse GRINDS (minutes, environment-dependent — measured 2s to - * 218s for the identical input). The guard's signal is the CRASH, - * which fires within the first seconds when the recursion cap - * regresses — so a clean exit after 10s proves the cap held without - * paying for the rest of the grind. SIGSEGV/SIGBUS still report as - * WIFSIGNALED; the alarm handler exits cleanly, never masking them. */ + /* Deterministic ceiling. Falsification data (2026-07-18, macOS + * ASan): with CBM_TS_STACK_MERGE_MAX_DEPTH deleted outright, this + * branch stayed GREEN both unbounded (220s full grind, no crash) + * and bounded — instrumentation shows the recursive merge path is + * never even entered here (max depth 0 through the whole window). + * The cap-regression DETECTION therefore lives in the Windows + * in-process branch above (real ~1 MB native stack, where #913 + * manifested); this POSIX branch is a bounded no-crash smoke of + * the pathological parse, and grinding past 10s adds no signal + * (unbounded it wandered 2s-218s with machine state). A real + * SIGSEGV/SIGBUS still terminates the child by signal before the + * alarm handler can exit cleanly. */ signal(SIGALRM, so_parse_alarm_exit); alarm(10); TSParser *parser = ts_parser_new(); From 95659b3955eb580b4322c25ec37a3b41589130a1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 13:39:42 +0200 Subject: [PATCH 5/6] test-par: serialize the mcp suite too (both alarm-deadline tests live there) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flake audit: the whole corpus has exactly two wall-clock test deadlines — the EOF-shutdown alarm(5) and the issue-832 supervisor-spawn alarm(60), both in the mcp suite (the latter already flaked once under heavy local load). Serializing mcp costs ~6s of tail and closes the starvable-deadline class completely; every remaining parallel-wave suite is deadline-free. Signed-off-by: Martin Vogel --- scripts/run-tests-parallel.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index ed1fd8cb9..b86e2147a 100644 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -62,7 +62,7 @@ fi # 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. -SERIAL_SUITES="cli subprocess watcher incremental httpd ui index_resilience \ +SERIAL_SUITES="cli subprocess watcher incremental httpd ui index_resilience mcp \ stack_overflow_a stack_overflow_b stack_overflow_c" is_serial() { case " $SERIAL_SUITES " in *" $1 "*) return 0 ;; *) return 1 ;; esac From bc3594b728c3f1bd9e679143b10dd53d1666f279 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 14:10:06 +0200 Subject: [PATCH 6/6] fix(test): pin XDG_CONFIG_HOME in the three env-coupled cli tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parallel runner's isolated-process execution exposed three cli tests that were passing for the wrong reason on Linux: cbm_zed_config_dir (and the VS Code/vendor path resolvers) prefer $XDG_CONFIG_HOME over $HOME/.config, CI runners export it, and an isolated `test-runner cli` process therefore resolved config paths OUTSIDE the test fixture — the tests only stayed green in the all-suites single process because an earlier suite's env mutations leaked into them (hidden cross-suite coupling, deterministic failure in isolation, macOS unaffected since its resolvers do not consult XDG). - cli_detect_agents_finds_zed: save/set XDG_CONFIG_HOME to the fixture's .config for the detection call, restore after. - cli_vscode_profile_mcp_uninstall: same pin alongside its existing HOME/PATH/APPDATA handling. - cli_durable_profiles_follow_current_vendor_paths: XDG_CONFIG_HOME joins its unset-and-restore env list. All 222 cli tests pass locally with a hostile XDG_CONFIG_HOME exported. No production code changed; no assertion weakened. Signed-off-by: Martin Vogel --- tests/test_cli.c | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.c b/tests/test_cli.c index c6d72757d..8a5d39bfe 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -1413,6 +1413,10 @@ TEST(cli_vscode_profile_mcp_uninstall) { char *saved_home = save_test_env("HOME"); char *saved_path = save_test_env("PATH"); char *saved_appdata = save_test_env("APPDATA"); + char *saved_xdg = save_test_env("XDG_CONFIG_HOME"); + char xdg_dir[640]; + snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir); + cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolvers prefer XDG */ cbm_setenv("HOME", tmpdir, 1); cbm_setenv("PATH", tmpdir, 1); #ifdef _WIN32 @@ -1432,6 +1436,7 @@ TEST(cli_vscode_profile_mcp_uninstall) { restore_test_env("HOME", saved_home); restore_test_env("PATH", saved_path); restore_test_env("APPDATA", saved_appdata); + restore_test_env("XDG_CONFIG_HOME", saved_xdg); test_rmdir_r(tmpdir); if (rc != 0 || !removed) FAIL("VS Code uninstall must remove MCP entries from every existing profile"); @@ -2953,8 +2958,20 @@ TEST(cli_durable_profiles_follow_current_vendor_paths) { FAIL("cbm_mkdtemp failed"); const char *const env_names[] = { - "HOME", "PATH", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "QWEN_HOME", - "COPILOT_HOME", "CLINE_DATA_DIR", "KIRO_HOME", "VIBE_HOME", "OPENCODE_CONFIG", + "HOME", + "PATH", + "CLAUDE_CONFIG_DIR", + "CODEX_HOME", + "QWEN_HOME", + "COPILOT_HOME", + "CLINE_DATA_DIR", + "KIRO_HOME", + "VIBE_HOME", + "OPENCODE_CONFIG", + /* Linux path resolvers prefer $XDG_CONFIG_HOME over $HOME/.config; + * CI runners export it, so an isolated-process run resolved OUTSIDE + * the fixture home (green only via env leaked from earlier suites). */ + "XDG_CONFIG_HOME", }; char *saved_env[sizeof(env_names) / sizeof(env_names[0])]; for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { @@ -7786,7 +7803,12 @@ TEST(cli_detect_agents_finds_zed) { #endif test_mkdirp(dir); + char *saved_xdg = save_test_env("XDG_CONFIG_HOME"); + char xdg_dir[640]; + snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir); + cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */ cbm_detected_agents_t agents = cbm_detect_agents(tmpdir); + restore_test_env("XDG_CONFIG_HOME", saved_xdg); ASSERT_TRUE(agents.zed); test_rmdir_r(tmpdir);