Skip to content

Nothing verifies that a sanitized lane actually compiled with CBM_SANITIZED_BUILD (UBSan and trap-UBSan have no probe) #1648

Description

@moffermann

Summary

Nothing verifies that a sanitized lane actually compiled with CBM_SANITIZED_BUILD defined. #1615 gave
CBM_SANITIZED one spelling and a compiler-probe backstop, but the backstop only covers the three
sanitizers that announce themselves. For UBSan and trap-UBSan there is no probe and never can be, so the
build system is the sole source of truth — and a silent regression there is invisible: the suite still
passes, just against native timing budgets on an instrumented binary.

That is not hypothetical. It is exactly what happened twice:

Opening this per CONTRIBUTING.md ("Open an issue first — always"; build-system changes need prior
design discussion). Happy to implement once there is a decision on shape — nothing is written yet.

Why the existing mechanisms do not cover it

mechanism covers does not cover
SANITIZED_DEFINE (Makefile.cbm:93-96) any lane keyed off $(SANITIZE) a lane with its own sanitize variable — precisely the TSan gap
probes in sanitized.h (#1615) ASan, TSan, MSan UBSan, trap-UBSan — no macro, no __has_feature bit exists
the test suite passing nothing here a native budget on an instrumented binary is looser to violate, not tighter; it mostly still passes

The third row is the important one. The failure mode is a test that passes for months and then flakes on
a loaded runner, attributed to whatever PR happened to be in flight.

It is not only about budgets any more

scripts/msan.sh:120-129 reasons from this macro while ruling out a cause:

CBM_THREAD_STACK_MB at 256 MiB and at 1024 MiB (the cap). The knob is verified compiled in
(CBM_SANITIZED_BUILD is defined for this lane)
and the floor is applied in cbm_thread_create [...]
The fault address did not move by a single byte between 256 MiB and 1024 MiB — a 4x stack increase
changing nothing is what rules out "the stack is merely too small".

If the define were ever absent on that lane, cbm_thread_stack_floor would compile to return requested
(src/foundation/compat_thread.c:33-48), the 4x increase would have changed nothing because the knob was
inert
, and a documented "do not repeat" conclusion would be wrong. The premise is asserted in a comment;
nothing checks it.

Proposed shape

Three small pieces, and no changes under .github/workflows/ at all — which is the point of this shape.

1. The binary reports what it was compiled with. A --build-config early-exit in
tests/test_main.c, next to the existing --version (line ~796) and --list-suites (line ~861),
printing one line of key=value:

$ build/c/test-runner --build-config
sanitized=1 test_seams=1

sanitized comes straight from CBM_SANITIZED, so it answers the only question that matters: what did
the compiler actually receive
, not what the Makefile intended to send.

2. scripts/test.sh asserts it. This is the single choke point — every sanitized lane in
_test.yml already goes through it:

leg _test.yml invocation expected
test-unix :93 scripts/test.sh CC=… CXX=… (default ASan+UBSan) sanitized=1
test-diag :221 scripts/test.sh … SANITIZE="-fsanitize=address,undefined …" sanitized=1
test-lsan-macos :308 scripts/test.sh … (ASan+LSan) sanitized=1
test-tsan :340 scripts/test.sh --tsan … sanitized=1
Windows CLANGARM64 test.sh:173-177 trap-UBSan injected by the script itself sanitized=1
explicit plain build scripts/test.sh SANITIZE= sanitized=0

test.sh already knows the answer: it tracks SANITIZE_GIVEN (:147), the --tsan mode (:98, :179-185)
and the ARM64 trap-UBSan default (:173-177). So it can compute the expectation from the flags it is about
to pass to make, run --build-config on the binary it just built, and fail loudly on mismatch, before the
suite runs:

ERROR: build config mismatch — this lane builds instrumented but the binary reports sanitized=0.
       Every sanitized-budget branch compiled to its NATIVE value.
       Check that this lane's flag set carries $(SANITIZED_DEFINE) or -DCBM_SANITIZED_BUILD=1.

Note the TSan mode must check $BUILD_DIR/test-runner-tsan, not test-runner.

3. scripts/msan.sh gets the same one-liner. It is the only lane that bypasses test.sh
(_test.yml:284 → Docker → msan.sh, running build/msan/test-runner). One check next to the build at
msan.sh:66-68 — and it would turn the comment quoted above into something enforced.

What this would have caught, and how fast

The TSan gap would have failed test-tsan in seconds, at the build step, with a message naming the cause
— instead of surfacing as an unrelated-looking flaky spawn test across three PRs.

Limits, stated up front

  • It proves the define reached the compiler, not that the sanitizer is instrumenting. That was never
    the failure mode, and -fsanitize= not working would fail far louder.
  • It cannot cover a future lane that routes around both scripts. Partial mitigation: the fix(build): give sanitized-build detection one spelling and a backstop #1615 probes
    still self-heal ASan/TSan/MSan, so a new lane would have to be UBSan-only to slip through.
  • It adds one flag to a test-only binary. No production surface.
  • test-windows (_test.yml:398) runs unsanitized, so it asserts sanitized=0 — which is also worth
    having: it catches the inverse mistake of leaking the define into a native lane.

Alternatives considered

  1. #error in sanitized.h when a probe fires without the define. Rejected in fix(build): give sanitized-build detection one spelling and a backstop #1615, for reasons
    that still hold: it breaks the binary instead of correcting it, and breaks an out-of-tree
    make CFLAGS_EXTRA=-fsanitize=address. It is also blind to UBSan, the case this issue is really about.
  2. Env var (CBM_EXPECT_SANITIZED=1) plus an in-suite test. A lane that forgets the variable skips
    the check silently — the same silence that caused the problem. Inverting the default (fail when unset)
    breaks every ad-hoc local run.
  3. Grep Makefile.cbm in CI for the define on each flag set. Verifies the intent, not the binary, and
    would have missed nothing only by accident — a flag set can also be assembled in a script.
  4. A make print-flags target compared in CI. Same weakness: it reports what make would pass, not
    what the compiler received, and it needs the CI workflow edits this shape avoids.

Scope

Three files: tests/test_main.c, scripts/test.sh, scripts/msan.sh. Roughly 40-60 lines including the
error text. One issue, one PR, per CONTRIBUTING.md.

Found while doing #1615 and #1618; not blocking either.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggithub_actionsPull requests that update GitHub Actions codemaintainer-notesInternal observations and improvement notespriority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions