Skip to content

Sanitizer capability comparison

The survey's capstone: the capability matrix — the seven analysis concerns against every sanitizer family surveyed — the field's consensus on how a test runner pins a finding to a test, the architectural trade-offs behind the differences, the measured overheads, and the delta table mapping each capability onto where sparkles:test-runner stands today. It ends with the questions the survey could not close from this hardware.

Last reviewed: July 11, 2026

Verification tags, abbreviated for the wide tables: [hw] = [hw-verified: x86_64-linux] (AMD Ryzen 9 7940HX, kernel 6.18.26, LDC 1.41.0 / DMD 2.112.1, GCC 15.2 runtimes) · [hw·mac] = [hw-verified: aarch64-darwin] (Apple M4 Max, macOS 26.3.1 — one datum landed, the rest blocked) · [src] = [source-verified] (pinned repos, see the deep-dives' Sources) · [lit] = [literature]. There is no aarch64-linux, Windows, or MTE-silicon bed — those cells carry [src]/[lit] by construction.


The capability matrix

One row per concern, one column per tool family; each cell is a terse verdict plus a D-reachability qualifier (toolchain/OS) and its strongest verification tag. Absent cells are explicit — an absence is a finding, not a blank. The matrix is split by column group so neither table scrolls: the compiler-instrumented tools first, then the no-recompile, hardware, and sampling tools.

Compiler-instrumented tools (LLVM IR pass / clang CodeGen)

ConcernASan + LSanTSanMSanUBSan
Defect classes and blind spotsheap/stack/global OOB, UAF, stack-use-after-return; leaks (LSan). Blind: GC-pool UAF; LSan false-positives GC-referenced malloc [hw]data races (core.atomic modeled); thread leaks, mutex misuse. Blind: fence-only lock-free, GC memory; deadlock detector dead [hw]uninitialized-value reads (definedness). Blind: fresh GC mmap reads defined → false negatives [hw]40 UB checks, but D covers most (defined wrap, RangeError, @safe); residue: out-of-range shifts, int.min/-1, misalignment [src]
Instrumentation model and recompile scopeLLVM IR pass; user-code-only rebuild (no instrumented world). LDC via gcc-rt fallback; not DMD [hw]LLVM IR pass; rebuild checked modules only. LDC-only among D compilers [hw]LLVM IR pass + instrumented-world requirement; nixpkgs LDC link-fails (gcc has no MSan), links via -conf= clang_rt [hw]clang CodeGen only, no IR pass → unreachable from LDC, GDC (check-empty), DMD [src]
D and druntime interactionclean GC baseline; defaultTraceHandler leaks force detect_leaks=0; fake-stack GC-scan hazard (unproven) [hw]2 druntime noise classes (2-line supp); GC-heavy multithreaded livelock (watchdog); fibers sound [hw]GC memory reads defined (false negatives); uninstrumented callee → false positive [hw]N/A — no instrumentation exists to interact [src]
Runtime control and report capturehalts by default; recover needs -fsanitize-recover=address + halt_on_error=0, then exits 0 (count reports); report callback, log_path [hw]report-and-continue; exit 66 at finalize; __tsan_on_report/_on_finalize/_default_options (need --export-dynamic) [hw]-fsanitize-recover=memory supported; shared *SAN_OPTIONS surface [src]N/A — libubsan is installed but nothing feeds it [hw]
Symbolization and suppressionsGCC rt self-symbolizes file:line; no D demangle → mangled globs; -fsanitize-blacklist / @noSanitize [hw]GCC rt self-symbolizes; mangled globs; race:/signal: types [hw]compiler-rt symbolization (llvm-symbolizer); no D demangle [src]N/A [src]
Test-runner integration semanticsthree attribution designs reproduced from D; log_path per-process file [hw]Go count-delta windowing reproduced from D; dub launders 66 → 2 [hw]no per-test story — unusable by default (a finding-with-locator, not a TODO) [hw]N/A — no D finding to attribute [src]
Platform, toolchain, and overheadLDC-only; Linux [hw]; darwin exit 134 (dylib) [hw·mac]; Windows DLL but exceptions broken (#3760); ~2× [lit]LDC-only; ~5× time, 2× memory; darwin TSan exists (unrun); no Windows link path [hw]/[src]LDC via -conf=/tarball only; unusable without an instrumented world; no default, no Windows [hw]unreachable on every compiler and platform [src]

No-recompile, hardware, and sampling tools

ConcernValgrind memcheckhelgrind / DRDHWASan / Arm MTEGWP-ASan
Defect classes and blind spotsaddressability + definedness (UninitValue/UninitCondition, the class ASan lacks); leaks. Blind: no redzones (small in-block overrun), GC-UAF [hw]data races + lock-order (helgrind) / lock contention (DRD) + API misuse. Blind: core.atomic/SpinLock invisible; miss short serialized races [hw]ASan classes by tag mismatch (probabilistic); MTE = hardware spatial+temporal heap safety. No definedness/UB; GC-UAF still blind [hw]/[src]heap OOB + UAF on sampled allocations only; probabilistic; no stack/global [hw]
Instrumentation model and recompile scopeDBI (VEX JIT), no recompile, every compiler incl. DMD; -g adds file:line [hw]DBI, no recompile, every compiler [hw]HWASan = IR pass + tagging malloc; MTE = hardware, only stack tagging rebuilt. Not in LDC's -fsanitize= set [src]sampling allocator, no recompile; -fsanitize=scudo (no gwp-asan flag) [hw]
D and druntime interactionetc.valgrind client requests; GC noise tiny (3-entry supp); breaks under DMD shared Phobos; gc.d:3907 dead code [hw]clean at -t 1, drown at -t>1 (GC SpinLock); nixpkgs default.supp over-blankets libc [hw]GC scan through an untagged pointer faults (hw-proven); druntime must untag; fiber tag-boundary open [hw]C-heap only — never GC memory (mmap pools) [src]
Runtime control and report captureCLI-only, report-and-continue always; --error-exitcode opt-in; client requests [hw]CLI; report-and-continue; --error-exitcode [hw]HWASAN_OPTIONS mirror ASan, exit 99; MTE has no *SAN_OPTIONS (kernel prctl; ASYNC si_addr=0) [hw]/[src]SCUDO_OPTIONS=GWP_ASAN_*; SIGSEGV exit 139; Recoverable mode [hw]
Symbolization and suppressionsbuilt-in DWARF reader + D demangler (categorical win); protocol-4 XML; mangled suppressions [hw]built-in demangle; XML Race kind; mangled suppressions [hw]tag dump around the fault; compiler-rt symbolize, no D demangle; MTE SYNC precise / ASYNC unattributable [hw]/[src]alloc/free/access stacks as raw addresses; external symbolizer; no D demangle [hw]
Test-runner integration semanticswrapper-and-parse over XML; VALGRIND_PRINTF marker windows; forces -t 1 + --fair-sched=yes [hw]-t 1 forced; helgrind default + DRD second opinion; not a TSan replacement for short races [hw]report shape = ASan's; x86 aliasing fork-unsafe (no process-per-test); MTE = deployment, N/A [src]not a unit-test mode — sampling ≠ deterministic; production/soak only [src]
Platform, toolchain, and overheadcompiler-independent; DMD's only path; Linux [hw]; dead on macOS > 10.13; ~4.4× marginal [hw]compiler-independent; helgrind 2.1× / DRD ~2.2×; Linux; dead on macOS [hw]HWASan: aarch64 native / x86 LAM (AMD fatal) / x86 aliasing (runs here); MTE silicon = Pixel 8 / AmpereOne, not M4 [hw]/[lit]reachable via -fsanitize=scudo (C heap); Linux [hw]; production (Android/Chrome/Apple) [lit]

NOTE

Three findings recur down every column and are the matrix's real spine. First: the sanitizer set ends at the Linux border except AddressSanitizer — ASan crosses to macOS (dylib) and Windows (DLL, though D exceptions break under it); everything else is Linux-only, dead (Valgrind on macOS), or absent (arm64 runtimes). Second: UBSan (and TySan) are unreachable from every D compiler because their checks live in clang CodeGen with no IR pass to borrow — an architectural wall, not a packaging gap (ubsan.md). Third: D's GC pools come from mmap, so a use-after-free inside GC memory is invisible to every allocator-interception tool — ASan, memcheck, HWASan, GWP-ASan alike — and only memcheck's etc.valgrind path narrows it (the GC memory blind spot).


The integration consensus: three attribution designs

The field's runners (runner-integrations.md) converge on exactly three ways to pin a finding to the test that caused it, and — the load-bearing survey result — all three were already reproduced from D. This is the condensed view; the full per-ecosystem breakdown is runner-integrations.md's integration-semantics table, which this page does not duplicate.

DesignWho uses it (surveyed)The one lesson for sparkles
In-process report windowingGo go test -race, googletest weak-callback, pytest-valgrindMaps onto sparkles' existing TaskPool runner; needs continue-semantics; -t 1 (or per-worker windows) bounds the cross-test blur — the seam Experiment E8 drove from D
Process-per-test isolationcargo-nextest, SwiftPM --parallel, BazelIts own machinery (extract/driver) is ≈90% of it; it is what fixes the runner's SEGV-kills-the-run gap, with zero sanitizer-specific code
Wrapper-and-parseCTest MemCheck, pytest-valgrind's log sideThe --valgrind mode, over XML protocol 4 instead of regex-over-log; suppression files become checked-in config (CTest's CTEST_MEMORYCHECK_SUPPRESSIONS_FILE is the only first-class precedent)

The single hardest consensus to swallow: "nonzero exit = failed test" survives no contact with the tools. ASan halts and a recovered run exits 0; TSan reports-and-continues and exits 66 only at finalize; HWASan exits 99, RTSan 43, GWP-ASan SIGSEGVs to 139, TySan continues to 0, darwin ASan aborts to 134, and Valgrind passes the child's own code through unless --error-exitcode is set. The full table is concepts § halt vs recover; the runner consequence is that a --sanitize mode must count reports, not read exit codes.


Architectural trade-offs

The axes that explain the matrix's differences, and where each tool family lands.

AxisPolesWhere each lands
Instrumentation locuscompile-time IR pass ↔ clang CodeGen ↔ DBI ↔ hardware/samplingASan/TSan/MSan/HWASan: LLVM IR passes LDC inherits · UBSan/TySan: clang CodeGen, unreachable from D · memcheck/helgrind/DRD: DBI, no recompile, DMD's only path · MTE: hardware · GWP-ASan: sampling allocator
Recompile scopeuser code only ↔ instrumented world ↔ noneASan/TSan: user modules suffice (uninstrumented druntime is a coverage gap, not a false positive) · MSan: all code incl. libc or false positives · Valgrind/GWP-ASan/MTE: nothing recompiled
Halt policyhalt-on-first ↔ report-and-continueASan/HWASan/RTSan halt (recover flags to survive) · TSan/memcheck/TySan continue by default · exit codes irreconcilable (1 / 66 / 99 / 43 / 0 / 139 / 134), so count reports
Attribution boundaryin-process window ↔ process edge ↔ parsed side-channelwindowing (Go/gtest → sparkles' TaskPool) · process-per-test (nextest/Swift/Bazel → extract/driver) · wrapper-and-parse (CTest, the --valgrind XML mode)
Symbolization provenanceruntime self-symbolizes ↔ external symbolizer ↔ built-in demanglerGCC libsanitizer self-symbolizes via libbacktrace but never demangles D · clang_rt needs llvm-symbolizer (demangles only D main) · Valgrind ships its own DWARF reader and D demangler
GC-memory visibilityallocator-intercepted ↔ mmap-blindevery allocator-interception tool is blind to GC pools (the blind spot); only memcheck closes it (source-into-app -debug=VALGRIND); TSan still races-checks GC memory because it instruments the accesses, not the allocator

Measured overhead

All figures [hw] on the x86_64-linux bed, taken as recorded on the tool pages (asan.md, tsan.md, valgrind.md); no darwin or Windows timings were measured (hardware blocked).

Tool / modeMarginal slowdown (hot code)Fixed startupNotes
ASan~2×the docs' figure; makes it a test-time, not production, tool [lit]
LSan≈0 until the exit-time scanfolds into ASan or runs standalone [lit]
TSan≈5× (:versions 49 ms vs 8.8 ms), 2× memorylow end of the documented 5–15× because the tests are tiny [hw]
memcheck4.4× (6.4× with --track-origins)~0.25 sheadline "10–50×" is the whole-program figure; hot-loop marginal is far lower [hw]
helgrind2.1×~0.21 sterse dedup; adds a lock-order class [hw]
DRD~2.2×quieter on druntime primitives, floods per-access on a real race [hw]

The number that dominates the --valgrind design is not a slowdown factor but a scheduler pathology: sparkles' in-process TaskPool under Valgrind's default unfair scheduler turns a 4 ms :base suite into a 156.5 s memcheck run at -t auto (spread 12.5–180 s), versus 1.16 s at -t 1 and 1.27 s at -t auto --fair-sched=yes. This is why the --valgrind mode forces -t 1 and passes --fair-sched=yes (valgrind.md § runner integration).


The delta table: the survey vs. the sparkles baseline

Each survey capability against today's runner, with the proposal milestone that closes the gap. The runner has no sanitizer support today, so every "sparkles today" cell but one is an absence — which is the audit's whole point (baseline § what the audit will check).

Capability (best practice found)sparkles todayGapCloses in
Whole-closure instrumented rebuild (custom buildType proven, not DFLAGS)❌ no --sanitize mode; only channel (DFLAGS) is a false greenno way to build-and-run a package under instrumentationM1
Per-test attribution (3 designs, 2 hw-proven from D)❌ none; a finding lands on stderr unattributed, and a crash kills the runcan't say which test raced/leaked/faultedM2 (+ M4)
Halt-vs-recover policy (a per-tool table)❌ none — no notion of a tool's exit/halt semantics"nonzero exit = failed" assumption would misread every toolM1 / M2
Suppression management (2-line TSan + 3-entry memcheck sets authored)❌ none in-treedruntime noise floor unhandled; no user-suppression compositionM2 / M3
Capability advertisement (a CapabilityReport sketch)⚠️ skipTest exists (the degradation shape), but no sanitizer seamabsences aren't enumerated ("DMD has no -fsanitize; use --valgrind")capability seam
Crash isolation (process-per-test ≈90% built via extract machinery)❌ in-process only; a SIGSEGV loses the summary and cancels siblingsone bad test dooms the whole runM4
Report rendering (TestResult needs a findings field)Thrown[] only (no field to land in)a sanitizer report has nowhere to render per testM1
Parallelism policy (-t 1 / --fair-sched findings)totalCPUs default, no per-tool policyTSan livelocks and memcheck goes pathological at default parallelismM2 / M3

Open questions & gaps

What stayed [src]/[lit] for lack of hardware, plus the known-open hazards the runner must carry forward. Each is one honest line.

  1. MSan needs an instrumented world sparkles does not build. MSan is a finding-with-a-locator, not a feature: usable only against an instrumented druntime+Phobos (buildable in ~3 min via ldc-build-runtime) plus real compiler-rt; on the default nixpkgs path it does not even link (d-toolchain § MSan).
  2. No MTE silicon is in reach. The project's only aarch64 box is an Apple M4, which has no MTE (MIE/EMTE is A19-only); every MTE claim is kernel-doc [src] or [lit], and the direct sysctl probe was itself blocked (hardware-assisted § silicon reality).
  3. Windows has no hardware bed at all. The whole Windows column is MS Learn docs plus LDC/Dr. Memory source — never [hw-verified] (macos-windows § Windows).
  4. The darwin transcripts are blocked, not written. The mac-bsn D-on-darwin sanitizer runs (TSan, LSan detect_leaks, fiber UAR, the MallocNanoZone warning text) were blocked by an ssh-key/gpg-agent pinentry wall; only the Apple-clang C ASan smoke test landed. A zero-thought rerun kit is staged (macos-windows).
  5. The event-horizon TSan run is unexplained. The repo's 2026-07-10 history claims "51 tests ASan-clean" from a DFLAGS=… dub test recipe that, on today's dub, compiles zero tests — either dub semantics changed or the recipe never ran as written; undecidable from this box (baseline § the historical seed).
  6. -allinst's necessity under sanitizers is unconfirmed. No sanitizer-specific -allinst failure was reproducible for :versions/:base; the event-horizon "-allinst fix" applies to a seed package that lives only on feat/event-horizon (d-toolchain § instrumentation model).
  7. The fake-stack GC-root hazard is real but unreproduced.scanStackForASanFakeStack exists precisely because unscanned UAR fake frames drop GC roots, yet four attempts did not trigger a premature collection (LLVM declined to fake-stack the test frames) (d-toolchain § SupportSanitizers).
  8. __lsan_register_root_region over GC pools is untested. The API (present in both GCC and clang_rt runtimes) would turn the GC-referenced-malloc LSan false positive into a true negative, but was never exercised as a druntime hook (asan § LeakSanitizer and the D GC).
  9. The helgrind/DRD -t>1 suppression set was not authored. Both tools are clean at -t 1 and drown at -t>1 (the GC SpinLock is invisible to pthread-modeling detectors); a parallel-mode suppression file was not written — the mode should force -t 1 instead (valgrind § runner integration).
  10. UBSan's residual slice has no D tool. Out-of-range shifts, int.min / -1, and misaligned access are genuinely uncovered in D and out of every surveyed tool's reach; closing them means an LDC gen/ emitter or targeted language checks, not a sanitizer port (ubsan § if UBSan-for-D mattered).
  11. RTSan is adoptable but blocked on an LLVM bump. It is a real IR pass an LDC port could reach, but needs LLVM ≥ 20 and LDC 1.41 ships LLVM 18 (hardware-assisted § RTSan).

Sources

Aggregated from the deep-dives; every cell's locator lives in its page's Sources section (repos pinned by SHA there). The primary reads behind the matrix are LLVM compiler-rt/clang at 73802c2e, Valgrind at 218cee2f (tag VALGRIND_3_26_0), LDC v1.41.0, DMD e6baf474, and dub 5efed360; the runner-integration columns add Go, Rust/cargo-nextest, SwiftPM, Zig, googletest, CMake, and pytest-valgrind at the SHAs runner-integrations.md pins. Direct experiment evidence: the eleven runnable example probes and the one mac-bsn Apple-clang smoke test quoted in macos-windows.md.