Skip to content

Commit 7d6cdb2

Browse files
authored
Merge pull request #1164 from DeusData/feat/test-sharding
Parallel test execution: per-suite processes with a zero-loss gate contract
2 parents 94c8e96 + bc3594b commit 7d6cdb2

6 files changed

Lines changed: 261 additions & 18 deletions

File tree

Makefile.cbm

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o
513513

514514
# ── Targets ──────────────────────────────────────────────────────
515515

516-
.PHONY: test test-repro test-foundation test-tsan cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security
516+
.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
517517

518518
$(BUILD_DIR):
519519
mkdir -p $(BUILD_DIR)
@@ -639,6 +639,12 @@ $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_
639639
test: $(BUILD_DIR)/test-runner
640640
cd $(CURDIR) && $(BUILD_DIR)/test-runner
641641

642+
# Parallel variant: every suite as its own process; identical gate quality
643+
# (see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh — union guard
644+
# against --list-suites + aggregated pass/fail/skip totals).
645+
test-par: $(BUILD_DIR)/test-runner
646+
cd $(CURDIR) && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner
647+
642648
# Focused native development is intentionally a separate, explicit target so
643649
# an inherited TEST_SUITES value cannot silently narrow the gating test target.
644650
TEST_SUITES ?=

scripts/run-tests-parallel.sh

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env bash
2+
# run-tests-parallel.sh — run every registered test suite as parallel
3+
# processes of the already-built test-runner.
4+
#
5+
# ZERO-LOSS CONTRACT (gate quality must be identical to the sequential run):
6+
# 1. The suite list comes from `test-runner --list-suites`, which is printed
7+
# by the SAME macro table that executes suites — the list cannot drift
8+
# from reality by construction.
9+
# 2. UNION GUARD: after the run, the set of suites that actually produced a
10+
# result is compared against that list; any difference (a suite that
11+
# never ran, or ran twice) fails the gate loudly. A newly added suite is
12+
# picked up automatically on the next invocation.
13+
# 3. Per-suite pass/fail/skip counts are summed and reported in the same
14+
# "N passed[, M failed][, K skipped]" shape as the sequential runner, so
15+
# before/after totals are directly comparable.
16+
# 4. ANY suite failing, crashing (nonzero exit), or missing ⇒ exit 1.
17+
#
18+
# Usage: run-tests-parallel.sh <path-to-test-runner> [jobs]
19+
# jobs defaults to CBM_TEST_PAR_JOBS, then the CPU count.
20+
21+
set -uo pipefail
22+
23+
RUNNER="${1:?usage: run-tests-parallel.sh <path-to-test-runner> [jobs]}"
24+
JOBS="${2:-${CBM_TEST_PAR_JOBS:-}}"
25+
26+
if [ -z "$JOBS" ]; then
27+
if command -v nproc >/dev/null 2>&1; then
28+
JOBS=$(nproc)
29+
elif command -v sysctl >/dev/null 2>&1; then
30+
JOBS=$(sysctl -n hw.ncpu 2>/dev/null || echo 4)
31+
else
32+
JOBS=4
33+
fi
34+
fi
35+
36+
LOGDIR="$(dirname "$RUNNER")/test-logs"
37+
rm -rf "$LOGDIR"
38+
mkdir -p "$LOGDIR"
39+
40+
SUITES_FILE="$LOGDIR/suites.txt"
41+
RESULTS_FILE="$LOGDIR/results.txt"
42+
43+
# tr strips the CR that the Windows CRT appends to every stdout line — a
44+
# suites file with CRLF endings made the runner reject every name
45+
# ("arena\r" is an unknown suite) and fail all 104 suites on CI.
46+
if ! "$RUNNER" --list-suites | tr -d '\r' > "$SUITES_FILE"; then
47+
echo "FAIL: test-runner --list-suites exited nonzero" >&2
48+
exit 1
49+
fi
50+
NSUITES=$(wc -l < "$SUITES_FILE" | tr -d ' ')
51+
if [ "$NSUITES" -lt 1 ] || grep -qvE '^[a-z0-9_]+$' "$SUITES_FILE"; then
52+
echo "FAIL: suite list empty or malformed (runner too old for --list-suites?)" >&2
53+
exit 1
54+
fi
55+
# Timing-sensitive suites run SEQUENTIALLY after the parallel wave: they
56+
# spawn subprocesses / watch the filesystem / bind ports with fixed
57+
# deadlines, and a saturated 4-core CI runner starves those deadlines into
58+
# flakes (3 cli-suite failures on the ubuntu legs of the first CI run).
59+
# Same suites, same tests, same gates — only the schedule differs; the
60+
# union guard below still checks the COMBINED result set.
61+
# stack_overflow_a/b/c: their giant-recursion ASan allocations stall ~100x
62+
# when co-STARTED with a large wave on Apple Silicon (2s staggered vs ~230s
63+
# simultaneous — a local scheduler/zone quirk, not contention: job count
64+
# does not change it). Staggered in the tail they cost seconds.
65+
SERIAL_SUITES="cli subprocess watcher incremental httpd ui index_resilience mcp \
66+
stack_overflow_a stack_overflow_b stack_overflow_c"
67+
is_serial() {
68+
case " $SERIAL_SUITES " in *" $1 "*) return 0 ;; *) return 1 ;; esac
69+
}
70+
PAR_FILE="$LOGDIR/suites-parallel.txt"
71+
SER_FILE="$LOGDIR/suites-serial.txt"
72+
: > "$PAR_FILE"
73+
: > "$SER_FILE"
74+
while IFS= read -r sname; do
75+
if is_serial "$sname"; then
76+
echo "$sname" >> "$SER_FILE"
77+
else
78+
echo "$sname" >> "$PAR_FILE"
79+
fi
80+
done < "$SUITES_FILE"
81+
echo "=== parallel test run: $NSUITES suites ($(wc -l < "$SER_FILE" | tr -d ' ') serial-tail), $JOBS jobs ==="
82+
83+
export RUNNER LOGDIR RESULTS_FILE
84+
run_one() {
85+
s="$1"
86+
t0=$SECONDS
87+
"$RUNNER" "$s" > "$LOGDIR/$s.log" 2>&1
88+
rc=$?
89+
secs=$((SECONDS - t0))
90+
summary=$(grep -E '^ [0-9]+ passed' "$LOGDIR/$s.log" | tail -1)
91+
pass=$(printf '%s' "$summary" | sed -n 's/^ \([0-9]*\) passed.*/\1/p')
92+
failn=$(printf '%s' "$summary" | sed -n 's/.* \([0-9]*\) failed.*/\1/p')
93+
skip=$(printf '%s' "$summary" | sed -n 's/.* \([0-9]*\) skipped.*/\1/p')
94+
# A single short echo line is an atomic append (< PIPE_BUF).
95+
echo "$s rc=$rc pass=${pass:-0} fail=${failn:-0} skip=${skip:-0} secs=$secs" >> "$RESULTS_FILE"
96+
}
97+
export -f run_one
98+
99+
xargs -P "$JOBS" -I{} bash -c 'run_one "$@"' _ {} < "$PAR_FILE"
100+
# Serial tail: quiet machine for the deadline-sensitive suites.
101+
while IFS= read -r sname; do
102+
run_one "$sname"
103+
done < "$SER_FILE"
104+
105+
# ── Union guard: every listed suite produced exactly one result ──
106+
MISSING=$(comm -23 <(sort "$SUITES_FILE") <(awk '{print $1}' "$RESULTS_FILE" | sort -u))
107+
DUPES=$(awk '{print $1}' "$RESULTS_FILE" | sort | uniq -d)
108+
if [ -n "$MISSING" ] || [ -n "$DUPES" ]; then
109+
echo "FAIL: shard union does not match --list-suites (GATE-QUALITY LOSS)" >&2
110+
[ -n "$MISSING" ] && echo " never ran: $MISSING" >&2
111+
[ -n "$DUPES" ] && echo " ran twice: $DUPES" >&2
112+
exit 1
113+
fi
114+
115+
TOTAL_PASS=$(awk -F'pass=' '{split($2,a," "); s+=a[1]} END{print s+0}' "$RESULTS_FILE")
116+
TOTAL_FAIL=$(awk -F'fail=' '{split($2,a," "); s+=a[1]} END{print s+0}' "$RESULTS_FILE")
117+
TOTAL_SKIP=$(awk -F'skip=' '{split($2,a," "); s+=a[1]} END{print s+0}' "$RESULTS_FILE")
118+
BAD_RC=$(grep -cv ' rc=0 ' "$RESULTS_FILE" || true)
119+
120+
echo "── 8 slowest suites ──"
121+
sort -t= -k6 -rn "$RESULTS_FILE" | head -8
122+
grep -v ' rc=0 ' "$RESULTS_FILE" || true
123+
for f in $(grep -v ' rc=0 ' "$RESULTS_FILE" | awk '{print $1}'); do
124+
echo "──── $f: every failure site ────"
125+
grep -B2 -A8 "FAIL" "$LOGDIR/$f.log" | head -120
126+
echo "──── $f: last 15 lines ────"
127+
tail -15 "$LOGDIR/$f.log"
128+
done
129+
130+
echo "────────────────────────────────────────────"
131+
echo " $TOTAL_PASS passed, $TOTAL_FAIL failed, $TOTAL_SKIP skipped ($NSUITES suites, $JOBS jobs)"
132+
echo "────────────────────────────────────────────"
133+
134+
if [ "$TOTAL_FAIL" -gt 0 ] || [ "$BAD_RC" -gt 0 ]; then
135+
exit 1
136+
fi
137+
exit 0

scripts/test.sh

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,17 @@ verify_compiler "$CC"
5757
# Step 1: Clean
5858
scripts/clean.sh
5959

60-
# Step 2 + 3: Build and run tests (Makefile applies $ARCHFLAGS on macOS)
61-
make -j"$NPROC" -f Makefile.cbm test $MAKE_ARGS
60+
# Step 2 + 3: Build, then run every suite as parallel processes (identical
61+
# gate quality — see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh:
62+
# the suite set is enumerated from the runner itself and union-guarded, and
63+
# pass/fail/skip totals aggregate to the same numbers as the sequential run).
64+
# CBM_TEST_SEQUENTIAL=1 restores the single-process runner.
65+
make -j"$NPROC" -f Makefile.cbm build/c/test-runner $MAKE_ARGS
66+
if [ "${CBM_TEST_SEQUENTIAL:-0}" = "1" ]; then
67+
make -f Makefile.cbm test $MAKE_ARGS
68+
else
69+
make -f Makefile.cbm test-par $MAKE_ARGS
70+
fi
6271

6372
# Step 4: C++ large-TU index-hang regression guard (#410). Runs the PROD binary
6473
# in a subprocess with a wall-clock timeout — a hang must fail, not block the run.

tests/test_cli.c

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1413,6 +1413,10 @@ TEST(cli_vscode_profile_mcp_uninstall) {
14131413
char *saved_home = save_test_env("HOME");
14141414
char *saved_path = save_test_env("PATH");
14151415
char *saved_appdata = save_test_env("APPDATA");
1416+
char *saved_xdg = save_test_env("XDG_CONFIG_HOME");
1417+
char xdg_dir[640];
1418+
snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir);
1419+
cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolvers prefer XDG */
14161420
cbm_setenv("HOME", tmpdir, 1);
14171421
cbm_setenv("PATH", tmpdir, 1);
14181422
#ifdef _WIN32
@@ -1432,6 +1436,7 @@ TEST(cli_vscode_profile_mcp_uninstall) {
14321436
restore_test_env("HOME", saved_home);
14331437
restore_test_env("PATH", saved_path);
14341438
restore_test_env("APPDATA", saved_appdata);
1439+
restore_test_env("XDG_CONFIG_HOME", saved_xdg);
14351440
test_rmdir_r(tmpdir);
14361441
if (rc != 0 || !removed)
14371442
FAIL("VS Code uninstall must remove MCP entries from every existing profile");
@@ -2953,8 +2958,20 @@ TEST(cli_durable_profiles_follow_current_vendor_paths) {
29532958
FAIL("cbm_mkdtemp failed");
29542959

29552960
const char *const env_names[] = {
2956-
"HOME", "PATH", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "QWEN_HOME",
2957-
"COPILOT_HOME", "CLINE_DATA_DIR", "KIRO_HOME", "VIBE_HOME", "OPENCODE_CONFIG",
2961+
"HOME",
2962+
"PATH",
2963+
"CLAUDE_CONFIG_DIR",
2964+
"CODEX_HOME",
2965+
"QWEN_HOME",
2966+
"COPILOT_HOME",
2967+
"CLINE_DATA_DIR",
2968+
"KIRO_HOME",
2969+
"VIBE_HOME",
2970+
"OPENCODE_CONFIG",
2971+
/* Linux path resolvers prefer $XDG_CONFIG_HOME over $HOME/.config;
2972+
* CI runners export it, so an isolated-process run resolved OUTSIDE
2973+
* the fixture home (green only via env leaked from earlier suites). */
2974+
"XDG_CONFIG_HOME",
29582975
};
29592976
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
29602977
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
@@ -7786,7 +7803,12 @@ TEST(cli_detect_agents_finds_zed) {
77867803
#endif
77877804
test_mkdirp(dir);
77887805

7806+
char *saved_xdg = save_test_env("XDG_CONFIG_HOME");
7807+
char xdg_dir[640];
7808+
snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir);
7809+
cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */
77897810
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
7811+
restore_test_env("XDG_CONFIG_HOME", saved_xdg);
77907812
ASSERT_TRUE(agents.zed);
77917813

77927814
test_rmdir_r(tmpdir);

tests/test_main.c

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,20 @@ static bool suite_requested(const char *name) {
166166
return requested;
167167
}
168168

169-
#define RUN_SELECTED_SUITE(name) \
170-
do { \
171-
if (suite_requested(#name)) { \
172-
RUN_SUITE(name); \
173-
} \
169+
/* --list-suites: print every registered suite name, one per line, without
170+
* running anything. The list and the run share this ONE macro table, so the
171+
* list can never drift from what actually executes — shard runners (make
172+
* test-par, the CI shard matrix) enumerate suites from here and their union
173+
* guard compares against it. */
174+
static bool g_list_only = false;
175+
176+
#define RUN_SELECTED_SUITE(name) \
177+
do { \
178+
if (g_list_only) { \
179+
printf("%s\n", #name); \
180+
} else if (suite_requested(#name)) { \
181+
RUN_SUITE(name); \
182+
} \
174183
} while (0)
175184

176185
/* Forward declarations of suite functions */
@@ -273,7 +282,9 @@ extern void suite_semantic(void);
273282
extern void suite_ast_profile(void);
274283
extern void suite_slab_alloc(void);
275284
extern void suite_simhash(void);
276-
extern void suite_stack_overflow(void);
285+
extern void suite_stack_overflow_a(void);
286+
extern void suite_stack_overflow_b(void);
287+
extern void suite_stack_overflow_c(void);
277288
extern void suite_dump_verify(void);
278289
extern void suite_dump_verify_io(void);
279290

@@ -308,16 +319,23 @@ int main(int argc, char **argv) {
308319
return 2;
309320
}
310321

311-
g_suite_argc = argc;
312-
g_suite_argv = argv;
313-
if (argc > 1) {
322+
if (argc == 2 && strcmp(argv[1], "--list-suites") == 0) {
323+
g_list_only = true;
324+
g_suite_argc = 1; /* no suite-name args to match */
325+
} else {
326+
g_suite_argc = argc;
327+
g_suite_argv = argv;
328+
}
329+
if (g_suite_argc > 1) {
314330
g_suite_arg_matched = calloc((size_t)argc, sizeof(*g_suite_arg_matched));
315331
if (!g_suite_arg_matched) {
316332
fprintf(stderr, "Failed to allocate test-suite argument tracking\n");
317333
return 1;
318334
}
319335
}
320-
printf("\n codebase-memory-mcp C test suite\n");
336+
if (!g_list_only) {
337+
printf("\n codebase-memory-mcp C test suite\n");
338+
}
321339

322340
/* Foundation */
323341
RUN_SELECTED_SUITE(arena);
@@ -454,8 +472,11 @@ int main(int argc, char **argv) {
454472
RUN_SELECTED_SUITE(ast_profile);
455473
RUN_SELECTED_SUITE(simhash);
456474

457-
/* Stack overflow regression (GitHub #199) */
458-
RUN_SELECTED_SUITE(stack_overflow);
475+
/* Stack overflow regression (GitHub #199) — split a/b/c so no single
476+
* suite serializes a parallel run (each ~1/3 of the old wall time). */
477+
RUN_SELECTED_SUITE(stack_overflow_a);
478+
RUN_SELECTED_SUITE(stack_overflow_b);
479+
RUN_SELECTED_SUITE(stack_overflow_c);
459480

460481
/* Integration (end-to-end) */
461482
RUN_SELECTED_SUITE(integration);
@@ -480,6 +501,12 @@ int main(int argc, char **argv) {
480501

481502
RUN_SELECTED_SUITE(incremental);
482503

504+
if (g_list_only) {
505+
fflush(stdout);
506+
cbm_kind_in_set_free_cache();
507+
sqlite3_shutdown();
508+
return 0;
509+
}
483510
bool any_suite_matched = false;
484511
for (int i = 1; i < g_suite_argc; i++) {
485512
any_suite_matched = any_suite_matched || g_suite_arg_matched[i];

0 commit comments

Comments
 (0)