Skip to content

test: sanitizers, fuzzing and memory bounds - #10

Merged
gudoshnikovn merged 1 commit into
release/v2.0.1.0from
feature/sanitizers
Oct 13, 2026
Merged

gudoshnikovn merged 1 commit into
release/v2.0.1.0from
feature/sanitizers

Conversation

@gudoshnikovn

Copy link
Copy Markdown
Owner

Runs CI on the branch; it is integrated by a local rebase and git merge --ff-only, not by merging this PR.

  • Sanitizers (tools/sanitizers.py): libphash, its decoders and the extension built with Clang under ASan + UBSan (with libFuzzer's coverage hooks) and under TSan; the runtime is preloaded into the uninstrumented interpreter. check fails on any report and on a leak whose block the extension allocated; tests/conftest.py asks LeakSanitizer for the check after the tests.
  • Fuzzing (tools/fuzz_targets.py, tools/fuzz.py, atheris): load, pixels, digest. The corpus in tests/fuzz/ is replayed by pytest on every platform. A minute per target in ci.yml, a quarter of an hour weekly in fuzz.yml.
  • Memory (tests/test_memory.py): every path repeated, growth of Python objects and RSS bounded; a batch's peak held to libphash's estimate per worker.

🤖 Generated with Claude Code

@gudoshnikovn
gudoshnikovn force-pushed the feature/sanitizers branch 3 times, most recently from 5e7ae54 to dcfbc9b Compare October 13, 2026 11:17
tools/sanitizers.py builds libphash, its decoders and the extension with
Clang's AddressSanitizer + UndefinedBehaviorSanitizer (and libFuzzer's
coverage hooks, so one build serves the tests and the fuzzers) or with
ThreadSanitizer, runs a command with the runtime preloaded into the
uninstrumented interpreter, and checks the logs. Any report fails; a leak
fails when the extension itself allocated the block. CPython leaves
thousands of unfreed blocks, and an LSan suppression cannot single out the
extension: it matches any frame, and every stack passes the interpreter.
The leak check runs when the tests are done (conftest), not at exit: the
interpreter's teardown buries it there, and silencing the exit check
through `exitcode` would also turn ASan aborts into success, as ASan,
LSan and UBSan share that flag.

Clang, not GCC: GCC's object-size check reports stb_image_resize2's
scatter tables at -O3, which Clang does not and libphash's own sanitizer
job (Clang) never sees. libphash is built in Release, as wheels are: its
Debug install names -lpng16 in libphash.pc while the archive is
libpng16d.a. The address build leaves out the function type check
(-fno-sanitize=function): the fuzzers run under atheris' copy of the
runtime, built by an older LLVM that has no handler for it, and the
extension would not load.

ci.yml runs the suite under both sanitizers on every push and PR, then each
fuzz target for a minute; fuzz.yml gives them a quarter of an hour each.

The fuzz targets cover what the binding adds over libphash, which fuzzes
its decoders in C: encoded bytes, array layouts against their C-ordered
copy (where ASan sees any read past a zero-copy view) and digests. The
load target found that libphash 2.0.1 hashes a truncated TGA differently
from load to load: stb_image leaves the missing rows uninitialized (16
loads of the same 74 bytes gave 11 hashes on a plain build). The target
compares the two loads' outcome and dimensions only, and the input is in
the corpus.

tests/test_memory.py bounds the growth of Python objects and of resident
memory over hundreds of repetitions of every path (success, load and hash
errors, GC of an unclosed context, batch, a raising callback); a deliberate
leak of one object or one context per step fails it. It also holds a
batch's peak to libphash's estimate, four bytes per pixel and the file per
worker (measured: exactly that for 1, 2 and 4 workers), plus the files the
binding reads itself on Windows. Under a sanitizer it skips: CPython
allocates through malloc there and some of its caches fill slowly enough
to look like growth. mutmut leaves it out: it would multiply the run's time
and catches no mutant the other tests miss.

On a free-threaded build the two scenarios whose callbacks run on
libphash's workers are a strict xfail: cffi 2.1.1 keeps a few objects of
every C thread that enters an extern "Python" callback (an empty one
included; a minimal cffi module without libphash shows it, a ctypes
callback from the same threads does not, and GIL builds stay flat), and
each batch starts its own workers.

Valgrind is not run: ASan and LSan cover its findings here, and CPython
under Valgrind is slow and noisy.
@gudoshnikovn
gudoshnikovn merged commit 2cca660 into release/v2.0.1.0 Oct 13, 2026
27 checks passed
@gudoshnikovn
gudoshnikovn deleted the feature/sanitizers branch October 13, 2026 12:05
@gudoshnikovn gudoshnikovn self-assigned this Oct 13, 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.

1 participant