Skip to content

Document running DOLFINx under sanitizers - #4447

Draft
jhale wants to merge 28 commits into
mainfrom
jhale/developer-debug-mode
Draft

Document running DOLFINx under sanitizers#4447
jhale wants to merge 28 commits into
mainfrom
jhale/developer-debug-mode

Conversation

@jhale

@jhale jhale commented Aug 25, 2026

Copy link
Copy Markdown
Member

Adds a runbook (cpp/cmake/sanitizers/README.md) for building and running DOLFINx under ASan/UBSan: -Og plus sanitizer and hardening flags via plain CMAKE_{CXX,C}_FLAGS(_INIT), no dedicated CMake build type needed — DOLFINx's targets pick these up with zero wiring, the same way build-wheels.yml already injects CMAKE_CXX_FLAGS_INIT for its own purposes.

The one piece of CMake logic that's actually necessary: nanobind_add_stub's own sanitizer auto-detection scans a target's COMPILE_OPTIONS/LINK_OPTIONS, not CMAKE_CXX_FLAGS, so building the Python bindings with a sanitizer enabled this way crashes during the build's own stub-generation step with "Interceptors are not working" unless fed explicitly. Fixed with a small, generic, build-type-agnostic block in python/CMakeLists.txt (reacts to any -fsanitize= in CMAKE_CXX_FLAGS, zero footprint otherwise).

An earlier version of this PR added a dedicated DeveloperDebug CMake build type instead. That machinery has been reverted — the doc-only approach covers the same ground with far less surface area (12 files changed here vs. 26 previously, mostly the runbook and suppressions files, no changes to cpp/CMakeLists.txt, the demo generator, or any of the 9 demo CMakeLists.txt).

Every command in the runbook has been run end to end on this machine: cpp/test (single-process and MPI, np=1..3), the Python build (including the stub-generation step), and a real ASan+UBSan pytest run — confirmed via otool/dyld tracing that the sanitized library is actually what gets linked and loaded, not a stray non-sanitized one from elsewhere on the search path.

Also includes the &coeffs(idx, 0[, 0]) UB fix from #4446 (merged in so this could be exercise-tested under real sanitizers); that diff will disappear from here once #4446 merges and this branch rebases.

jhale and others added 12 commits August 24, 2026 20:51
Introduce a DeveloperDebug CMake build type alongside Developer: -Og plus
sanitizers and hardening, prioritising bug-finding over performance
(#4395). Warning flags stay in DOLFINX_CXX_DEVELOPER_DEBUG_FLAGS, scoped
per-target as before; -Og/-g3/sanitizers are set via the standard
CMAKE_{C,CXX}_FLAGS_DEVELOPERDEBUG and linker-flag variables so they reach
every target and link line, including FFCx-generated C kernels, without
touching every consumer CMakeLists.txt.

Also add dolfinx_validate_build_type(), rejecting an unrecognised
CMAKE_BUILD_TYPE instead of silently producing an unflagged build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add the DeveloperDebug compile-options/definitions genex alongside
Developer's in every consumer, and call dolfinx_validate_build_type()
from the C++ and Python top-level builds (not from cpp/test/, cpp/demo/,
or the generated demo template, which downstream projects may build with
their own build types).

Regenerate the nine demo CMakeLists.txt from the updated template.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Seed ASan/LSan/UBSan suppressions for known non-DOLFINx allocation and
leak noise (MPI, PETSc, HDF5), installed alongside
DolfinxDeveloperCompilerFlags.cmake so downstream builds can reference
them via DOLFINX_DIR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Point AGENTS.md, cpp/demo/README.md and python/README.md at the new build
type and the sanitizer runbook (cpp/cmake/sanitizers/README.md), which
covers running ctest and pytest under it, including the LD_PRELOAD/
DYLD_INSERT_LIBRARIES recipe the Python extension module needs and the
macOS SIP caveat for venvs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nt arrays

&coeffs(idx, 0[, 0]) dereferences through mdspan::operator() to form a
reference before taking its address. When a form has no Function
coefficients (only Constants -- e.g. the Poisson bilinear form), coeffs has
a zero-length second dimension and a null data pointer, so this is UB
regardless of the index value. UBSan under the new DeveloperDebug build
type (see the preceding commits) caught this on a completely mainstream
fem::assemble_matrix call, aborting every C++ and Python test that
assembles a form without Function coefficients.

Replace with md::submdspan(coeffs, idx, md::full_extent).data_handle(),
which computes the same address via pointer arithmetic only, across all 11
call sites in assemble_matrix_impl.h, assemble_vector_impl.h,
assemble_scalar_impl.h and assemble_expression_impl.h. Confirmed against a
DeveloperDebug + ASan/UBSan build: the C++ suite (cpp/test/, all 76 Catch2
test cases at np=1/2/3) and the Python suite (3486 tests) both now pass
cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…inter

submdspan(coeffs, idx, md::full_extent).data_handle() is UB-free but not
free: at -O2 its generic offset computation compiles to a few extra
instructions (a compare and conditional select) per call versus the
original &coeffs(idx, 0), inside a loop that runs once per cell/facet per
assembly.

Replace it with plain pointer arithmetic on a base pointer and stride
hoisted out of the loop once: coeffs.data_handle() + idx * coeffs.extent(1)
(and the corresponding two-term offset for interior-facet integrals, where
coeffs is indexed (f, side, cstride)). This is well-defined even when
coeffs is empty, since every offset then collapses to nullptr + 0 (a
zero-offset addition on a null pointer is explicitly well-defined by the
standard, unlike a nonzero one), and it compiles to strictly less code
than the original &coeffs(idx, 0) -- the per-iteration multiply is also
gone, since the stride no longer needs recomputing from an mdspan each
time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Drop dolfinx_validate_build_type() and its call sites: an unrecognised
CMAKE_BUILD_TYPE now behaves like any other CMake project (silently
produces no extra flags), rather than a hard error.

Simplify the DeveloperDebug flag construction: drop per-flag
check_cxx_compiler_flag probing for -Og/-g3/-fno-omit-frame-pointer, the
per-sanitizer link-probe loop with a WARNING fallback, and the Linux+Clang
-shared-libasan detection -- these flags are supported unconditionally by
the GCC/Clang versions DOLFINx already requires elsewhere in this module,
so probing them added complexity without changing the outcome.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nanobind_add_stub's nanobind_sanitizer_preload_env scans a target's
COMPILE_OPTIONS/LINK_OPTIONS properties for -fsanitize= to decide whether
to preload a sanitizer runtime for its stub-generation subprocess (which
imports the freshly built extension module directly). It cannot see flags
injected via the CMAKE_*_FLAGS_<CONFIG> variables that
DolfinxDeveloperCompilerFlags.cmake otherwise uses to reach every target
without per-target edits, so building the Python bindings under
DeveloperDebug with any sanitizer enabled crashed during the build itself
with "Interceptors are not working" -- before a single test could run.

Publish the flag lists computed for CMAKE_CXX_FLAGS_DEVELOPERDEBUG and
CMAKE_*_LINKER_FLAGS_DEVELOPERDEBUG as DOLFINX_CXX_DEVELOPER_DEBUG_
{SANITIZE_FLAGS,LINK_OPTIONS}, and attach them explicitly to the cpp
target in python/CMakeLists.txt so nanobind's existing detection can see
them. Guarded by CMAKE_BUILD_TYPE rather than a $<CONFIG:...> genex: a
genex-guarded entry's text is still present in COMPILE_OPTIONS even when
inactive, and nanobind's regex-based scan cannot tell the difference.

Confirmed: a from-scratch DeveloperDebug + ASan + UBSan Python build now
completes, including the stub-generation step, and pytest passes cleanly
(3486 passed, 80 skipped, 30 xfailed) once the process is launched
correctly for the sanitizer runtime to intercept from the start (see the
sanitizer runbook).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The SIP-stripping explanation in the original runbook was untested and
wrong for this failure mode. The real cause, confirmed empirically: a
framework Python build's venv bin/python3.x is a second launch layer over
Resources/Python.app/Contents/MacOS/Python, so dyld loads the sanitizer
runtime twice -- once via DYLD_INSERT_LIBRARIES at the outer launch, again
when dolfinx.cpp's own @rpath-linked copy is dlopened at import time --
and the too-late second initialisation aborts with "Interceptors are not
working". Launching the framework binary directly (as nanobind's own
darwin-python-path.py helper already does for the build-time stub
generator) avoids the double load.

Also update "Known findings" to record that both suites pass cleanly as of
the preceding two fixes, rather than implying an open, unquantified list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jhale

jhale commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

LLM rubbish, will revise.

jhale and others added 6 commits August 25, 2026 10:46
Cut from ~130 to ~75 lines: lead with commands, cut rationale down to what's
needed to follow the steps correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Clang's ASan runtime is named per-arch (libclang_rt.asan-<arch>.so); use
uname -m instead of a hardcoded x86_64 so the command works on arm64 too.

Replace the macOS recipe's fragile nested-quote one-liner with a plain
heredoc script plus a flat command, both directly copy-pastable, and
derive the venv's site-packages from $VIRTUAL_ENV rather than shelling out
again -- matches how virtually everyone actually runs this (venv active).

Verified end to end on this machine (Apple Silicon, Homebrew Python
framework build): the framework-binary launch avoids the double ASan load,
and site.addsitedir via $VIRTUAL_ENV picks up editable installs (ufl, ffcx)
correctly, which plain PYTHONPATH does not.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The runbook jumped straight to running tests, assuming the reader already
had a DeveloperDebug C++ install and Python bindings built against it.
Add explicit "Build the C++ library" and "Build the Python bindings"
steps, and rename the run sections for parallel structure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A dedicated build type turned out not to be needed: -Og, sanitizer flags,
and hardening are exactly what CMAKE_{CXX,C}_FLAGS(_INIT) exist for, and
apply uniformly to every target (library, tests, demos) with no DOLFINx-side
wiring at all -- there's already precedent for this in this repo's own
build-wheels.yml. Revert cpp/CMakeLists.txt, DolfinxDeveloperCompilerFlags.cmake,
generate-cmakefiles.py, the 9 demo CMakeLists.txt, cpp/dolfinx/CMakeLists.txt
and cpp/test/CMakeLists.txt to their state on main.

The one piece of this that isn't replaceable by a documented cmake flag is
kept and simplified in the next commit: nanobind_add_stub's own sanitizer
detection needs a small, genuinely necessary fix in python/CMakeLists.txt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… one

nanobind_add_stub's own sanitizer auto-detection scans a target's
COMPILE_OPTIONS/LINK_OPTIONS for -fsanitize=; it can't see flags injected
via CMAKE_CXX_FLAGS(_INIT), so building the Python bindings with a
sanitizer enabled that way crashes during the build's own stub-generation
step with "Interceptors are not working" -- before a single test runs.

The previous fix gated this on CMAKE_BUILD_TYPE STREQUAL "DeveloperDebug",
which no longer exists. Replace it with a plain regex match against
CMAKE_CXX_FLAGS: it reacts to any -fsanitize= however it got there, needs
no DOLFINx-specific cache variable, and has zero footprint when no
sanitizer is active.

Verified end to end: pip install with -Ccmake.define.CMAKE_CXX_FLAGS
containing -fsanitize=address,undefined completes (including stub
generation), the resulting cpp.so links the sanitized C++ library first in
its rpath, and a real ASan-instrumented pytest run passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace every -DCMAKE_BUILD_TYPE=DeveloperDebug reference with the
CMAKE_{CXX,C}_FLAGS(_INIT)-based recipe from the preceding commits, and
trim prose throughout: lead with commands, keep only the handful of notes
that are load-bearing (CMAKE_BUILD_TYPE=Debug and *_FLAGS_DEBUG="" being
required to avoid flags getting silently overridden; the macOS
DYLD_INSERT_LIBRARIES double-load abort and the exact fix for it; the
Open MPI + DYLD_INSERT_LIBRARIES incompatibility on macOS). Drop path
placeholders a developer building DOLFINx already knows how to fill in
(CMAKE_PREFIX_PATH, --prefix) and the two linker-flag variables that are
dead for the Python build (nanobind_add_module only produces a MODULE
target; no EXE or plain SHARED target exists to apply them to).

Update AGENTS.md, cpp/demo/README.md and python/README.md to point at the
runbook without naming a build type.

Every command in the runbook has been run on this machine end to end:
cpp/test (single-process and MPI, np=1..3), the Python build (including
the nanobind stub-generation step), and a real ASan+UBSan pytest run,
using the exact commands as written.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jhale jhale changed the title Add a DeveloperDebug CMake build type Document running DOLFINx under sanitizers Aug 25, 2026
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.

2 participants