Files
codebase-memory-mcp/scripts/msan.sh
T
Martin Vogel dbc6ac486b fix(msan): reports print with fast unwinding; five suites run again
Five suites (grammar_regression grammar_labels pipeline lang_contract
grammar_probe_e) were excluded from the MSan lane for "stack-overflow
under instrumentation". That was never deep recursion. MSan aborted while
REPORTING real uninitialized reads:

- every abort was on the main thread. MSan's "T<n>" is the OS thread id
  and equalled the pid in all five x86-64 aborts (CI job 91868259558);
  the old block read it as a worker-thread ordinal, so the worker-stack
  knob, MSAN_ORIGINS and CBM_WORKERS=1 could never change anything;
- with the default slow unwinder (the image's MSan-instrumented
  libunwind) a report exhausted the main thread's 256 MiB stack, and the
  overflow handler then failed with "nested bug in the same thread";
- with fast_unwind_on_fatal=1 the same test prints the
  use-of-uninitialized-value report instead (measured on one tree and
  test: slow unwind aborts at the stack limit, fast unwind reports
  wit/scanner.c:13).

The two reads are fixed in the previous commits of this series, so:

- MSAN_OPTIONS defaults to fast_unwind_on_fatal=1 (the build already
  keeps frame pointers);
- MSAN_EXCLUDE is down to cli, whose install-path failure is a separate,
  still-undiagnosed cause;
- the known-red block, the header (it still claimed CI runs without
  exclusions) and the MSAN_ORIGINS and stack comments say what is true;
- the partial-lane warning and the exclude-name typo check lived in a
  branch behind `elif true` that never ran. They now run in the
  per-suite path, the check as a case match instead of a grep -q pipe
  that pipefail can misreport;
- the comments in scripts/ci/msan-lane.sh and above the test-msan job
  in _test.yml no longer say the five suites overflow.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-11 18:26:26 +02:00

197 lines
9.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# msan.sh — MemorySanitizer lane (stage 2: full coverage incl. the C++ paths).
#
# Runs inside the cbm-msan image (test-infrastructure/Dockerfile.msan), which
# provides MSan-instrumented libc++/libc++abi/libunwind and zlib in /opt/msan.
# Vendored C deps compile in-tree and are instrumented by the build itself.
#
# MSan detects uninitialized READS — the one memory-error class ASan/LSan and
# the clang-analyzer lane do not cover dynamically. halt_on_error stays ON:
# a finding is a bug (or an interceptor gap to triage), never board data.
#
# VENUES: the CI test-msan job (x86-64, authoritative) and the local arm64
# container (test-infrastructure/docker-compose.yml, service test-msan) both
# run this script with the same exclusion list below.
#
# Usage: scripts/msan.sh [suite ...] (default: full suite)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
MSAN_PREFIX="${MSAN_PREFIX:-/opt/msan}"
if [ ! -d "$MSAN_PREFIX/lib" ]; then
echo "FATAL: MSan-instrumented runtime not found at $MSAN_PREFIX (build test-infrastructure/Dockerfile.msan)" >&2
exit 1
fi
# -isystem: the image deliberately has no system zlib (so the instrumented
# one cannot be shadowed); its headers live under the MSan prefix.
# MSAN_ORIGINS: 2 = full origin chains (best reports, heaviest frames),
# 1 = immediate origin only, 0 = none. Detection is IDENTICAL at every level;
# only report quality differs.
# Default 1 on this lane: the immediate origin is enough to locate a read, and
# the lighter frames keep the lane cheaper. Export MSAN_ORIGINS=2 locally when
# chasing a specific report's origin chain.
MSAN_ORIGINS="${MSAN_ORIGINS:-1}"
if [ "$MSAN_ORIGINS" = "0" ]; then
MSAN_ORIGIN_FLAG=""
else
MSAN_ORIGIN_FLAG="-fsanitize-memory-track-origins=$MSAN_ORIGINS"
fi
MSAN_SAN="-fsanitize=memory $MSAN_ORIGIN_FLAG -fno-omit-frame-pointer -isystem $MSAN_PREFIX/include"
# Scoped to the zstd object ONLY (see the ZSTD_EXTRA_CFLAGS note in
# Makefile.cbm): zstd's MSan block needs stdint.h that its amalgamation lost,
# but force-including it globally freezes glibc feature-test macros before
# sqlite3.c can set _GNU_SOURCE, breaking that compile instead.
#
# -D_GNU_SOURCE rides along for the same freeze reason IN this object:
# a command-line define lands before the forced include, while zstd.c's own
# in-file feature setup lands after it -- without this, glibc freezes
# without _GNU_SOURCE and zstd loses qsort_r.
ZSTD_EXTRA="-D_GNU_SOURCE -include stdint.h"
# Always clean: make does not encode flags into dependencies, so a build dir
# populated under different stdlib/sanitizer flags silently mixes objects
# (observed: a stage-1 probe's libstdc++ objects surviving into the libc++
# lane and producing an unattributable report). Correctness over speed here.
make -f Makefile.cbm clean-c BUILD_DIR=build/msan >/dev/null 2>&1 || true
make -j"$(nproc)" -f Makefile.cbm build/msan/test-runner \
CC=clang CXX=clang++ BUILD_DIR=build/msan \
SANITIZE="$MSAN_SAN" \
ZSTD_EXTRA_CFLAGS="$ZSTD_EXTRA" \
CXX_STDLIB_FLAGS="-stdlib=libc++ -nostdinc++ -isystem $MSAN_PREFIX/include/c++/v1" \
CXX_STDLIB="-L$MSAN_PREFIX/lib -Wl,-rpath,$MSAN_PREFIX/lib -lc++ -lc++abi"
# fast_unwind_on_fatal=1: a report's stack is walked with frame pointers (this
# build keeps them, -fno-omit-frame-pointer) instead of the default slow
# unwinder, which in this image is the MSan-instrumented libunwind. With the
# slow unwinder, reporting an uninitialized read exhausted the main thread's
# stack, so the report never printed and MSan aborted with "stack-overflow"
# followed by "nested bug in the same thread" instead. That disguise kept five
# suites excluded as deep recursion while they hid a real read (the WIT
# scanner's create function returned nothing; see the grammar MANIFEST.md).
# Measured on the same tree and test: slow unwind aborts at the 256 MiB stack
# limit, fast unwind prints the use-of-uninitialized-value report.
export MSAN_OPTIONS="${MSAN_OPTIONS:-halt_on_error=1:print_stats=0:fast_unwind_on_fatal=1}"
# Instrumented frames are several times larger than plain ones, so give the
# stacks headroom: the main thread via RLIMIT_STACK, worker threads via the
# sanitized-build-only knob in cbm_thread_create.
ulimit -s 262144 2>/dev/null || true
export CBM_THREAD_STACK_MB="${CBM_THREAD_STACK_MB:-256}"
export LD_LIBRARY_PATH="$MSAN_PREFIX/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
expected_build_config=$'sanitized=1 test_seams=1\n'
build_config_marker="__cbm_build_config_end__"
captured_build_config=""
actual_build_config=""
build_config_probe_ok=0
if captured_build_config="$(./build/msan/test-runner --build-config && printf '%s' "$build_config_marker")"; then
build_config_probe_ok=1
actual_build_config="${captured_build_config%"$build_config_marker"}"
else
actual_build_config="$captured_build_config"
fi
if [ "$build_config_probe_ok" -ne 1 ] ||
[ "$actual_build_config" != "$expected_build_config" ]; then
reported_build_config="${actual_build_config//$'\n'/\\n}"
printf '%s\n' 'ERROR: build config mismatch for build/msan/test-runner' >&2
printf '%s\n' ' expected: sanitized=1 test_seams=1' >&2
printf ' reported: %s\n' "${reported_build_config:-<empty>}" >&2
printf '%s\n' 'Refusing to run suites; check sanitizer flags and CBM_SANITIZED_BUILD wiring.' >&2
exit 1
fi
echo "=== MSan lane: $(clang --version | head -1) ==="
# KNOWN RED — whitelisted per cause (O10). Only (B) is still excluded.
#
# (A) "STACK-OVERFLOW" — FIXED, no longer excluded. Five suites
# (grammar_regression grammar_labels pipeline lang_contract
# grammar_probe_e) aborted on both arm64 and x86-64 with "MemorySanitizer:
# stack-overflow" followed by "nested bug in the same thread, aborting".
# It was never deep recursion. MSan crashed while REPORTING real
# uninitialized reads (see fast_unwind_on_fatal above): the WIT scanner's
# create function returned nothing (the four grammar suites), and
# githistory_coupling_limits_output left its commits' timestamps
# uninitialized (pipeline). Both are fixed and the suites are back in the
# lane. The crashing thread was the MAIN thread: MSan's "T<n>" is the OS
# thread id, and every abort showed T<n> equal to the pid. An earlier
# version of this block read it as a worker-thread ordinal, which is why
# the worker-stack knob, MSAN_ORIGINS and CBM_WORKERS=1 never changed
# anything and why it proposed a byte-measuring recursion guard.
#
# (B) INSTALL/ACTIVATION failures — cli. NOT an overflow. On x86-64 the suite
# runs to completion: 253 passed, 5 failed, every failure in the install or
# activation path. The parent process's install returns 1, with
# "agent_config agent=OpenClaw op=mcp_install" reported just above it; the
# suite is green on every other venue. MSan reported ZERO
# use-of-uninitialized-value here, so excluding it costs no uninit coverage
# -- which is the only thing this lane exists to provide.
# NOT DIAGNOSED yet. Recorded as a follow-up rather than guessed at from a
# log; with (A) understood, the local arm64 container is a usable venue
# for it (scripts/msan.sh cli).
#
# (C) RSS BUDGET — incremental. FIXED rather than excluded, so it is no longer
# in the list below: the budget assertion is now skipped under
# __has_feature(memory_sanitizer) in test_incremental.c, because shadow (and
# origin) mappings inflate RSS by construction -- 3054MB against a 2304MB
# budget -- and cannot be told apart from a real leak. Inflating the budget
# instead would blind the guard on the platforms where it does work. The
# suite stays IN this lane and keeps its uninitialized-read coverage.
#
# WHY (B) IS EXCLUDED RATHER THAN LEFT RED: the lane is gating. A permanently
# red gate teaches everyone to ignore it and hides the findings the other
# suites produce, which is the entire reason this lane exists. The exclusion is
# narrow, named, and expires when the install failure is diagnosed. It is not a
# claim that cli is covered, and the lane says so when it runs (below).
#
# LESSON FROM (A): an MSan abort that is not a use-of-uninitialized-value
# report is a reporting failure until proven otherwise. Look for the read
# first (fast unwinding prints it), not for a stack limit.
MSAN_EXCLUDE="${MSAN_EXCLUDE-cli}"
if [ "$#" -gt 0 ]; then
./build/msan/test-runner "$@"
else
# One suite per process: a fresh process per suite keeps one suite's
# thread/allocator state from reaching the next, and makes a failure name
# exactly one suite. Suite enumeration comes from --list-suites, whose
# completeness the sharding union guard already proves; the known-red set
# documented above is skipped by name and nothing else is.
all_suites="$(./build/msan/test-runner --list-suites)"
# Fail loudly if an entry matches no suite: a typo (or a TEST name given
# where a SUITE name is required) would otherwise exclude nothing silently.
# A case match, not `printf | grep -q`, which pipefail can turn into a
# false failure when grep exits early.
for e in $MSAN_EXCLUDE; do
case $'\n'"$all_suites"$'\n' in
*$'\n'"$e"$'\n'*) ;;
*)
echo "FATAL: MSAN_EXCLUDE entry '$e' is not a suite name" >&2
exit 1
;;
esac
done
if [ -n "$MSAN_EXCLUDE" ]; then
echo "WARNING: running a PARTIAL sanitizer lane — excluded: $MSAN_EXCLUDE" >&2
echo "WARNING: a green result here does NOT mean the tree is MSan-clean." >&2
fi
fails=""
for suite in $all_suites; do
case " $MSAN_EXCLUDE " in
*" $suite "*)
echo "=== msan: $suite SKIPPED (known-red, see the block above) ==="
continue
;;
esac
echo "=== msan: $suite ==="
./build/msan/test-runner "$suite" || fails="$fails $suite"
done
if [ -n "$fails" ]; then
echo "=== MSan lane FAILED suites:$fails ===" >&2
exit 1
fi
fi