From 481d003b6dcaab08ab86fa785ae2649cf618e949 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 22:25:23 +0000 Subject: [PATCH 01/10] CI: enable LLVM source-based C/C++ coverage on macOS and Linux pytest 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. --- .github/workflows/apt.txt | 2 + .github/workflows/build_ubuntu-24.04.sh | 12 ++++ .github/workflows/macos.yml | 75 +++++++++++++++++++++++++ .github/workflows/macos_install.sh | 10 ++++ .github/workflows/pytest.yml | 70 +++++++++++++++-------- utils/coverage_disk_usage.sh | 29 ++++++++++ utils/llvm_coverage_export.sh | 59 +++++++++++++++++++ 7 files changed, 234 insertions(+), 23 deletions(-) create mode 100755 utils/coverage_disk_usage.sh create mode 100755 utils/llvm_coverage_export.sh diff --git a/.github/workflows/apt.txt b/.github/workflows/apt.txt index 50fc0fa8b84..814545569f1 100644 --- a/.github/workflows/apt.txt +++ b/.github/workflows/apt.txt @@ -1,5 +1,6 @@ bison build-essential +clang flex libbz2-dev libcairo-dev @@ -16,6 +17,7 @@ libproj-dev libreadline-dev libsvm-dev libzstd-dev +llvm pdal proj-bin python3-dateutil diff --git a/.github/workflows/build_ubuntu-24.04.sh b/.github/workflows/build_ubuntu-24.04.sh index 677c46d5f33..0b55bd7621c 100755 --- a/.github/workflows/build_ubuntu-24.04.sh +++ b/.github/workflows/build_ubuntu-24.04.sh @@ -30,6 +30,18 @@ set -u export INSTALL_PREFIX=$1 +# LLVM source-based coverage instrumentation, opt-in via env var. Requires +# clang (installed via apt.txt). Kept out of the default O2 path so +# non-coverage runs are unaffected. +if [ "${GRASS_LLVM_COVERAGE:-0}" = "1" ]; then + export CC="${CC:-clang}" + export CXX="${CXX:-clang++}" + COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -O0 -g" + export CFLAGS="${CFLAGS:-} ${COV_FLAGS}" + export CXXFLAGS="${CXXFLAGS:-} ${COV_FLAGS}" + export LDFLAGS="${LDFLAGS:-} -fprofile-instr-generate -fcoverage-mapping" +fi + ./configure \ --enable-largefile \ --prefix="$INSTALL_PREFIX/" \ diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 4aa66a8335a..72e87176cfc 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -23,6 +23,7 @@ jobs: runs-on: macos-26 env: PYTHONWARNINGS: always + GRASS_LLVM_COVERAGE: "1" steps: - name: Info run: | @@ -57,8 +58,11 @@ jobs: - name: Prepare Conda environment file env: test_dependencies: | + coverage ipython + llvm-tools pytest + pytest-cov pytest-github-actions-annotate-failures pytest-timeout pytest-xdist @@ -92,6 +96,18 @@ jobs: shell: micromamba-shell {0} run: source ./.github/workflows/print_versions.sh + - name: Prepare coverage directories + shell: micromamba-shell {0} + run: | + mkdir -p "${PWD}/llvm-cov-raw" + # %p (process id) separates every subprocess; %9m caps the per-binary + # pool at 512 files so long xdist runs don't fill the runner disk. + { + echo "LLVM_PROFILE_FILE=${PWD}/llvm-cov-raw/grass-%p-%9m.profraw" + echo "INITIAL_GISBASE=$(grass --config path)" + echo "INITIAL_PWD=${PWD}" + } >> "${GITHUB_ENV}" + - name: Run pytest with multiple workers in parallel shell: micromamba-shell {0} run: | @@ -101,6 +117,7 @@ jobs: export LD_LIBRARY_PATH pytest \ @.github/workflows/pytest_args_ci.txt \ + @.github/workflows/pytest_args_cov.txt \ @.github/workflows/pytest_args_parallel.txt \ --junitxml=pytest.xdist.junit.xml \ -k 'not testsuite' @@ -116,6 +133,7 @@ jobs: export LD_LIBRARY_PATH pytest \ @.github/workflows/pytest_args_ci.txt \ + @.github/workflows/pytest_args_cov.txt \ @.github/workflows/pytest_args_not_parallel.txt \ --junitxml=pytest.needs_solo_run.junit.xml \ -k 'not testsuite' @@ -127,6 +145,7 @@ jobs: export PYTHONPATH export LD_LIBRARY_PATH pytest \ + @.github/workflows/pytest_args_cov.txt \ @.github/workflows/pytest_args_gunittest.txt \ --junitxml=pytest.gunittest.junit.xml @@ -171,6 +190,62 @@ jobs: SAMPLE_DATA_URL: "file://${{ github.workspace }}/sample-data/\ nc_spm_full_v2beta1.tar.gz" + - name: Coverage disk usage (before combine) + if: ${{ !cancelled() }} + shell: micromamba-shell {0} + run: utils/coverage_disk_usage.sh + + - name: Combine Python coverage and export XML + if: ${{ !cancelled() }} + shell: micromamba-shell {0} + run: | + # coverage combine before coverage_mapper.py: parallel mode leaves + # only .coverage... partials, no .coverage yet. + coverage combine || true + if [ -f .coverage ]; then + python utils/coverage_mapper.py + if [ -f .coverage.fixed_scripts ]; then + mv .coverage.fixed_scripts .coverage + fi + coverage html + coverage xml -o coverage.xml + else + echo "No .coverage data collected; skipping Python coverage export." + fi + + - name: Merge and export LLVM C/C++ coverage + if: ${{ !cancelled() }} + shell: micromamba-shell {0} + run: utils/llvm_coverage_export.sh + + - name: Upload Python coverage to Codecov (macOS) + if: ${{ !cancelled() }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + verbose: true + files: coverage.xml + flags: macos-pytest-python + name: macos-pytest-python + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload LLVM C/C++ coverage to Codecov (macOS) + if: ${{ !cancelled() }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + verbose: true + files: coverage-llvm.lcov + flags: macos-llvm-c + name: macos-llvm-c + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Make python-only code coverage test report available + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-codecoverage-report-macOS + path: coverage_html_report + retention-days: 1 + - name: Make HTML test report available if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/macos_install.sh b/.github/workflows/macos_install.sh index 08cf05cd82c..5b06daa06a9 100755 --- a/.github/workflows/macos_install.sh +++ b/.github/workflows/macos_install.sh @@ -64,6 +64,16 @@ export CFLAGS="-O2 -pipe -ffp-contract=off -arch ${CONDA_ARCH} -DGL_SILENCE_DEPR export CXXFLAGS="-O2 -pipe -ffp-contract=off -stdlib=libc++ -arch ${CONDA_ARCH} -Wall -Wextra -Wpedantic" export CPPFLAGS="-isystem${CONDA_PREFIX}/include" +# LLVM source-based coverage instrumentation, opt-in via env var. +# -O0 keeps region mapping accurate; overhead is small compared to gcov. +if [ "${GRASS_LLVM_COVERAGE:-0}" = "1" ]; then + COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -O0 -g" + CFLAGS="${CFLAGS} ${COV_FLAGS}" + CXXFLAGS="${CXXFLAGS} ${COV_FLAGS}" + LDFLAGS="${LDFLAGS:-} -fprofile-instr-generate -fcoverage-mapping" + export CFLAGS CXXFLAGS LDFLAGS +fi + ./configure $CONFIGURE_FLAGS EXEMPT="" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 593434e294c..084d6022070 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -32,6 +32,9 @@ jobs: FORCE_COLOR: 1 # for software including pip: https://force-color.org/ CLICOLOR_FORCE: 1 # for other software including ninja: https://bixense.com/clicolors/ PYTHONWARNINGS: always + GRASS_LLVM_COVERAGE: "1" + CC: clang + CXX: clang++ steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -90,16 +93,25 @@ jobs: - name: Test executing of the grass command run: .github/workflows/test_simple.sh + - name: Prepare coverage directories + run: | + mkdir -p "${PWD}/llvm-cov-raw" + # %p (process id) separates every subprocess; %9m caps the per-binary + # pool at 512 files so long suites don't create millions of tiny + # .profraw. Together they eliminate the write-collision that made + # earlier runs non-deterministic. + { + echo "LLVM_PROFILE_FILE=${PWD}/llvm-cov-raw/grass-%p-%9m.profraw" + echo "INITIAL_GISBASE=$(grass --config path)" + echo "INITIAL_PWD=${PWD}" + } >> "${GITHUB_ENV}" + - name: Run pytest with multiple workers in parallel run: | PYTHONPATH="$(grass --config python_path):${PYTHONPATH}" LD_LIBRARY_PATH="$(grass --config path)/lib:${LD_LIBRARY_PATH}" - INITIAL_GISBASE="$(grass --config path)" - INITIAL_PWD="${PWD}" export PYTHONPATH export LD_LIBRARY_PATH - export INITIAL_GISBASE - export INITIAL_PWD pytest \ @.github/workflows/pytest_args_ci.txt \ @.github/workflows/pytest_args_cov.txt \ @@ -113,12 +125,8 @@ jobs: run: | PYTHONPATH="$(grass --config python_path):${PYTHONPATH}" LD_LIBRARY_PATH="$(grass --config path)/lib:${LD_LIBRARY_PATH}" - INITIAL_GISBASE="$(grass --config path)" - INITIAL_PWD="${PWD}" export PYTHONPATH export LD_LIBRARY_PATH - export INITIAL_GISBASE - export INITIAL_PWD pytest \ @.github/workflows/pytest_args_ci.txt \ @.github/workflows/pytest_args_cov.txt \ @@ -129,29 +137,34 @@ jobs: run: | PYTHONPATH="$(grass --config python_path):${PYTHONPATH}" LD_LIBRARY_PATH="$(grass --config path)/lib:${LD_LIBRARY_PATH}" - INITIAL_GISBASE="$(grass --config path)" - INITIAL_PWD="${PWD}" export PYTHONPATH export LD_LIBRARY_PATH - export INITIAL_GISBASE - export INITIAL_PWD pytest \ @.github/workflows/pytest_args_cov.txt \ @.github/workflows/pytest_args_gunittest.txt \ --junitxml=pytest.gunittest.junit.xml - - name: Fix non-standard installed script paths in coverage data + + - name: Coverage disk usage (before combine) + if: ${{ !cancelled() }} + run: utils/coverage_disk_usage.sh + + - name: Combine Python coverage and export XML + if: ${{ !cancelled() }} run: | - PYTHONPATH="$(grass --config python_path):${PYTHONPATH}" - LD_LIBRARY_PATH="$(grass --config path)/lib:${LD_LIBRARY_PATH}" - INITIAL_GISBASE="$(grass --config path)" - INITIAL_PWD="${PWD}" - export PYTHONPATH - export LD_LIBRARY_PATH - export INITIAL_GISBASE - export INITIAL_PWD - python utils/coverage_mapper.py + # coverage combine must run before coverage_mapper.py: parallel + # mode leaves .coverage... partials and no + # .coverage file for the mapper to read. coverage combine + python utils/coverage_mapper.py + if [ -f .coverage.fixed_scripts ]; then + mv .coverage.fixed_scripts .coverage + fi coverage html + coverage xml -o coverage.xml + + - name: Merge and export LLVM C/C++ coverage + if: ${{ !cancelled() }} + run: utils/llvm_coverage_export.sh - name: Upload test results to Codecov if: ${{ !cancelled() }} @@ -162,13 +175,24 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} report_type: test_results - - name: Upload coverage reports to Codecov + - name: Upload Python coverage to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: verbose: true + files: coverage.xml flags: pytest-python-${{ matrix.python-version }} name: pytest-python-${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload LLVM C/C++ coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + verbose: true + files: coverage-llvm.lcov + flags: ubuntu-llvm-c + name: ubuntu-llvm-c-${{ matrix.python-version }} + token: ${{ secrets.CODECOV_TOKEN }} - name: Make python-only code coverage test report available if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/utils/coverage_disk_usage.sh b/utils/coverage_disk_usage.sh new file mode 100755 index 00000000000..f12a5c59c85 --- /dev/null +++ b/utils/coverage_disk_usage.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Print disk usage of raw coverage artifacts before combining. Emitted to +# the CI log so we can watch it approach the runner storage budget. + +set -u + +: "${INITIAL_PWD:=$(pwd)}" +: "${RAW_DIR:=${INITIAL_PWD}/llvm-cov-raw}" + +echo "=== LLVM .profraw files ===" +if [ -d "${RAW_DIR}" ]; then + count=$(find "${RAW_DIR}" -name '*.profraw' -type f | wc -l | tr -d ' ') + echo "count: ${count}" + du -sh "${RAW_DIR}" 2>/dev/null || true +else + echo "no ${RAW_DIR} (build was not instrumented, or no tests ran)" +fi + +echo "=== Python coverage.py partial files ===" +py_count=$(find "${INITIAL_PWD}" -maxdepth 2 -name '.coverage.*' -type f 2>/dev/null \ + | wc -l | tr -d ' ') +echo "count: ${py_count}" +if [ "${py_count}" -gt 0 ]; then + du -sch "${INITIAL_PWD}"/.coverage.* 2>/dev/null | tail -1 +fi + +echo "=== Filesystem ===" +df -h "${INITIAL_PWD}" 2>/dev/null || df -h diff --git a/utils/llvm_coverage_export.sh b/utils/llvm_coverage_export.sh new file mode 100755 index 00000000000..87f34ae676c --- /dev/null +++ b/utils/llvm_coverage_export.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +# Merge LLVM source-based coverage .profraw files into a single .profdata, +# then export lcov for Codecov. Invoked from CI after all test steps. +# +# Inputs (env): +# INITIAL_PWD - repository working directory (must match .coveragerc) +# RAW_DIR - directory holding *.profraw (default: $INITIAL_PWD/llvm-cov-raw) +# OUT_PROFDATA - merged profdata output (default: $INITIAL_PWD/grass.profdata) +# OUT_LCOV - lcov output for Codecov (default: $INITIAL_PWD/coverage-llvm.lcov) +# GISBASE - installed GRASS prefix; falls back to `grass --config path` +# +# Requires llvm-profdata and llvm-cov on PATH matching the clang used to build. + +set -euo pipefail + +: "${INITIAL_PWD:=$(pwd)}" +: "${RAW_DIR:=${INITIAL_PWD}/llvm-cov-raw}" +: "${OUT_PROFDATA:=${INITIAL_PWD}/grass.profdata}" +: "${OUT_LCOV:=${INITIAL_PWD}/coverage-llvm.lcov}" +: "${GISBASE:=$(grass --config path)}" + +if ! compgen -G "${RAW_DIR}/*.profraw" > /dev/null; then + echo "llvm_coverage_export: no .profraw files under ${RAW_DIR}; nothing to export." + : > "${OUT_LCOV}" + exit 0 +fi + +# -sparse drops zero-count regions so merges are smaller and deterministic +# for a given input set. +llvm-profdata merge -sparse -o "${OUT_PROFDATA}" "${RAW_DIR}"/*.profraw + +# Enumerate every instrumented binary and shared library the install produced. +# On macOS the tools are Mach-O executables plus *.dylib; on Linux ELF plus *.so. +OBJ_ARGS=() +while IFS= read -r -d '' obj; do + OBJ_ARGS+=(-object "${obj}") +done < <( + find "${GISBASE}" \ + \( -type f -perm -u+x \ + -o -name '*.dylib' -o -name '*.so' -o -name '*.so.*' \) \ + -print0 +) + +if [ "${#OBJ_ARGS[@]}" -eq 0 ]; then + echo "llvm_coverage_export: no instrumented objects found under ${GISBASE}." >&2 + exit 1 +fi + +# Drop system, conda, and build-tree paths so Codecov only sees repo files. +llvm-cov export \ + -format=lcov \ + -instr-profile="${OUT_PROFDATA}" \ + -ignore-filename-regex='(^/opt/|^/usr/|/conda-|/miniconda|/micromamba|/\.cache/|/bin\.[^/]+/|/dist\.[^/]+/|/OBJ\.[^/]+/)' \ + "${OBJ_ARGS[@]}" \ + > "${OUT_LCOV}" + +echo "llvm_coverage_export: wrote ${OUT_LCOV}" +du -sh "${OUT_PROFDATA}" "${OUT_LCOV}" From 2de6b015aa0899700083f6bc06961301fc00bb0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 22:41:18 +0000 Subject: [PATCH 02/10] CI: gitignore LLVM coverage artifacts and wipe build-time profraw 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. --- .github/workflows/macos.yml | 4 ++++ .github/workflows/pytest.yml | 4 ++++ .gitignore | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 72e87176cfc..e76e81eed8c 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -100,6 +100,10 @@ jobs: shell: micromamba-shell {0} run: | mkdir -p "${PWD}/llvm-cov-raw" + # Wipe stray default.profraw the build step left behind (Makefiles + # run instrumented tools to dump interface XML) before the tests + # start writing controlled per-PID files. + find . -name 'default.profraw' -type f -delete # %p (process id) separates every subprocess; %9m caps the per-binary # pool at 512 files so long xdist runs don't fill the runner disk. { diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 084d6022070..0b96aec95e7 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -96,6 +96,10 @@ jobs: - name: Prepare coverage directories run: | mkdir -p "${PWD}/llvm-cov-raw" + # Wipe stray default.profraw the build step left behind (Makefiles + # run instrumented tools to dump interface XML), so their build- + # time data can't contaminate the test-run coverage. + find . -name 'default.profraw' -type f -delete # %p (process id) separates every subprocess; %9m caps the per-binary # pool at 512 files so long suites don't create millions of tiny # .profraw. Together they eliminate the write-collision that made diff --git a/.gitignore b/.gitignore index fd8c521b7c6..b4bcef7f870 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,11 @@ lib/*/latex/ .coverage .coverage.* coverage.xml +*.profraw +*.profdata +coverage-llvm.lcov +llvm-cov-raw/ +coverage_html_report/ # Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos # Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos From baf475d957ffe44bdeeba8ca16c7c61bbcb58b5e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 22:50:04 +0000 Subject: [PATCH 03/10] CI: fix LLVM coverage non-determinism in OpenMP-parallel regions 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. --- .github/workflows/build_ubuntu-24.04.sh | 5 ++++- .github/workflows/macos_install.sh | 5 ++++- utils/llvm_coverage_export.sh | 9 ++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_ubuntu-24.04.sh b/.github/workflows/build_ubuntu-24.04.sh index 0b55bd7621c..ef9d9865904 100755 --- a/.github/workflows/build_ubuntu-24.04.sh +++ b/.github/workflows/build_ubuntu-24.04.sh @@ -36,7 +36,10 @@ export INSTALL_PREFIX=$1 if [ "${GRASS_LLVM_COVERAGE:-0}" = "1" ]; then export CC="${CC:-clang}" export CXX="${CXX:-clang++}" - COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -O0 -g" + # -fprofile-update=atomic makes coverage counter increments atomic; + # required for GRASS's OpenMP-parallel regions (e.g. r.univar) so + # threads racing on shared counters do not lose updates. + COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g" export CFLAGS="${CFLAGS:-} ${COV_FLAGS}" export CXXFLAGS="${CXXFLAGS:-} ${COV_FLAGS}" export LDFLAGS="${LDFLAGS:-} -fprofile-instr-generate -fcoverage-mapping" diff --git a/.github/workflows/macos_install.sh b/.github/workflows/macos_install.sh index 5b06daa06a9..a193e79344c 100755 --- a/.github/workflows/macos_install.sh +++ b/.github/workflows/macos_install.sh @@ -66,8 +66,11 @@ export CPPFLAGS="-isystem${CONDA_PREFIX}/include" # LLVM source-based coverage instrumentation, opt-in via env var. # -O0 keeps region mapping accurate; overhead is small compared to gcov. +# -fprofile-update=atomic makes coverage counter increments atomic; without +# it, OpenMP parallel regions (e.g. r.univar's process_raster) lose counter +# updates across threads and the merged .profdata is non-deterministic. if [ "${GRASS_LLVM_COVERAGE:-0}" = "1" ]; then - COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -O0 -g" + COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g" CFLAGS="${CFLAGS} ${COV_FLAGS}" CXXFLAGS="${CXXFLAGS} ${COV_FLAGS}" LDFLAGS="${LDFLAGS:-} -fprofile-instr-generate -fcoverage-mapping" diff --git a/utils/llvm_coverage_export.sh b/utils/llvm_coverage_export.sh index 87f34ae676c..30434ba90b5 100755 --- a/utils/llvm_coverage_export.sh +++ b/utils/llvm_coverage_export.sh @@ -34,7 +34,14 @@ llvm-profdata merge -sparse -o "${OUT_PROFDATA}" "${RAW_DIR}"/*.profraw # On macOS the tools are Mach-O executables plus *.dylib; on Linux ELF plus *.so. OBJ_ARGS=() while IFS= read -r -d '' obj; do - OBJ_ARGS+=(-object "${obj}") + # Filter to real binaries (ELF on Linux, Mach-O on macOS). find -perm -u+x + # sweeps in executable Python and shell scripts, which llvm-cov rejects. + magic=$(head -c4 "${obj}" 2>/dev/null | od -An -tx1 | tr -d ' \n') + case "${magic}" in + 7f454c46) OBJ_ARGS+=(-object "${obj}") ;; # ELF + cffaedfe|cafebabe|feedface|feedfacf) \ + OBJ_ARGS+=(-object "${obj}") ;; # Mach-O + esac done < <( find "${GISBASE}" \ \( -type f -perm -u+x \ From 31a67473ce8c713d8da60c18319d49427b6ad53a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 23:03:29 +0000 Subject: [PATCH 04/10] CI: drop -O0 -g from LLVM coverage build; keep -O2 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). --- .github/workflows/build_ubuntu-24.04.sh | 20 ++++++++++++++------ .github/workflows/macos_install.sh | 14 ++++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build_ubuntu-24.04.sh b/.github/workflows/build_ubuntu-24.04.sh index ef9d9865904..cc0a69c21e6 100755 --- a/.github/workflows/build_ubuntu-24.04.sh +++ b/.github/workflows/build_ubuntu-24.04.sh @@ -36,12 +36,20 @@ export INSTALL_PREFIX=$1 if [ "${GRASS_LLVM_COVERAGE:-0}" = "1" ]; then export CC="${CC:-clang}" export CXX="${CXX:-clang++}" - # -fprofile-update=atomic makes coverage counter increments atomic; - # required for GRASS's OpenMP-parallel regions (e.g. r.univar) so - # threads racing on shared counters do not lose updates. - COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g" - export CFLAGS="${CFLAGS:-} ${COV_FLAGS}" - export CXXFLAGS="${CXXFLAGS:-} ${COV_FLAGS}" + # LLVM source-based coverage generates its region mapping before the + # optimizer runs, so optimization does not affect coverage quality + # (clang doc: SourceBasedCodeCoverage, "Impact of llvm optimizations + # on coverage reports"). Keep the project's normal -O2 to hold the + # runtime impact small. -fprofile-update=atomic makes counter + # increments atomic; required for GRASS's OpenMP-parallel regions + # (e.g. r.univar) so threads racing on shared counters do not lose + # updates. + # + # Setting CFLAGS explicitly at configure time suppresses autoconf's + # default "-g -O2", so pass -O2 here. + COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic" + export CFLAGS="${CFLAGS:--O2} ${COV_FLAGS}" + export CXXFLAGS="${CXXFLAGS:--O2} ${COV_FLAGS}" export LDFLAGS="${LDFLAGS:-} -fprofile-instr-generate -fcoverage-mapping" fi diff --git a/.github/workflows/macos_install.sh b/.github/workflows/macos_install.sh index a193e79344c..2ad59f2f1d8 100755 --- a/.github/workflows/macos_install.sh +++ b/.github/workflows/macos_install.sh @@ -64,13 +64,15 @@ export CFLAGS="-O2 -pipe -ffp-contract=off -arch ${CONDA_ARCH} -DGL_SILENCE_DEPR export CXXFLAGS="-O2 -pipe -ffp-contract=off -stdlib=libc++ -arch ${CONDA_ARCH} -Wall -Wextra -Wpedantic" export CPPFLAGS="-isystem${CONDA_PREFIX}/include" -# LLVM source-based coverage instrumentation, opt-in via env var. -# -O0 keeps region mapping accurate; overhead is small compared to gcov. -# -fprofile-update=atomic makes coverage counter increments atomic; without -# it, OpenMP parallel regions (e.g. r.univar's process_raster) lose counter -# updates across threads and the merged .profdata is non-deterministic. +# LLVM source-based coverage instrumentation, opt-in via env var. The +# coverage-mapping section is generated before the LLVM optimizer runs, so +# optimization does not affect coverage quality (per clang's +# SourceBasedCodeCoverage docs) - keep the project's -O2. -fprofile-update= +# atomic makes counter increments atomic; without it, OpenMP parallel +# regions (e.g. r.univar's process_raster) lose counter updates across +# threads and the merged .profdata is non-deterministic. if [ "${GRASS_LLVM_COVERAGE:-0}" = "1" ]; then - COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g" + COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic" CFLAGS="${CFLAGS} ${COV_FLAGS}" CXXFLAGS="${CXXFLAGS} ${COV_FLAGS}" LDFLAGS="${LDFLAGS:-} -fprofile-instr-generate -fcoverage-mapping" From 4746645a040491d6eeb8dc0d8f658a235aeef5d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 23:41:35 +0000 Subject: [PATCH 05/10] CI: point configure at clang's omp.h when coverage build uses clang 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//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. --- .github/workflows/apt.txt | 1 + .github/workflows/build_ubuntu-24.04.sh | 13 ++++++++++++- .github/workflows/pytest.yml | 20 +++++++++++++------- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/apt.txt b/.github/workflows/apt.txt index 814545569f1..45d1a262994 100644 --- a/.github/workflows/apt.txt +++ b/.github/workflows/apt.txt @@ -15,6 +15,7 @@ libpdal-dev libpng-dev libproj-dev libreadline-dev +libomp-dev libsvm-dev libzstd-dev llvm diff --git a/.github/workflows/build_ubuntu-24.04.sh b/.github/workflows/build_ubuntu-24.04.sh index cc0a69c21e6..58648cadce8 100755 --- a/.github/workflows/build_ubuntu-24.04.sh +++ b/.github/workflows/build_ubuntu-24.04.sh @@ -30,6 +30,9 @@ set -u export INSTALL_PREFIX=$1 +# Extra configure args added under coverage; keep declared under `set -u`. +OPENMP_ARGS=() + # LLVM source-based coverage instrumentation, opt-in via env var. Requires # clang (installed via apt.txt). Kept out of the default O2 path so # non-coverage runs are unaffected. @@ -51,6 +54,13 @@ if [ "${GRASS_LLVM_COVERAGE:-0}" = "1" ]; then export CFLAGS="${CFLAGS:--O2} ${COV_FLAGS}" export CXXFLAGS="${CXXFLAGS:--O2} ${COV_FLAGS}" export LDFLAGS="${LDFLAGS:-} -fprofile-instr-generate -fcoverage-mapping" + # Clang ships omp.h in its resource-dir instead of /usr/include, so + # configure's plain omp.h check misses it. Point configure at both + # the include and (from apt libomp-dev) the runtime lib. + CLANG_RES_DIR="$("${CC}" -print-resource-dir 2>/dev/null || true)" + if [ -n "${CLANG_RES_DIR}" ] && [ -d "${CLANG_RES_DIR}/include" ]; then + OPENMP_ARGS+=("--with-openmp-includes=${CLANG_RES_DIR}/include") + fi fi ./configure \ @@ -73,7 +83,8 @@ fi --with-readline \ --with-sqlite \ --with-tiff \ - --with-zstd + --with-zstd \ + ${OPENMP_ARGS[@]+"${OPENMP_ARGS[@]}"} eval $makecmd make install diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 0b96aec95e7..dedcfbaad78 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -157,14 +157,20 @@ jobs: run: | # coverage combine must run before coverage_mapper.py: parallel # mode leaves .coverage... partials and no - # .coverage file for the mapper to read. - coverage combine - python utils/coverage_mapper.py - if [ -f .coverage.fixed_scripts ]; then - mv .coverage.fixed_scripts .coverage + # .coverage file for the mapper to read. Tolerate "no data" so + # a build failure earlier in the job does not mask itself as a + # later failure here. + coverage combine || true + if [ -f .coverage ]; then + python utils/coverage_mapper.py + if [ -f .coverage.fixed_scripts ]; then + mv .coverage.fixed_scripts .coverage + fi + coverage html + coverage xml -o coverage.xml + else + echo "No .coverage data collected; skipping Python coverage export." fi - coverage html - coverage xml -o coverage.xml - name: Merge and export LLVM C/C++ coverage if: ${{ !cancelled() }} From 35bf0fe42cdba31721ac608800675faf184830b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 00:31:02 +0000 Subject: [PATCH 06/10] CI: point configure at clang's libomp path so tools link with -lomp 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= 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. --- .github/workflows/build_ubuntu-24.04.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/build_ubuntu-24.04.sh b/.github/workflows/build_ubuntu-24.04.sh index 58648cadce8..86f5a57be59 100755 --- a/.github/workflows/build_ubuntu-24.04.sh +++ b/.github/workflows/build_ubuntu-24.04.sh @@ -60,6 +60,16 @@ if [ "${GRASS_LLVM_COVERAGE:-0}" = "1" ]; then CLANG_RES_DIR="$("${CC}" -print-resource-dir 2>/dev/null || true)" if [ -n "${CLANG_RES_DIR}" ] && [ -d "${CLANG_RES_DIR}/include" ]; then OPENMP_ARGS+=("--with-openmp-includes=${CLANG_RES_DIR}/include") + # Debian's libomp-dev installs libomp.so under /usr/lib/llvm-/lib, + # not the linker's default search path, so configure's link probe for + # omp_get_num_threads fails and it falls back to -lgomp - which clang's + # OpenMP-instrumented code (using __kmpc_*) then can't link against. + # The resource-dir sits at /clang/, so its + # grandparent is , where libomp.so lives. + LLVM_LIB_DIR="$(cd "${CLANG_RES_DIR}/../.." && pwd)" + if [ -e "${LLVM_LIB_DIR}/libomp.so" ]; then + OPENMP_ARGS+=("--with-openmp-libs=${LLVM_LIB_DIR}") + fi fi fi From b74940ae7cc5466fc91ae8d47dad18b778f97b85 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 00:42:30 +0000 Subject: [PATCH 07/10] CI: install clang/llvm/libomp only when LLVM coverage is on 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. --- .github/workflows/apt.txt | 3 --- .github/workflows/pytest.yml | 6 ++++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/apt.txt b/.github/workflows/apt.txt index 45d1a262994..50fc0fa8b84 100644 --- a/.github/workflows/apt.txt +++ b/.github/workflows/apt.txt @@ -1,6 +1,5 @@ bison build-essential -clang flex libbz2-dev libcairo-dev @@ -15,10 +14,8 @@ libpdal-dev libpng-dev libproj-dev libreadline-dev -libomp-dev libsvm-dev libzstd-dev -llvm pdal proj-bin python3-dateutil diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index dedcfbaad78..904965a75c0 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -57,6 +57,12 @@ jobs: xargs -a <(awk '! /^ *(#|$)/' ".github/workflows/apt.txt") -r -- \ sudo apt-get install -y --no-install-recommends --no-install-suggests + - name: Install LLVM coverage toolchain + if: env.GRASS_LLVM_COVERAGE == '1' + run: | + sudo apt-get install -y --no-install-recommends --no-install-suggests \ + clang llvm libomp-dev + - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - name: Install Python dependencies From 99ac2e3b171abb6d5d756d828d962bd4f9923ca9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 00:44:18 +0000 Subject: [PATCH 08/10] CI: only run LLVM C coverage on the latest Python matrix cell 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. --- .github/workflows/pytest.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 904965a75c0..b1bdd900c72 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -32,9 +32,9 @@ jobs: FORCE_COLOR: 1 # for software including pip: https://force-color.org/ CLICOLOR_FORCE: 1 # for other software including ninja: https://bixense.com/clicolors/ PYTHONWARNINGS: always - GRASS_LLVM_COVERAGE: "1" - CC: clang - CXX: clang++ + # Only exercise LLVM C coverage on the latest Python; the older one + # runs the default gcc/no-instrumentation build for speed. + GRASS_LLVM_COVERAGE: ${{ matrix.python-version == '3.13' && '1' || '0' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -179,7 +179,7 @@ jobs: fi - name: Merge and export LLVM C/C++ coverage - if: ${{ !cancelled() }} + if: ${{ !cancelled() && env.GRASS_LLVM_COVERAGE == '1' }} run: utils/llvm_coverage_export.sh - name: Upload test results to Codecov @@ -201,7 +201,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Upload LLVM C/C++ coverage to Codecov - if: ${{ !cancelled() }} + if: ${{ !cancelled() && env.GRASS_LLVM_COVERAGE == '1' }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: verbose: true From 98f2e778c582b98177aa7d14e3288acb0164b5b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:40:20 +0000 Subject: [PATCH 09/10] CI: fix llvm-profdata "Argument list too long" in coverage export 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 Claude-Session: https://claude.ai/code/session_01P53N6mt8mDNedgUDBcYqfW --- utils/llvm_coverage_export.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/utils/llvm_coverage_export.sh b/utils/llvm_coverage_export.sh index 30434ba90b5..ed5ed9c485b 100755 --- a/utils/llvm_coverage_export.sh +++ b/utils/llvm_coverage_export.sh @@ -27,8 +27,15 @@ if ! compgen -G "${RAW_DIR}/*.profraw" > /dev/null; then fi # -sparse drops zero-count regions so merges are smaller and deterministic -# for a given input set. -llvm-profdata merge -sparse -o "${OUT_PROFDATA}" "${RAW_DIR}"/*.profraw +# for a given input set. The per-process "%p-%9m" naming can produce +# hundreds of thousands of .profraw files in a full test run, so pass them +# via -f (a newline-separated list) rather than shell-globbing them onto +# argv, which overflows the OS argument-list limit ("Argument list too +# long"). +PROFRAW_LIST="$(mktemp)" +trap 'rm -f "${PROFRAW_LIST}"' EXIT +find "${RAW_DIR}" -name '*.profraw' -print > "${PROFRAW_LIST}" +llvm-profdata merge -sparse -f "${PROFRAW_LIST}" -o "${OUT_PROFDATA}" # Enumerate every instrumented binary and shared library the install produced. # On macOS the tools are Mach-O executables plus *.dylib; on Linux ELF plus *.so. From 8a917ce2252fac48823069fdb3dcc960f3e05bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:24:36 +0000 Subject: [PATCH 10/10] CI: skip gpg validation for macOS coverage Codecov uploads 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. --- .github/workflows/macos.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index e76e81eed8c..81d34baaf0d 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -231,6 +231,7 @@ jobs: flags: macos-pytest-python name: macos-pytest-python token: ${{ secrets.CODECOV_TOKEN }} + skip_validation: true - name: Upload LLVM C/C++ coverage to Codecov (macOS) if: ${{ !cancelled() }} @@ -241,6 +242,7 @@ jobs: flags: macos-llvm-c name: macos-llvm-c token: ${{ secrets.CODECOV_TOKEN }} + skip_validation: true - name: Make python-only code coverage test report available if: ${{ !cancelled() }}