Skip to content

Build under the sanitizers and with -Werror in CI - #21

Merged
Aduneer merged 1 commit into
mainfrom
ci-sanitizers-werror
Aug 19, 2026
Merged

Build under the sanitizers and with -Werror in CI#21
Aduneer merged 1 commit into
mainfrom
ci-sanitizers-werror

Conversation

@Aduneer

@Aduneer Aduneer commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Every pull request so far was checked under -fsanitize=address,undefined by hand and came back clean. That is the argument for making it a job rather than a habit — the habit lives in a scratch file outside the repo, and the next contributor doesn't have it.

CI becomes a four-way matrix: both compilers against an optimised and a sanitised build. -O2 and the sanitizers don't see the same program — the optimised build is what ships, and the sanitised one is the only thing that reports a read past the end of a buffer rather than returning whatever was next to it.

Three details that are the whole point

-fno-sanitize-recover=undefined. UBSan's default is to print the diagnostic and carry on, exiting 0. Without this flag the entry is decorative: a real finding is a green build, and nobody reads the log of a job that passed. This is not theoretical — here it is both ways on a two-line program:

$ ./ub1                      # -fsanitize=undefined
ub.cpp:5:13: runtime error: shift exponent 32 is too large for 32-bit type 'int'
still running, y=1
exit=0                       # <-- CI goes green

$ ./ub2                      # + -fno-sanitize-recover=undefined
ub.cpp:5:13: runtime error: shift exponent 32 is too large for 32-bit type 'int'
exit=1

-Werror is here and not in the Makefile. A warning should stop a change being merged, not stop a contributor building the project at all. make on a clean checkout stays warm.

CXX/CXXFLAGS are set for the job, not passed to each make. The Makefile takes both with ?=, which honours the environment. The test and golden targets link against the objects built by the Build step, so they need identical flags — passing them one step at a time is how you get a link error against a sanitizer runtime that half the objects don't have.

Plus UBSAN_OPTIONS=print_stacktrace=1, so a failure names the source line rather than an address.

Verifying the entry can actually fail

Green proves nothing on its own, so I injected an out-of-bounds read into count_label() — a function the unit tests certainly reach — and ran two entries against it:

=== optimised entry ===
  PASS - the -O2 entry sails past it

=== sanitised entry ===
src/text.cpp:70:39: runtime error: index 4 out of bounds for type 'char [4]'
    #0 ... in FlashTerm::count_label(...) src/text.cpp:70
    #1 ... in test_count_label tests/tests.cpp:75
    #2 ... in main tests/tests.cpp:1945
make: *** [Makefile:31: test] Error 1

It stopped rather than printing and passing, which is -fno-sanitize-recover doing its job, and the stack trace names the test that reached it. The injected bug was reverted; the diff here is ci.yml plus docs.

Testing

All four entries pass locally: 618 unit checks and 42 golden cases each.

optimised sanitised
g++ pass pass
clang++ pass pass

One thing worth flagging: locally that's gcc 16 and clang 22, while ubuntu-latest is gcc 13 and clang 18. Older compilers warn about different things — notably the -Wmaybe-uninitialized and -Wdangling-reference false positives that later versions fixed. If this run surfaces something, that's the entry doing its job on its first outing rather than a reason to hold it back, and I'll fix what it finds.

Every pull request so far was checked under -fsanitize=address,undefined by
hand and came back clean. That is the argument for making it a job rather than
a habit: the habit lives in a scratch file outside the repo, and the next
contributor does not have it.

CI becomes a four-way matrix -- both compilers against an optimised and a
sanitised build -- because -O2 and the sanitizers do not see the same program.
The optimised build is what ships; the sanitised one is the only thing that
reports a read past the end of a buffer rather than returning whatever was
next to it.

Three details that are the whole point:

* -fno-sanitize-recover=undefined. UBSan's default is to print the diagnostic
  and carry on, exiting 0, so without this the entry is decorative: a real
  finding would be a green build, and nobody reads the log of a job that
  passed. Verified by injecting an out-of-bounds read into count_label(): the
  optimised entry sails past it, and the sanitised entry stops with exit 2 and
  names src/text.cpp and the test that reached it.
* -Werror is here and not in the Makefile. A warning should stop a change being
  merged, not stop a contributor building the project at all, so `make` on a
  clean checkout stays warm.
* CXX and CXXFLAGS are set for the job rather than passed to each make. The
  Makefile takes both with ?=, which honours the environment, and the test and
  golden targets have to link against objects built with the same flags --
  passing them one step at a time is how you get a link error against a
  sanitizer runtime that half the objects do not have.

UBSAN_OPTIONS=print_stacktrace=1 so that a failure names the source line rather
than an address.

All four entries pass locally on gcc 16 and clang 22. CI runs gcc 13 and clang
18, which warn about different things, so this may well surface something the
newer compilers stopped complaining about -- which is the entry doing its job
on its first run rather than a reason to hold it back.
@Aduneer
Aduneer merged commit 1c0ad3b into main Aug 19, 2026
4 checks passed
@Aduneer
Aduneer deleted the ci-sanitizers-werror branch August 19, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant