Skip to content

Commit ece838c

Browse files
committed
test: widen ThreadSanitizer coverage and add native ARM64 Windows UBSan
Close the cross-platform sanitizer gaps that were leaving real concurrency and undefined-behavior bugs uncaught, and fix a data race the first widened run surfaced. - tsan: the data-race gate ran three suites (mem, slab_alloc, parallel) over no real threaded production code. It now covers every threaded surface that runs clean and stable under TSan: the parallel-extraction worker pool (parallel, worker_pool, pipeline), the filesystem watcher, the embedded HTTP server (httpd), diagnostics sampling, the MCP server and mutation guard, subprocess supervision, and the runnable daemon-coordination paths (daemon, daemon_application). daemon_runtime (deadlocks under TSan+fork), daemon_ipc and daemon_frontend (test-harness synchronization, not production) are excluded with the reasons recorded in the Makefile. - tsan: fixed a genuine data race the widened gate immediately found — cbm_lsp_max_walk_depth's lazy cache was read and written by parallel LSP-extraction workers without synchronization. A data race is undefined behavior even when every worker computes the same value, so the cache slot is now a relaxed atomic: a plain load on the hot path, and a first-touch double-compute simply stores the same value. - tsan(ci + local): the test-tsan job now runs on Linux amd64, Linux arm64, AND native ARM64 macOS (the threading code is shared, so a race is usually caught on all three, but scheduler differences let each surface one the others miss). The local ladder gained `run.sh tsan` and `tsan-amd64` plus the matching compose services. TSan's shadow memory aborts under modern high-entropy ASLR, so the containers run under `setarch -R` with an unconfined seccomp profile (the personality syscall is otherwise blocked) and the CI Linux legs lower vm.mmap_rnd_bits first; amd64 TSan cannot run under x86_64-on-ARM translation and is a real-hardware/CI gate only (documented in run.sh). - ubsan(win/arm64): native ARM64 Windows had no sanitizer at all — AddressSanitizer ships no aarch64-w64-windows-gnu runtime. UBSan in trap mode (-fsanitize-trap=undefined) needs no runtime library, so it instruments natively and turns undefined behavior into an illegal-instruction trap; -fstack-protector-strong adds stack-smash coverage the heap tools miss. The GitHub windows-11-arm leg switches from unsanitized to this, and vm/win.sh gains trap-ubsan-build / trap-ubsan-test for local iteration (reproduce under the emulated x86_64 UBSan to see which check fired). The whole codebase builds and runs clean under it. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1 parent 34f9ad1 commit ece838c

6 files changed

Lines changed: 165 additions & 23 deletions

File tree

.github/workflows/_test.yml

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -115,31 +115,60 @@ jobs:
115115
env:
116116
CCACHE_DIR: ${{ github.workspace }}/.ccache
117117

118+
# ThreadSanitizer data-race gate. The threaded production surfaces (watcher,
119+
# subprocess, httpd, pipeline, mcp, worker pool, diagnostics, the runnable
120+
# daemon-coordination paths) plus allocator concurrency all run under TSan
121+
# here — see TEST_TSAN_SUITES in Makefile.cbm. Runs on both Linux
122+
# architectures AND native ARM64 macOS: the threading code is shared, so a
123+
# race is usually caught on all three, but scheduler differences mean each
124+
# platform can surface a race the others miss. Windows has no TSan runtime on
125+
# any toolchain (documented irreducible gap); this shared-code coverage is
126+
# its substitute.
118127
test-tsan:
119-
runs-on: ubuntu-latest
128+
strategy:
129+
fail-fast: false
130+
matrix:
131+
include:
132+
- { os: ubuntu-latest, cc: clang, cxx: clang++ }
133+
- { os: ubuntu-24.04-arm, cc: clang, cxx: clang++ }
134+
- { os: macos-14, cc: cc, cxx: c++ }
135+
runs-on: ${{ matrix.os }}
120136
timeout-minutes: 120
121137
steps:
122138
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
123139

124140
- name: Install deps (Ubuntu)
141+
if: runner.os == 'Linux'
125142
run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev ccache
126143

144+
- name: Install deps (macOS)
145+
if: runner.os == 'macOS'
146+
run: brew install ccache
147+
127148
- name: Compiler cache (content-verified)
128149
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
129150
with:
130151
path: ${{ github.workspace }}/.ccache
131-
key: ccache-tsan-${{ github.ref }}-${{ github.sha }}
152+
key: ccache-tsan-${{ matrix.os }}-${{ github.ref }}-${{ github.sha }}
132153
restore-keys: |
133-
ccache-tsan-${{ github.ref }}-
154+
ccache-tsan-${{ matrix.os }}-${{ github.ref }}-
134155
135156
- name: ThreadSanitizer tests
136-
# This job calls make directly (no env.sh), so route the compilers
137-
# through ccache's Debian masquerade dir; update-ccache-symlinks in the
138-
# ccache postinst has already linked the clang installed above.
157+
# Calls make directly (no env.sh), so route the compilers through
158+
# ccache's masquerade dir: /usr/lib/ccache on Debian, libexec under the
159+
# Homebrew prefix on macOS. On Linux, reduce mmap ASLR entropy first:
160+
# modern (esp. aarch64) kernels randomize high enough that TSan's
161+
# shadow mapping aborts with "unexpected memory mapping" before any
162+
# test runs. macOS TSan needs no such adjustment.
139163
run: |
140-
export PATH=/usr/lib/ccache:$PATH
164+
if [ "$RUNNER_OS" = "macOS" ]; then
165+
export PATH="$(brew --prefix ccache)/libexec:$PATH"
166+
else
167+
export PATH=/usr/lib/ccache:$PATH
168+
sudo sysctl -w vm.mmap_rnd_bits=28 || true
169+
fi
141170
export CCACHE_COMPILERCHECK=content
142-
make -f Makefile.cbm test-tsan CC=clang CXX=clang++
171+
make -f Makefile.cbm test-tsan CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
143172
env:
144173
CCACHE_DIR: ${{ github.workspace }}/.ccache
145174
CCACHE_MAXSIZE: 1500M
@@ -209,18 +238,23 @@ jobs:
209238
shell: msys2 {0}
210239
# AddressSanitizer is unavailable on native ARM64 Windows (LLVM ships no
211240
# libclang_rt.asan for aarch64-w64-windows-gnu) and cannot intercept the
212-
# system DLLs under x86-64 emulation either, so windows-11-arm runs the
213-
# native ARM64 build with SANITIZE= (no sanitizer) — still a real
214-
# functional gate. ASan/UBSan coverage comes from the other 9 legs,
215-
# including native-ARM Linux/macOS. x86-64 Windows keeps full sanitizers.
241+
# system DLLs under x86-64 emulation either. Instead of running the
242+
# ARM64 leg unsanitized, it runs UBSan in TRAP mode: -fsanitize-trap
243+
# needs no runtime library (the very thing aarch64-w64-windows-gnu
244+
# lacks), so it instruments natively and turns any undefined behavior
245+
# into an illegal-instruction trap; -fstack-protector-strong adds
246+
# stack-smash coverage the heap tools miss. To see WHICH check fired,
247+
# reproduce under the emulated x86_64 UBSan (win.sh ubsan-build), which
248+
# carries the full message. x86-64 Windows keeps full ASan+UBSan; the
249+
# other native-ARM legs (Linux/macOS) carry ASan+LSan+TSan.
216250
run: |
217251
# Native tests resolve temp via TEMP/TMP (cbm_tmpdir); shell tools use
218252
# TMPDIR. Route both through the protected root created above so no
219253
# fixture lands under a shared or ACL-inherited ancestry.
220254
export TEMP="$(cygpath -m "$CBM_CI_TEMP_ROOT")"
221255
export TMP="$TEMP"
222256
export TMPDIR="$(cygpath -u "$CBM_CI_TEMP_ROOT")"
223-
scripts/test.sh CC=clang CXX=clang++ ${{ matrix.os == 'windows-11-arm' && 'SANITIZE=' || '' }}
257+
scripts/test.sh CC=clang CXX=clang++ ${{ matrix.os == 'windows-11-arm' && 'SANITIZE=-fsanitize=undefined -fsanitize-trap=undefined -fstack-protector-strong -fno-omit-frame-pointer' || '' }}
224258
env:
225259
CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }}
226260
CCACHE_DIR: ${{ github.workspace }}/.ccache

Makefile.cbm

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,29 @@ test-repro: $(BUILD_DIR)/test-repro-runner
716716

717717
# ── TSan full test ───────────────────────────────────────────────
718718

719-
TEST_TSAN_SUITES ?= mem slab_alloc parallel
719+
# Every threaded production surface that runs clean AND stable under TSan:
720+
# allocator concurrency (mem, slab_alloc), the parallel extraction worker pool
721+
# (parallel, worker_pool, pipeline), the filesystem watcher (watcher), the
722+
# embedded HTTP server (httpd), diagnostics sampling (diagnostics), the MCP
723+
# server + mutation guard (mcp, mcp_mutation_guard), subprocess supervision
724+
# (subprocess), and the runnable daemon-coordination paths (daemon,
725+
# daemon_application). Was `mem slab_alloc parallel` — a keyhole that ran TSan
726+
# over no real threaded production code.
727+
#
728+
# Deliberately EXCLUDED (documented, not forgotten):
729+
# daemon_runtime — deadlocks under TSan: it forks after going
730+
# multi-threaded and the child re-enters the runtime, a
731+
# combination TSan's runtime does not support (hangs).
732+
# daemon_ipc — a TEST-harness data race (a helper thread writes a
733+
# main-thread stack slot without synchronization,
734+
# test_daemon_ipc.c:2088); production IPC is not implicated.
735+
# daemon_frontend — TEST-harness thread leaks (fixture threads left unjoined,
736+
# test_daemon_frontend.c:698) plus a fork-under-TSan fixture
737+
# failure. Test-quality work, tracked separately.
738+
# Re-enabling these three needs test-harness synchronization + a TSan-fork
739+
# strategy; it is out of scope for the current sanitizer-coverage pass.
740+
TEST_TSAN_SUITES ?= mem slab_alloc parallel worker_pool watcher httpd pipeline \
741+
diagnostics mcp mcp_mutation_guard subprocess daemon daemon_application
720742
TSAN_OPTIONS ?= halt_on_error=1
721743

722744
$(BUILD_DIR)/test-runner-tsan: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TSAN) | $(BUILD_DIR)

internal/cbm/lsp/scope.h

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33

44
#include "type_rep.h"
55
#include "../arena.h"
6-
#include <stdlib.h> /* getenv, atoi (cbm_lsp_max_walk_depth) */
6+
#include <stdatomic.h> /* relaxed cache for cbm_lsp_max_walk_depth */
7+
#include <stdlib.h> /* getenv, atoi (cbm_lsp_max_walk_depth) */
78

89
typedef struct {
910
const char* name;
@@ -40,17 +41,22 @@ typedef struct CBMScope {
4041

4142
// Resolved walk-depth cap: env override (CBM_LSP_MAX_WALK_DEPTH, if a positive
4243
// integer) else CBM_LSP_MAX_WALK_DEPTH. Read once and cached — the walkers call
43-
// this per node, so it must not hit getenv on the hot path. The cache is a
44-
// benign idempotent race under multi-threaded indexing (every thread computes
45-
// the same value).
44+
// this per node, so it must not hit getenv on the hot path. The cache is
45+
// idempotent under multi-threaded indexing (every worker computes the same
46+
// value), but a plain data race is undefined behavior even when the values
47+
// agree, so the slot is a relaxed atomic: on the hot path this is a plain load
48+
// with no fence, and a first-touch double-compute simply stores the same
49+
// value. This keeps the parallel extractor TSan-clean.
4650
static inline int cbm_lsp_max_walk_depth(void) {
47-
static int cached = -1;
48-
if (cached < 0) {
51+
static _Atomic int cached = -1;
52+
int value = atomic_load_explicit(&cached, memory_order_relaxed);
53+
if (value < 0) {
4954
const char* e = getenv("CBM_LSP_MAX_WALK_DEPTH");
5055
int v = (e && *e) ? atoi(e) : 0;
51-
cached = (v > 0) ? v : CBM_LSP_MAX_WALK_DEPTH;
56+
value = (v > 0) ? v : CBM_LSP_MAX_WALK_DEPTH;
57+
atomic_store_explicit(&cached, value, memory_order_relaxed);
5258
}
53-
return cached;
59+
return value;
5460
}
5561

5662
CBMScope* cbm_scope_push(CBMArena* a, CBMScope* current);

test-infrastructure/docker-compose.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,46 @@ services:
3939
CCACHE_MAXSIZE: 1500M
4040
command: ["CC=gcc", "CXX=g++", "BUILD_DIR=build/linux-amd64"]
4141

42+
# ── ThreadSanitizer (data-race gate) ───────────────────────────
43+
# Overrides the default test.sh entrypoint to build + run the widened TSan
44+
# suite set. /usr/lib/ccache is first on PATH in the image, so CC=gcc is
45+
# ccache-masqueraded automatically (same as the ASan legs).
46+
test-tsan:
47+
build:
48+
context: ..
49+
dockerfile: test-infrastructure/Dockerfile
50+
platform: linux/arm64
51+
volumes:
52+
- ..:/src
53+
- cbm-ccache-arm64:/root/.ccache
54+
environment:
55+
CCACHE_DIR: /root/.ccache
56+
CCACHE_MAXSIZE: 1500M
57+
# TSan's shadow memory needs a predictable address space; modern aarch64
58+
# kernels' high-entropy mmap ASLR makes its runtime abort with "unexpected
59+
# memory mapping" before any test runs. setarch -R (ADDR_NO_RANDOMIZE)
60+
# fixes it, but the personality() syscall it uses is blocked by the default
61+
# seccomp profile — unconfined is safe here (the container runs only our own
62+
# test code). The no-randomize personality inherits to the make child.
63+
security_opt: ["seccomp=unconfined"]
64+
entrypoint: ["setarch", "-R", "make", "-f", "Makefile.cbm", "test-tsan"]
65+
command: ["CC=gcc", "CXX=g++", "BUILD_DIR=build/linux-arm64-tsan"]
66+
67+
test-tsan-amd64:
68+
build:
69+
context: ..
70+
dockerfile: test-infrastructure/Dockerfile
71+
platform: linux/amd64
72+
volumes:
73+
- ..:/src
74+
- cbm-ccache-amd64:/root/.ccache
75+
environment:
76+
CCACHE_DIR: /root/.ccache
77+
CCACHE_MAXSIZE: 1500M
78+
security_opt: ["seccomp=unconfined"]
79+
entrypoint: ["setarch", "-R", "make", "-f", "Makefile.cbm", "test-tsan"]
80+
command: ["CC=gcc", "CXX=g++", "BUILD_DIR=build/linux-amd64-tsan"]
81+
4282
# ── Linux production build (-O2 -Werror) ───────────────────
4383
build:
4484
build:

test-infrastructure/run.sh

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
# ./test-infrastructure/run.sh windows # Windows cross-compile only
3030
# ./test-infrastructure/run.sh test # Linux arm64 test only (no perf)
3131
# ./test-infrastructure/run.sh perf # Linux arm64 perf/incremental only
32+
# ./test-infrastructure/run.sh tsan # Linux arm64 ThreadSanitizer race gate
33+
# ./test-infrastructure/run.sh tsan-amd64 # Linux amd64 ThreadSanitizer race gate
3234
# ./test-infrastructure/run.sh build # Linux arm64 build only
3335
# ./test-infrastructure/run.sh lint # clang-format + cppcheck
3436
# ./test-infrastructure/run.sh shell # debug shell
@@ -76,6 +78,8 @@ case "${1:-full}" in
7678
echo "=== Linux arm64: test + build ==="
7779
$COMPOSE run --rm -e CBM_SKIP_PERF=1 test
7880
$COMPOSE run --rm build
81+
echo "=== Linux arm64: ThreadSanitizer (data-race gate) ==="
82+
$COMPOSE run --rm test-tsan
7983
echo "=== Linux arm64: smoke test ==="
8084
$COMPOSE run --rm smoke
8185
echo "=== Linux portable: Alpine static build + smoke ==="
@@ -92,6 +96,19 @@ case "${1:-full}" in
9296
echo "=== Linux arm64: perf/incremental tests ==="
9397
$COMPOSE run --rm test
9498
;;
99+
tsan)
100+
echo "=== Linux arm64: ThreadSanitizer (data-race gate) ==="
101+
$COMPOSE run --rm test-tsan
102+
;;
103+
tsan-amd64)
104+
# NOTE: TSan's shadow memory is incompatible with x86_64-on-ARM
105+
# translation (Rosetta/QEMU), so this FATALs ("unexpected memory
106+
# mapping") on an Apple-Silicon host — it is a real-amd64-hardware /
107+
# GitHub-CI gate, not a local-on-ARM one. ASan amd64 (test-amd64) runs
108+
# fine under Rosetta; only TSan's mapping does not.
109+
echo "=== Linux amd64: ThreadSanitizer (data-race gate; native amd64 only) ==="
110+
$COMPOSE run --rm test-tsan-amd64
111+
;;
95112
build)
96113
echo "=== Linux arm64: production build (-O2 -Werror) ==="
97114
$COMPOSE run --rm build
@@ -130,6 +147,10 @@ case "${1:-full}" in
130147
$COMPOSE run --rm -e CBM_SKIP_PERF=1 test
131148
$COMPOSE run --rm build
132149
$COMPOSE run --rm smoke
150+
echo "=== Linux arm64: ThreadSanitizer (data-race gate) ==="
151+
$COMPOSE run --rm test-tsan
152+
# amd64 TSan is CI/native-amd64 only (Rosetta can't map TSan shadow);
153+
# run it with `run.sh tsan-amd64` on real amd64. GitHub CI gates it.
133154
echo "=== Linux portable: Alpine static build + smoke ==="
134155
$COMPOSE run --rm smoke-portable
135156
echo "=== Linux amd64: test + build + smoke ==="
@@ -153,7 +174,7 @@ case "${1:-full}" in
153174
$COMPOSE run --rm --entrypoint bash test-portable
154175
;;
155176
*)
156-
echo "Usage: $0 {full|test|perf|build|smoke|portable|portable-test|windows|smoke-windows|soak-windows|amd64|all|lint|shell|shell-alpine}"
177+
echo "Usage: $0 {full|test|perf|tsan|tsan-amd64|build|smoke|portable|portable-test|windows|smoke-windows|soak-windows|amd64|all|lint|shell|shell-alpine}"
157178
exit 1
158179
;;
159180
esac

test-infrastructure/vm/win.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# win.sh push-file <local> <vm> # scp one file into the VM (WIP iteration)
1818
# win.sh test-par # full suite, parallel on all VM cores
1919
# win.sh ubsan-build|ubsan-test # UBSan at CI's x86_64 arch (emulated; works)
20+
# win.sh trap-ubsan-build|-test # NATIVE ARM64 UBSan (trap mode, no runtime)
2021
# win.sh pageheap on|off # OS heap verification for native runs
2122
set -euo pipefail
2223

@@ -153,6 +154,24 @@ ubsan-test)
153154
[ $# -ge 1 ] || { echo "usage: win.sh ubsan-test <suite...>" >&2; exit 2; }
154155
vm clang64 "cd /c/cbm && ./build/c/test-runner $* 2>&1 | tail -40"
155156
;;
157+
trap-ubsan-build)
158+
# NATIVE ARM64 UBSan via trap mode. -fsanitize-trap=undefined needs NO
159+
# runtime library (which is exactly what aarch64-w64-windows-gnu lacks), so
160+
# unlike ASan this instruments and runs on native ARM64: a UB hit becomes a
161+
# bare illegal-instruction trap (SIGILL) instead of a diagnostic. Paired
162+
# with -fstack-protector-strong for stack-smash coverage the heap tools
163+
# (PageHeap) miss. This is the native-arch UBSan gate; to see WHICH check
164+
# fired, reproduce under the emulated `win.sh ubsan-build`/`ubsan-test`,
165+
# which carries the full runtime + message. BUILD_DIR isolated so it never
166+
# clobbers the plain test-runner.
167+
vm clangarm64 "cd /c/cbm && make -j${JOBS} -f Makefile.cbm CC='ccache clang' CXX='ccache clang++' SANITIZE='-fsanitize=undefined -fsanitize-trap=undefined -fstack-protector-strong -fno-omit-frame-pointer' BUILD_DIR=build/trap-ubsan build/trap-ubsan/test-runner > /tmp/win-trap-ubsan-build.log 2>&1 && echo TRAP_UBSAN_BUILD_OK || (echo TRAP_UBSAN_BUILD_FAIL; tail -20 /tmp/win-trap-ubsan-build.log; exit 1)"
168+
;;
169+
trap-ubsan-test)
170+
[ $# -ge 1 ] || { echo "usage: win.sh trap-ubsan-test <suite...>" >&2; exit 2; }
171+
# A UB trap crashes the runner with SIGILL (exit 132); the harness reports
172+
# the failing suite so the emulated diagnosis loop can name the check.
173+
vm clangarm64 "cd /c/cbm && ./build/trap-ubsan/test-runner $* 2>&1 | tail -40"
174+
;;
156175
pageheap)
157176
# OS-level heap verification (page-granular overflow/UAF detection) for the
158177
# native ARM64 test-runner — toolchain-agnostic partial ASan substitute.

0 commit comments

Comments
 (0)