Repository navigation
Conversation
Adds opt-in clang -fprofile-instr-generate -fcoverage-mapping instrumentation to the macOS and Linux pytest builds and wires the resulting coverage to Codecov alongside the existing Python coverage. Determinism under pytest-xdist: LLVM_PROFILE_FILE is set to grass-%p-%9m.profraw so every subprocess and every instrumented binary writes its own file, eliminating the collision that made re-runs non-deterministic for untouched files. Merge uses llvm-profdata -sparse which is bit-identical for a given input set (verified locally). Fixes a latent ordering bug where utils/coverage_mapper.py ran before coverage combine and could not read a .coverage that did not yet exist in parallel mode. The mapper now runs on the combined data. Adds utils/coverage_disk_usage.sh to log raw coverage disk usage before the combine step so a run that threatens the runner storage budget is visible in the log, and utils/llvm_coverage_export.sh to merge .profraw into .profdata and export lcov for Codecov, filtering system, conda, and build-tree paths. macOS Codecov uploads use distinct flags macos-pytest-python and macos-llvm-c; Linux adds ubuntu-llvm-c to exercise the same path during local iteration. macOS coverage is combined after the gunittest run so profraw from both pytest and gunittest are included. Large use of AI assistance (Claude Code) helped draft the workflow diffs and the utility scripts.
The instrumented build runs each tool with --interface-description to emit its XML, which drops default.profraw next to the binary. Those files are build artifacts (no tests ran, coverage from the build tree would contaminate the test-run merge), so they belong ignored, and the CI coverage-prep step deletes any stray default.profraw before the test steps start writing controlled per-PID files under llvm-cov-raw/. Adds *.profraw, *.profdata, coverage-llvm.lcov, llvm-cov-raw/, and coverage_html_report/ to .gitignore.
Adds -fprofile-update=atomic to the coverage CFLAGS on both macOS and Linux pytest builds. Without it, LLVM's coverage counter increments are non-atomic, and GRASS tools with #pragma omp parallel / for regions (e.g. r.univar's process_raster) lose counter updates across threads. Two identical runs against a locally-built, instrumented grass produced 244 differing lcov lines, all inside r.univar_main.c's outlined OpenMP region. This is the collision reported previously as coverage of untouched files changing between runs. Also tightens utils/llvm_coverage_export.sh so its -object list only picks up real ELF / Mach-O binaries. The previous perm -u+x sweep would pull in executable Python scripts (e.g. installed grass scripts), and llvm-cov aborts on the first one it cannot recognize. A full local rebuild + rerun is queued to verify the diff closes; if anything remains after -fprofile-update=atomic it will land as a follow-up commit on this branch.
Clang's SourceBasedCodeCoverage doc explicitly states that llvm optimizations (inlining, CFG simplification) have no impact on coverage report quality: the mapping from source regions to profile counters is immutable and is generated before the optimizer runs, and the feature does not rely on debug info line tables. Force -O0 -g was gcov guidance, not the right choice here, and it inflated the instrumented build size and the runtime cost this PR promised to keep small. Also fix the Linux path: setting CFLAGS explicitly at configure time suppresses autoconf's default "-g -O2", so include -O2 in the coverage CFLAGS (macOS already sets -O2 explicitly before the coverage block).
The pytest workflow's Ubuntu build failed at configure with "Unable to locate OpenMP includes" once GRASS_LLVM_COVERAGE=1 flipped the compiler to clang. Ubuntu's libomp-dev keeps omp.h at /usr/lib/llvm-18/lib/clang/<ver>/include/omp.h rather than /usr/include, and configure's plain omp.h probe doesn't add clang's own resource-dir to its include search. Fix by passing --with-openmp-includes derived from `$CC -print-resource-dir` when the coverage build is active. Also add libomp-dev to apt.txt so the runtime lib is linkable. Make the Python coverage combine step tolerate "no data" so that when an earlier step fails and pytest is skipped, the combine step reports "nothing to export" instead of failing on top of the real error.
The pytest workflow's Ubuntu build now clears the OpenMP-include probe but fails to link every OpenMP-using tool (r.mapcalc, r.univar, r.slope.aspect, r.sun, ...) once GRASS_LLVM_COVERAGE=1 flipped the compiler to clang. Root cause: libomp-dev on Ubuntu 24.04 installs the unversioned libomp.so at /usr/lib/llvm-18/lib, which is not in the linker's default search path. configure's link probe for omp_get_num_threads in -lomp therefore fails and it falls back to -lgomp - but clang's OpenMP-instrumented code emits __kmpc_* calls that gomp doesn't provide. Pass --with-openmp-libs=<clang's llvm libdir> so configure finds -lomp and every OMP tool links cleanly. The libdir is derived from clang -print-resource-dir so the fix is not tied to any specific LLVM version number.
Adding clang, llvm, and libomp-dev to the shared apt.txt slowed down every workflow that reads that list (ubuntu.yml, gcc.yml, codeql, cmake, docs...) for a toolchain none of them need. Move them into a new "Install LLVM coverage toolchain" step in pytest.yml, gated on env.GRASS_LLVM_COVERAGE == '1', so only the coverage matrix pays the apt cost.
Instrumenting the build costs ~1 min extra compile time and adds a merge/export step. Running it in every matrix cell duplicated all of that for no additional information. Gate GRASS_LLVM_COVERAGE, the llvm/libomp toolchain install, the merge/export step, and the ubuntu-llvm-c Codecov upload on matrix.python-version == '3.13' so the 3.11 cell now takes the fast gcc/no-instrumentation path. Python coverage still runs on both cells and continues to upload under its own per-version flag.
utils/llvm_coverage_export.sh globbed all .profraw files onto llvm-profdata's argv. The per-process %p-%9m naming produces one file per (binary, pid) pair, and a full test run generates hundreds of thousands of them (454754 on the macOS job that failed), which overflows the OS argument-list limit on both the Linux pytest and macOS jobs. Pass the file list via llvm-profdata's -f option (a newline-separated list file) instead of argv. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P53N6mt8mDNedgUDBcYqfW
Resolves a conflict in .github/workflows/pytest.yml between this branch's new "Install LLVM coverage toolchain" step and main's routine version-pin bump of rui314/setup-mold: keeps both.
The macOS job's "Uninstalling Homebrew" step removes gpg early on, same as it always has. The pre-existing "Upload test results to Codecov" step already sets skip_validation: true to work around this, but the two new coverage-upload steps this PR added did not, so codecov-action's own dependency check failed before it could upload anything. Match the existing step's skip_validation: true.
…d coverage Adds an opt-in GRASS_SANCOV_COVERAGE / WITH_SANCOV_COVERAGE path (Autotools and CMake) that instruments GRASS with -fsanitize-coverage= on GCC (trace-pc) or Clang (trace-pc-guard) and produces coverage dumps via a small custom runtime (utils/sancov_runtime.c) and symbolizer (utils/sancov_symbolize.py), alongside the existing clang source-based mechanism, for comparison. See doc/development/sancov_coverage_experiment.md for the investigation, measurements, and the resulting recommendation. Most of this was worked out through iterative, empirical debugging of real link and toolchain failures (documented inline): GCC's -fsanitize-coverage= does not implement trace-pc-guard, GNU ld's left-to-right link order breaks a naive LDFLAGS-based runtime link, CMake's install(EXPORT ...) rejects a non-exported link dependency, and so on. Substantial parts of this change, including the debugging process itself, were produced with AI assistance. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
Three matrix cells (llvm-source, sancov-gcc, sancov-clang) now share python-version 3.13, so names keyed only on matrix.python-version collide; actions/upload-artifact hard-fails on a duplicate name within one run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
…self/exe dladdr() reports the main executable's path exactly as it was exec()'d; for a program found via a PATH search (GRASS's db drivers, spawned by bare name) that is not a path addr2line can resolve later from an arbitrary working directory. Confirmed on a real db.connect coverage dump. A bare name can only be the main executable (every shared library path comes from the dynamic linker's own absolute bookkeeping), and /proc/self/exe always resolves to its real absolute path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
Mixing a base os/python-version/coverage cross product with include entries that only partially override it (just coverage, here) is a known GitHub Actions matrix pitfall: an include entry that does not exactly match an existing combination on every key it names is added as a new, separate combination instead of overriding the intended one. Confirmed on a real workflow_dispatch run of the previous version: the "pytest" job's instance list came back completely empty, so the whole matrix silently built and tested nothing. Switched to an include-only matrix where every entry is a complete, independent combination. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
Three matrix cells (llvm-source, sancov-gcc, sancov-clang) share python-version 3.13; a concurrency group keyed only on that collides across them, and cancel-in-progress: true cancels whichever job shares a group with one that just started. Confirmed on a real workflow_dispatch run: two of the three 3.13 cells were cancelled within a second of starting, leaving only one to actually run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
Covers the runtime/symbolizer design, the real build-system bugs found and fixed along the way (GCC lacking trace-pc-guard, GNU ld link order, CMake's install(EXPORT) requirement, a dladdr() bare-path bug), and the macro-attribution demonstration with real generated output. Sections 2 and 4 (CI timing numbers and bottom-line recommendation) are placeholders pending real GitHub Actions runs of this branch, in progress. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
…age cells independent Confirmed on a real workflow_dispatch run: GRASS's own $(MATHLIB) -- normally "-lm" -- comes out empty on this toolchain, because glibc merged libm into libc (since 2.34; Ubuntu 24.04 ships 2.39) so configure's "does atan link without -lm" probe already succeeds. GNU ld tolerates this by transitively finding cos/sin/floor in libc via an already-linked GRASS shared library; this workflow's mold (via rui314/setup-mold) does not, so ps.map's link failed with "undefined symbol: cos/sin/floor" under GRASS_SANCOV_CC=clang specifically (its wrapper script is the only thing free to add the missing library, so it now does). Also stopped that one cell's failure from cancelling the other three matrix cells mid-run (fail-fast: true, the default) -- they are independent comparisons, not four attempts at the same build, so losing their timing data to an unrelated failure defeats the point of running them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
Confirmed on a real CI run: a test that builds a GRASS addon into a temporary directory via g.extension has that directory cleaned up once the test finishes, well before this post-test symbolization step runs, so the addon binary recorded as a module is gone by the time addr2line would need it. Skip it with a warning instead of letting one vanished module abort the whole run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
Fills in the build-time/test-time comparison (Section 2) and bottom-line recommendation (Section 4) with real GitHub Actions run data, and documents the five real, CI-only bugs found and fixed getting there (Section 1.5): a matrix include pitfall producing zero job instances, a concurrency-group collision cancelling sibling cells, a missing -lm under mold specific to the clang+sancov link, a dladdr() bare-path bug, and a vanished-module bug in the symbolizer. The headline result: GCC's trace-pc mechanism (the only edge-level -fsanitize-coverage= sub-option GCC implements) adds +232% test time under cmake.yml's unrestricted OpenMP threading (2h02m vs 36m49s baseline), consistent with its mutex-guarded per-edge-execution dedup needed because GCC provides no per-edge guard word the way clang's trace-pc-guard does. Neither compiler's SanitizerCoverage path offers a granularity advantage over the existing GRASS_LLVM_COVERAGE mechanism, which correctly distinguishes macro expansions at block level that SanitizerCoverage collapses (Section 3.1). Bottom line: not recommended as an addition to GRASS's coverage tooling on either compiler. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
| # export set too, which it has no reason to be part of (confirmed | ||
| # empirically: CMake's install(EXPORT ...) step refuses to generate | ||
| # otherwise). An IMPORTED library is exempt from that requirement. | ||
| set(SANCOV_RUNTIME_LIB "${CMAKE_BINARY_DIR}/libsancovrt${CMAKE_SHARED_LIBRARY_SUFFIX}") |
There was a problem hiding this comment.
[pre-commit] reported by reviewdog 🐶
| set(SANCOV_RUNTIME_LIB "${CMAKE_BINARY_DIR}/libsancovrt${CMAKE_SHARED_LIBRARY_SUFFIX}") | |
| set(SANCOV_RUNTIME_LIB | |
| "${CMAKE_BINARY_DIR}/libsancovrt${CMAKE_SHARED_LIBRARY_SUFFIX}" | |
| ) |
| COMMAND ${CMAKE_C_COMPILER} -O2 -fPIC -shared -pthread | ||
| -o ${SANCOV_RUNTIME_LIB} | ||
| ${CMAKE_SOURCE_DIR}/utils/sancov_runtime.c -l${CMAKE_DL_LIBS} |
There was a problem hiding this comment.
[pre-commit] reported by reviewdog 🐶
| COMMAND ${CMAKE_C_COMPILER} -O2 -fPIC -shared -pthread | |
| -o ${SANCOV_RUNTIME_LIB} | |
| ${CMAKE_SOURCE_DIR}/utils/sancov_runtime.c -l${CMAKE_DL_LIBS} | |
| COMMAND | |
| ${CMAKE_C_COMPILER} -O2 -fPIC -shared -pthread -o | |
| ${SANCOV_RUNTIME_LIB} ${CMAKE_SOURCE_DIR}/utils/sancov_runtime.c | |
| -l${CMAKE_DL_LIBS} |
| add_custom_target(sancovrt_build DEPENDS ${SANCOV_RUNTIME_LIB}) | ||
| add_library(sancovrt SHARED IMPORTED GLOBAL) | ||
| set_target_properties( | ||
| sancovrt PROPERTIES IMPORTED_LOCATION ${SANCOV_RUNTIME_LIB} |
There was a problem hiding this comment.
[pre-commit] reported by reviewdog 🐶
| sancovrt PROPERTIES IMPORTED_LOCATION ${SANCOV_RUNTIME_LIB} | |
| sancovrt | |
| PROPERTIES IMPORTED_LOCATION ${SANCOV_RUNTIME_LIB} |
| * So a bare (no '/') name can only be the main executable, and | ||
| * /proc/self/exe always resolves to its real absolute path. */ | ||
| if (!strchr(path, '/')) { | ||
| ssize_t n = readlink("/proc/self/exe", exe_path, sizeof(exe_path) - 1); |
There was a problem hiding this comment.
[pre-commit] reported by reviewdog 🐶
| ssize_t n = readlink("/proc/self/exe", exe_path, sizeof(exe_path) - 1); | |
| ssize_t n = | |
| readlink("/proc/self/exe", exe_path, sizeof(exe_path) - 1); |
ruff, clang-format (v22.1.5, pinned by pre-commit) and gersemi flagged utils/sancov_symbolize.py, utils/sancov_runtime.c and CMakeLists.txt; apply their formatting. actionlint's shellcheck pass flagged the unquoted, intentionally-optional GRASS_SANCOV_CMAKE_ARG expansion in cmake.yml (SC2086); silence it with a comment explaining why the variable stays unquoted. markdownlint flagged missing fenced-code-block languages and non-compact table separator rows in the experiment report; fix both. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
cmake.yml's gcc sancov job failed a gunittest test that builds a real addon via g.extension, with "cannot find -lsancovrt". Every exported GRASS::* library target records sancovrt (deliberately an IMPORTED, non-exported target -- install(EXPORT ...) refuses a real dependency that isn't itself exported) as a bare, unresolved name in its INTERFACE_LINK_LIBRARIES, and the addon's own separate CMake project, built via the installed etc/cmake/build_addon.cmake package, has nothing by that name to resolve it to. Define the same sancovrt IMPORTED target in GRASSConfig.cmake.in, loaded by every find_package(GRASS ...) including addon builds, so it points at the runtime .so this repository's own build already installs to GRASS_INSTALL_LIBDIR. Guarded by the build's own WITH_SANCOV_COVERAGE setting, so a non-coverage install defines nothing extra. Verified locally end to end: a WITH_SANCOV_COVERAGE=ON CMake build, installed, then `g.extension extension=r.gdd operation=add` -- the exact command cmake.yml's failing test runs -- now compiles and installs r.gdd successfully, links it against the installed libsancovrt.so, and running it produces a real coverage dump. Updates the experiment report's Section 1.5 accordingly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
| continue | ||
| resolved = addr2line_batch(module, sorted(offsets), args.addr2line) | ||
| for loc in resolved.values(): | ||
| if loc in ("??:0", "??:?"): |
There was a problem hiding this comment.
[ruff] reported by reviewdog 🐶
| if loc in ("??:0", "??:?"): | |
| if loc in {"??:0", "??:?"}: |
ruff's preview-mode PLR6201 rule (enabled by the CI lint job's --preview flag, not caught by a plain local `ruff check`) flags a tuple used only for `in` membership testing; a set is the right type for that and avoids a linear scan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
|
This is not this PR's failure: I don't have a fix to port (no related fix PR is open that I'm aware of). I'll re-run the failed job once the rest of that workflow run finishes (a sibling matrix job is still in progress, which is currently blocking a re-run) to confirm it's a flake, and report back. Generated by Claude Code |
|
This isn't a new bug — it's the same overhead the report's Section 2.2 already measured and quantified as the headline finding of this experiment: gcc's I'm not fixing this by raising the timeout: the 300s cutoff lives in Given this branch is explicitly exploratory (see the report's opening paragraph) and not meant to replace Generated by Claude Code |
Summary
Exploratory/investigative work comparing SanitizerCoverage (
-fsanitize-coverage=, on both GCC and Clang) against the existing clang source-based coverage mechanism (GRASS_LLVM_COVERAGE), forpytest.yml's Linux matrix and (more narrowly)cmake.yml. This is not a replacement forGRASS_LLVM_COVERAGE— it's a prototype plus a written report with a bottom-line recommendation, backed by real GitHub Actions runs.GRASS_SANCOV_COVERAGE(Autotools) /WITH_SANCOV_COVERAGE(CMake) path that instruments GRASS with-fsanitize-coverage=trace-pc(GCC) ortrace-pc-guard(Clang), and produces coverage dumps via a small custom runtime (utils/sancov_runtime.c, since neither compiler auto-links a runtime for this flag the way clang does for-fprofile-instr-generate) and a symbolizer (utils/sancov_symbolize.py, usingaddr2line).pytest.yml's Linux matrix to addsancov-gccandsancov-clangcells alongside the existingnoneandllvm-sourcecells, and adds the same opt-in path tocmake.yml.doc/development/sancov_coverage_experiment.md, including build/test timing deltas from real CI runs and a concrete macro-attribution demonstration (compiling the real_()gettext macro inlib/gis/parser_standard_options.cthree ways and comparing the generated coverage output).workflow_dispatchwas temporarily added to both workflows so this branch's cells could be run on demand without a PR; noted inline as removable before any eventual merge.Headline finding
GCC's
-fsanitize-coverage=does not implementtrace-pc-guard(onlytrace-cmp/trace-pc, confirmed empirically — contrary to widely-repeated claims). With no per-edge guard word available, the runtime's GCC path must dedup via a mutex-guarded hash-set insertion on every edge execution, not just the first. Undercmake.yml's real gunittest suite (run with GRASS's normal, unrestricted OpenMP threading), this measured +232% test time (2h02m05s vs 36m49s baseline) in a real CI run. Clang'strace-pc-guardpath has no such penalty, but its coverage output is strictly coarser than the existing source-based mechanism for macro-expansion attribution (demonstrated concretely in the report).Bottom line
Not recommended as an addition to GRASS's coverage tooling on either compiler, for the reasons above.
GRASS_LLVM_COVERAGEshould remain GRASS's coverage mechanism. This PR's value is the validated prototype and the documented findings, in case a future fuzzing use case (wheretrace-pc-guard's ABI is directly consumable by libFuzzer/AFL++) makes it worth revisiting.Test plan
GRASS_SANCOV_COVERAGE=1 GRASS_SANCOV_CC=gcc) built and installed end to end; ran a real GRASS tool and confirmed a real, symbolizable coverage dump.-DWITH_SANCOV_COVERAGE=ON) reached 3996/3997 build steps (the one failure is an unrelated, pre-existing sandbox gap:wxPythonnot installed).pytest.ymlworkflow_dispatchrun with all four matrix cells (none,llvm-source,sancov-gcc,sancov-clang) green, including symbolized SanitizerCoverage output for both sancov cells: https://github.com/echoix/grass/actions/runs/33689957509cmake.ymlworkflow_dispatchrun (none,gcc) providing the headline timing comparison: https://github.com/echoix/grass/actions/runs/33686891578g.extension-built addons under CMake's sancov path (documented as an open limitation in the report — the addon's out-of-tree build can't find the installed runtime library)🤖 Generated with Claude Code
https://claude.ai/code/session_01EFijpm8JqSEJfJ9BhJFeen
Generated by Claude Code