Skip to content

Commit 4a85b8b

Browse files
committed
dev: Add LLVM source-based code coverage support
Add support for LLVM source-based code coverage instrumentation, enabling developers and CI to generate coverage reports for the Core Lightning codebase. Build System: - Add coverage-clang-collect and coverage-clang-report Makefile targets - Fix missing endif for PYTEST_TESTS conditional - Respect CARGO_TARGET_DIR environment variable for Rust builds Coverage Scripts (contrib/coverage/): - collect-coverage.sh: Merges .profraw files with validation and batching - generate-coverage-report.sh: Generates HTML reports using llvm-cov CI Workflow: - coverage.yaml: Daily workflow for building, testing, and publishing reports Usage: ./configure --enable-coverage CC=clang make -j$(nproc) CLN_COVERAGE_DIR=/tmp/cln-coverage make pytest make coverage-clang Changelog-Changed: Added LLVM source-based code coverage support with CI integration
1 parent 96adac4 commit 4a85b8b

7 files changed

Lines changed: 273 additions & 8 deletions

File tree

.github/workflows/coverage.yaml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
name: CLN static dev resources
3+
on:
4+
schedule:
5+
- cron: "37 23 * * *"
6+
workflow_dispatch:
7+
8+
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
9+
permissions:
10+
contents: read
11+
pages: write
12+
id-token: write
13+
14+
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
15+
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
16+
concurrency:
17+
group: "pages"
18+
cancel-in-progress: false
19+
20+
jobs:
21+
gen-n-upload:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v2
25+
with:
26+
fetch-depth: 0
27+
fetchLocal: ["master"]
28+
29+
- name: Setup Pages
30+
uses: actions/configure-pages@v4
31+
32+
- name: Rebase locally
33+
run: git rebase master 20240102-coverage
34+
35+
- name: Generate coverage report
36+
run: |
37+
make distclean coverage-clean
38+
./configure --enable-coverage --disable-valgrind CC=clang
39+
make -j $(nproc)
40+
PYTEST_PAR=$(nproc) make pytest || true
41+
make coverage
42+
43+
- name: Collect static webpage
44+
run: |
45+
mkdir -p site
46+
mv coverage/html site/coverage
47+
48+
- name: Upload artifact
49+
uses: actions/upload-pages-artifact@v3
50+
with:
51+
path: 'site'
52+
53+
- name: Upload to `gh-pages`
54+
id: deployment
55+
uses: actions/deploy-pages@v4

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ gen_*.c
2323
gen_*.h
2424
wire/gen_*_csv
2525
cli/lightning-cli
26-
coverage
2726
# Coverage profiling data files
2827
*.profraw
2928
*.profdata

Makefile

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,6 @@ else
6767
DEV_CFLAGS=
6868
endif
6969

70-
ifeq ($(COVERAGE),1)
71-
COVFLAGS = --coverage
72-
endif
73-
7470
ifeq ($(CLANG_COVERAGE),1)
7571
COVFLAGS+=-fprofile-instr-generate -fcoverage-mapping
7672
endif
@@ -378,10 +374,12 @@ endif
378374
RUST_PROFILE ?= debug
379375

380376
# Cargo places cross compiled packages in a different directory, using the target triple
377+
# Respect CARGO_TARGET_DIR if set in the environment
378+
CARGO_BASE_DIR = $(or $(CARGO_TARGET_DIR),target)
381379
ifeq ($(TARGET),)
382-
RUST_TARGET_DIR = target/$(RUST_PROFILE)
380+
RUST_TARGET_DIR = $(CARGO_BASE_DIR)/$(RUST_PROFILE)
383381
else
384-
RUST_TARGET_DIR = target/$(TARGET)/$(RUST_PROFILE)
382+
RUST_TARGET_DIR = $(CARGO_BASE_DIR)/$(TARGET)/$(RUST_PROFILE)
385383
endif
386384

387385
ifneq ($(RUST_PROFILE),debug)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/bin/bash -eu
2+
# Merge all .profraw files into a single .profdata file
3+
# Usage: ./collect-coverage.sh [COVERAGE_DIR] [OUTPUT_FILE]
4+
5+
COVERAGE_DIR="${1:-${CLN_COVERAGE_DIR:-/tmp/cln-coverage}}"
6+
OUTPUT="${2:-coverage/merged.profdata}"
7+
8+
echo "Collecting coverage from: $COVERAGE_DIR"
9+
10+
# Find all profraw files
11+
mapfile -t PROFRAW_FILES < <(find "$COVERAGE_DIR" -name "*.profraw" 2>/dev/null || true)
12+
13+
if [ ${#PROFRAW_FILES[@]} -eq 0 ]; then
14+
echo "ERROR: No .profraw files found in $COVERAGE_DIR"
15+
exit 1
16+
fi
17+
18+
echo "Found ${#PROFRAW_FILES[@]} profile files"
19+
20+
# Validate each profraw file and filter out corrupt/incomplete ones
21+
# Define validation function for parallel execution
22+
validate_file() {
23+
local profraw="$1"
24+
25+
# Check if file is empty
26+
if [ ! -s "$profraw" ]; then
27+
return 1 # Empty
28+
fi
29+
30+
# Check if file is suspiciously small (likely incomplete write)
31+
# Valid profraw files are typically > 1KB
32+
filesize=$(stat -c%s "$profraw" 2>/dev/null || stat -f%z "$profraw" 2>/dev/null)
33+
if [ "$filesize" -lt 1024 ]; then
34+
return 2 # Too small
35+
fi
36+
37+
# Try to validate the file by checking if llvm-profdata can read it
38+
if llvm-profdata show "$profraw" >/dev/null 2>&1; then
39+
echo "$profraw" # Valid - output to stdout
40+
return 0
41+
else
42+
return 3 # Corrupt
43+
fi
44+
}
45+
46+
# Export function for parallel execution
47+
export -f validate_file
48+
49+
TOTAL=${#PROFRAW_FILES[@]}
50+
NPROC=$(nproc 2>/dev/null || echo 4)
51+
echo "Validating ${TOTAL} files in parallel (using ${NPROC} cores)..."
52+
53+
# Run validation in parallel and collect valid files
54+
mapfile -t VALID_FILES < <(
55+
printf '%s\n' "${PROFRAW_FILES[@]}" | \
56+
xargs -P "$NPROC" -I {} bash -c 'validate_file "$@"' _ {}
57+
)
58+
59+
# Calculate error counts
60+
CORRUPT_COUNT=$((TOTAL - ${#VALID_FILES[@]}))
61+
62+
if [ ${#VALID_FILES[@]} -eq 0 ]; then
63+
echo "ERROR: No valid .profraw files found (all $CORRUPT_COUNT files were corrupt/incomplete)"
64+
exit 1
65+
fi
66+
67+
echo "Valid files: ${#VALID_FILES[@]}"
68+
if [ $CORRUPT_COUNT -gt 0 ]; then
69+
echo "Filtered out: $CORRUPT_COUNT files (empty/small/corrupt)"
70+
fi
71+
mkdir -p "$(dirname "$OUTPUT")"
72+
73+
# Merge with -sparse flag for efficiency
74+
# Use batched merging to avoid "Argument list too long" errors
75+
BATCH_SIZE=500
76+
TOTAL_FILES=${#VALID_FILES[@]}
77+
78+
if [ "$TOTAL_FILES" -le "$BATCH_SIZE" ]; then
79+
# Small enough to merge in one go
80+
echo "Merging ${TOTAL_FILES} files..."
81+
llvm-profdata merge -sparse "${VALID_FILES[@]}" -o "$OUTPUT"
82+
else
83+
# Need to merge in batches
84+
echo "Merging ${TOTAL_FILES} files in batches of ${BATCH_SIZE}..."
85+
86+
# Create temp directory for intermediate files
87+
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/profdata-merge.XXXXXX")
88+
trap 'rm -rf "$TEMP_DIR"' EXIT
89+
90+
BATCH_NUM=0
91+
INTERMEDIATE_FILES=()
92+
93+
# Merge files in batches
94+
for ((i=0; i<TOTAL_FILES; i+=BATCH_SIZE)); do
95+
BATCH_NUM=$((BATCH_NUM + 1))
96+
END=$((i + BATCH_SIZE))
97+
if [ "$END" -gt "$TOTAL_FILES" ]; then
98+
END=$TOTAL_FILES
99+
fi
100+
101+
BATCH_FILES=("${VALID_FILES[@]:$i:$BATCH_SIZE}")
102+
INTERMEDIATE="$TEMP_DIR/batch-$BATCH_NUM.profdata"
103+
104+
echo " Batch $BATCH_NUM: merging files $((i+1))-$END..."
105+
llvm-profdata merge -sparse "${BATCH_FILES[@]}" -o "$INTERMEDIATE"
106+
INTERMEDIATE_FILES+=("$INTERMEDIATE")
107+
done
108+
109+
# Merge all intermediate files into final output
110+
echo "Merging ${#INTERMEDIATE_FILES[@]} intermediate files into final output..."
111+
llvm-profdata merge -sparse "${INTERMEDIATE_FILES[@]}" -o "$OUTPUT"
112+
113+
# Cleanup handled by trap
114+
fi
115+
116+
echo "✓ Merged profile: $OUTPUT"
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/bin/bash -eu
2+
# Generate HTML and text coverage reports from merged profile data
3+
# Usage: ./generate-coverage-report.sh [PROFDATA_FILE] [OUTPUT_DIR]
4+
5+
PROFDATA="${1:-coverage/merged.profdata}"
6+
OUTPUT_DIR="${2:-coverage/html}"
7+
8+
if [ ! -f "$PROFDATA" ]; then
9+
echo "ERROR: Profile not found: $PROFDATA"
10+
echo "Run collect-coverage.sh first to create the merged profile"
11+
exit 1
12+
fi
13+
14+
# Get all binaries from Makefile (includes plugins, tools, test binaries)
15+
echo "Discovering instrumented binaries from Makefile..."
16+
mapfile -t BINARIES < <(make -qp 2>/dev/null | awk '/^ALL_PROGRAMS :=/ {$1=$2=""; print}' | tr ' ' '\n' | grep -v '^$')
17+
mapfile -t TEST_BINARIES < <(make -qp 2>/dev/null | awk '/^ALL_TEST_PROGRAMS :=/ {$1=$2=""; print}' | tr ' ' '\n' | grep -v '^$')
18+
19+
# Combine all binaries
20+
ALL_BINARIES=("${BINARIES[@]}" "${TEST_BINARIES[@]}")
21+
22+
# Build llvm-cov arguments
23+
ARGS=()
24+
for bin in "${ALL_BINARIES[@]}"; do
25+
if [ -f "$bin" ]; then
26+
if [ ${#ARGS[@]} -eq 0 ]; then
27+
ARGS+=("$bin") # First binary is primary
28+
else
29+
ARGS+=("-object=$bin") # Others use -object=
30+
fi
31+
fi
32+
done
33+
34+
if [ ${#ARGS[@]} -eq 0 ]; then
35+
echo "ERROR: No instrumented binaries found"
36+
echo "Make sure you've built with --enable-coverage"
37+
exit 1
38+
fi
39+
40+
echo "Generating coverage report for ${#ARGS[@]} binaries..."
41+
42+
# Generate HTML report
43+
llvm-cov show "${ARGS[@]}" \
44+
-instr-profile="$PROFDATA" \
45+
-format=html \
46+
-output-dir="$OUTPUT_DIR" \
47+
-show-line-counts-or-regions \
48+
-show-instantiations=false
49+
50+
echo "✓ HTML report: $OUTPUT_DIR/index.html"
51+
52+
# Generate text summary
53+
mkdir -p coverage
54+
llvm-cov report "${ARGS[@]}" \
55+
-instr-profile="$PROFDATA" \
56+
| tee coverage/summary.txt
57+
58+
echo "✓ Summary: coverage/summary.txt"

doc/developers-guide/coverage.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Test Coverage
2+
3+
> Coverage isn't everything, but it can tell you were you missed a thing.
4+
5+
We use LLVM's [Source-Based Code Coverage][sbcc] support to instrument
6+
the code at compile time. This instrumentation then emits coverage
7+
files (`profraw`), which can then be aggregated via `llvm-profdata`
8+
into a single `profdata` file, and from there a variety of tools can
9+
be used to inspect coverage.
10+
11+
The most common use is to generate an HTML report for all binaries
12+
under test. CLN being a multi-process system has a number of binaries,
13+
sharing some source code. To simplify the aggregation of data and
14+
generation of the report split per source file, we use the
15+
`prepare-code-coverage-artifact.py` ([`pcca.py`][pcca]) script from
16+
the LLVM project.
17+
18+
## Conventions
19+
20+
The `tests/fixtures.py` sets the `LLVM_PROFILE_FILE` environment
21+
variable, indicating that the `profraw` files ought to be stores in
22+
`coverage/raw`. Processing the file then uses [`pcca.py`][pcca] to
23+
aggregate the raw files, into a data file, and then generate a
24+
per-source-file coverage report.
25+
26+
This report is then published [here][report]
27+
28+
[sbcc]: https://clang.llvm.org/docs/SourceBasedCodeCoverage.html
29+
[pcca]: https://github.com/ElementsProject/lightning/tree/master/contrib/prepare-code-coverage-artifact.py
30+
[report]: https://cdecker.github.io/lightning/coverage

tests/fixtures.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@
1010

1111

1212
@pytest.fixture
13-
def node_cls():
13+
def node_cls(test_name: str): # noqa: F811
14+
# We always set the LLVM coverage destination, just in case
15+
# `lightningd` was compiled with the correct instrumentation
16+
# flags. This creates a `coverage` directory in the repository
17+
# and puts all the files in it.
18+
repo_root = Path(__file__).parent.parent
19+
os.environ['LLVM_PROFILE_FILE'] = str(
20+
repo_root / "coverage" / "raw" / f"{test_name}.%p.profraw"
21+
)
22+
1423
return LightningNode
1524

1625

0 commit comments

Comments
 (0)