Robustness checks: sanitizers, fuzzers, mutation checks, long property runs#
These checks look for bugs that the unit tests, the conformance kit and the golden parity do not reach by construction (PLAN Sections 7.3, 8.1 and 8.4; ADR 0034). The sanitizer jobs run the CPU test suite on every pull request; they are not required checks yet (the lead maintainer decides, Repository settings (lead maintainer), step 9). The other three are never required: each explores inputs at random or takes hours, so a failure is a new bug to fix, not a property of the pull request that met it.
Check |
What |
Where |
When |
|---|---|---|---|
Host sanitizers |
the CPU tests ( |
presets |
every pull request and push to |
Reader fuzzers |
libFuzzer on every file reader, with ASan and UBSan |
|
pull requests that touch a reader (60 s per target), weekly (10 minutes per target), on demand |
Corpus replay |
the seed corpus and every fixed finding, once |
CTest |
every test build, so every pull request |
Golden mutation checks |
each recorded bug, put back, must make its golden suite fail |
|
before each minor release, and when a mutation point changes (local: needs the golden corpus and a GPU) |
Long property runs |
the Hypothesis properties with many more, non-derandomized examples |
profile |
weekly, on demand |
The device code has its own sanitizer, compute-sanitizer (memcheck with leak checks, synccheck,
racecheck), which needs a GPU and runs in ci/gpu_local.sh.
Host sanitizers#
ci/sanitizers.sh configures, builds and runs ctest -L cpu for each preset it is given (all
three without arguments); sanitizers.yml runs one preset per job on GitHub-hosted runners.
Preset (job) |
Instrumentation |
What it covers |
|---|---|---|
|
AddressSanitizer + UndefinedBehaviorSanitizer, leak checks on ( |
every CPU test, the OpenMP backends included |
|
ThreadSanitizer, OpenMP off |
the |
|
ThreadSanitizer with the OpenMP backends; Clang only |
the OpenMP backends of every algorithm (the conformance kit’s C2-C12 on |
Why two TSan presets: an OpenMP runtime is not built with TSan, so TSan does not see its barriers,
reductions and schedules and reports every parallel region as a race. GCC’s libgomp offers no way
around that (the preset tsan therefore turns OpenMP off, and configuring tsan with GCC and
DYNG_ENABLE_OPENMP=ON is an error). Clang’s libomp comes with Archer, an OMPT tool that
tells TSan about OpenMP’s synchronization: the preset tsan-openmp builds with Clang, and its
tests run with OMP_TOOL_LIBRARIES=<libarcher.so> and
TSAN_OPTIONS=halt_on_error=1:ignore_noninstrumented_modules=1 (accesses inside the
uninstrumented libomp are not reported). Without Archer, the same tests report races in libomp’s
own locks; with it, a real race in an OpenMP loop (a shared variable written without the
reduction) is still reported, which was checked when the job was set up.
ci/sanitizers.sh # asan, tsan and tsan-openmp
ci/sanitizers.sh asan # one preset (CXX picks the compiler; default the system's)
CXX=clang++-18 ci/sanitizers.sh tsan-openmp
DYNG_ARCHER_LIBRARY=/path/to/libarcher.so ci/sanitizers.sh tsan-openmp
tsan-openmp takes CXX (default clang++-18, else clang++) and finds Archer next to the
compiler’s libraries ($CXX -print-file-name=libarcher.so; Debian and Ubuntu ship it with
libomp-<N>-dev), or takes DYNG_ARCHER_LIBRARY. The hosted sanitizer jobs and the fuzz job
(fuzz.yml, whose targets use the ASan runtime of LLVM 18) set vm.mmap_rnd_bits=28 first: the
runners randomize mmap with 32 bits, which the TSan and ASan runtimes of GCC 13 and LLVM 18 do not
support.
On the development machine (2026-10-06, ctest -L cpu -j 8): asan 826 tests passed in 101 s,
tsan 733 in 141 s, tsan-openmp 826 in 134 s; no sanitizer reports. Each preset’s build is a
full Debug build (about 6 CPU-minutes there; on the 4-core hosted runners a job is expected to
take 10-20 minutes).
A report fails its test (halt_on_error=1). Fix the code, not the check: a race or an
out-of-bounds access found here gets a regression test like any other bug. A suppression file
needs an ADR.
Reader fuzzers#
Every reader of dyng::io has a libFuzzer target in cpp/fuzz (its README lists them): Matrix
Market, edge lists (and their Matrix Market path), the MOSP CSR triplet, MOSP batches
(insert.txt / delete.txt), .dgt batch files (graph and hypergraph batches), the result files
(distances, parents), and since M8b the hypergraph files (fuzz_hg, .hg) and the binary batch
files (fuzz_batch_binary, .dgb). Each reader added later gets a target in the same pull
request.
A target splits its input into reader options (the first line: byte i selects option i) and the
file contents (lines @@ separate the files of a reader that opens several), writes the files and
calls the reader. It requires that
a rejected input raises only an exception the reader documents (
io_error,invalid_argument_error,out_of_memory_error); any other exception, a sanitizer report, a crash or a hang is a finding;an accepted input gives a consistent result (ids in range, sizes that agree, the documented order);
where a writer exists, writing the result and reading it back gives the same data. Only the first read may reject the input: in the round trip (
round_trip()ofcpp/fuzz/fuzz_input.hpp) any exception, a copy that the reader rejects included, is a finding. CTestfuzz.selftest.<target>proves that this check can fail: built withDYNG_FUZZ_CORRUPT_WRITES=1, the target appends a line of garbage to every file its writer produced, and the test passes only if the replay of the seed corpus reports the failure;no single allocation exceeds 2 GB and no input takes 10 s (
-malloc_limit_mb,-timeout): a file of a few kilobytes that makes a reader reserve gigabytes is a finding too. The replay of the committed inputs (fuzz.corpus.<target>) uses a 256 MB limit, which every reproducer ofcpp/fuzz/regressionsexceeded before its fix.
The command line’s text batches are parsed in Python, where libFuzzer does not reach:
python/tests/test_reader_robustness.py feeds them, and dyng.io’s readers, arbitrary text with
Hypothesis under the same rules (documented exceptions only, round trips).
Running them#
ci/fuzz.sh # every target for 60 s
ci/fuzz.sh --time 600 csr_triplet # one target for 10 minutes
DYNG_FUZZ_JOBS=4 ci/fuzz.sh # four fuzzing processes per target (libFuzzer -fork)
The script configures the preset fuzz (Clang only: CXX=clang++-18 by default; libdyng is
compiled with -fsanitize=fuzzer-no-link,address,undefined, -O1, libstdc++ assertions, and only
the modules the readers need), builds the targets, replays the seed corpus and the reproducers
(ctest --preset fuzz), then fuzzes each target on a copy of its seed corpus in
build/fuzz-runs/corpus/<target>. On the shared development machine, run it under the shared
perf lock and niced: flock -s "$DYNG_SCRATCH/perf.lock" nice -n 10 ci/fuzz.sh.
fuzz.yml runs the same script on GitHub’s runners with Clang 18 (clang-18,
libclang-rt-18-dev): on pull requests that change a reader, the parser utilities, the fuzzers or
the workflow (60 s per target), weekly on main (10 minutes per target) and on demand (at most
1000 s per target, so that the six targets and the build fit the job’s 120 minutes). A finding
fails the job and uploads its reproducer (artifact fuzz-findings; also when the job is
cancelled).
A finding#
Reproduce it:
build/fuzz/cpp/fuzz/fuzz_<target> <reproducer>(one run), and minimize it if it is large:fuzz_<target> -minimize_crash=1 -runs=10000 <reproducer>.Fix the reader. A reader must report malformed input as
io_errorwith the path, line and column, and must not size an allocation by a count in the file that the rest of the file cannot hold.Add the reproducer to
cpp/fuzz/regressions/<target>/(every test build replays it) and a test tocpp/tests/io/fuzz_regression_test.cppthat states what the reader must do (the error, and, for an allocation finding, the bound: that executable replacesoperator newand records the largest request).
Findings so far#
Date |
Target |
Finding |
Fix |
|---|---|---|---|
2026-10-06 |
|
a 74-byte file whose size line announced 3,000,000,000,003 entries made the reader reserve 3 GB (min(entries, 2^26) entries, twice for a symmetric matrix) before it found the file empty |
the reservation is bounded by the entries the file can hold (4 bytes each) |
2026-10-06 |
|
a RowPtr announcing 10^9 edges made the strict ColInd reader reserve 4 GB; with m edges in RowPtr and ColInd, a Values file of one line of K weights made the strict Values reader allocate m * K weights (1.6 GB for m = K = 20,000) |
both reservations are bounded by what the file can hold (an index takes 2 bytes, a weight line one line break) |
2026-10-06 |
|
the command line’s text batches ( |
both are |
Each fix has its reproducer and test (above). After the fixes every target ran for 5 and then 15
minutes on the development machine without a finding (in the 15-minute run between 0.47 million
inputs, fuzz_csr_triplet, and 5.6 million, fuzz_result_io).
Golden mutation checks#
parity/mutate.py puts each recorded bug back into a git archive copy of the current commit,
builds the compatibility driver of its golden suite (parity-cuda preset) and replays the suite
on the mutation’s backend: the control (no mutation) must pass on every suite and backend the
mutations use, and every mutation must make its suite fail.
Suite (golden corpus) |
Mutations (each on the sequential, OpenMP and CUDA backend) |
|---|---|
|
a skipped subtree invalidation: only the roots of a deleted tree edge are invalidated (PLAN 8.4, the count-to-infinity revert) |
|
double counting 5-cycles; a weakened ownership rule (PLAN 8.4, the two bugs the original recorded; host searches and device kernels) |
|
a combined-graph edge weighted by the first tree that holds it only; the path costs of every objective read from the first objective’s weights (shared by the backends) |
parity/mutate.py list
parity/mutate.py run --json mutation.json # everything (about 30 minutes: one build each)
parity/mutate.py run --suites mosp --json mosp.json # one suite
parity/mutate.py run --mutations cycle_count_weak_ownership
The run of 2026-10-06 (commit a99e968, parity/results/M6b-mutation.json): the control passed on
the sssp and cycle_count corpora with the sequential, OpenMP (4 threads) and CUDA backends, and all
eleven mutations were detected: sssp 365 failing cases on each backend, mosp 398 (combined-edge
weights) and 397 (path costs) of the 495 cases, cycle_count 4 (double-counted 5-cycles) and 14
(weakened ownership) of its cases on the host and on CUDA.
It needs the golden corpus in $DYNG_SCRATCH/goldens (parity/export_goldens.py) and a GPU for
the CUDA mutations ($DYNG_TEST_GPU, default 1), so it runs on the development machine, before
each minor release (docs/developer/release.md, step 3, records it with parity/certify.py check),
and whenever a mutation point changes: parity/tests checks that every textual mutation still
matches its code exactly once and that every defines mutation names a hook that exists. The
cycle_count mutations are also CTests (ctest -L mutation), which run them against the unit and
randomized suites on every build.
Long property runs#
The pytest suite’s Hypothesis properties (python/tests/test_properties.py, test_mosp.py)
compare sssp, cycle_count, mosp, the multi-result update and the tutorial algorithms
(dynamic_bfs, triangle_delta) with pure-Python oracles on random graphs and chains of batches.
Pull requests run the derandomized profile ci (200 examples per property, the same inputs every
time). The profile full explores: 10,000 examples per property by default
(DYNG_HYPOTHESIS_EXAMPLES), a new random seed every run, and the failing example printed as a
@reproduce_failure blob. With 2,500 examples the seven properties took 82 s on the development
machine (2026-10-06, all passed).
DYNG_HYPOTHESIS_PROFILE=full python -m pytest python/tests/test_properties.py python/tests/test_mosp.py
DYNG_HYPOTHESIS_PROFILE=full ci/python.sh # the whole suite, as property.yml runs it
property.yml runs ci/python.sh with the profile full weekly and on demand (with a chosen
number of examples). A failure: replay it with the printed blob, fix the bug, and keep the input
as an @example of the property.