[llvm-cov] Merged line coverage reports zero after an early return in a templated if constexpr
- Dominant language
- LLVM
- Stars
- 40.5k
- Forks
- 18.7k
- PR merge metrics
- PR metrics pending
Description
`llvm-cov` reports an executed statement as uncovered when it follows a templated `if constexpr` containing an early return. The individual instantiation correctly shows the statement executing once, but the merged source view reports zero. The false zero also affects the line-coverage summary.
Reproduced on 2026-09-17 with both the [latest stable release, LLVM 23.1.1](https://github.com/llvm/llvm-project/releases/tag/llvmorg-23.1.1), and the LLVM 24 development snapshot published by [apt.llvm.org](https://apt.llvm.org/) that day. The reproducer is standalone C++17 and has no target-specific code or library dependencies.
### Reproducer
Save as `repro.cpp`:
```cpp
volatile int sink = 0;
template void test() {
if constexpr (Stop) {
return;
}
++sink; // Executes once; merged coverage incorrectly reports zero.
}
int main() {
test();
test();
return sink != 1;
}
```
Use `clang++`, `llvm-profdata`, and `llvm-cov` from the same toolchain:
```sh
clang++ -std=c++17 -O0 -fprofile-instr-generate -fcoverage-mapping repro.cpp -o repro
LLVM_PROFILE_FILE=repro.profraw ./repro
# Exit status is 0: the runtime check confirms sink == 1.
llvm-profdata merge -sparse repro.profraw -o repro.profdata
llvm-cov show ./repro -instr-profile=repro.profdata -show-instantiations=false -use-color=false
```
The same result occurs with `-O2`.
### Expected behavior
Line 7, `++sink`, executes once, in `test()`. Its merged line count should be 1, and it should be covered.
### Actual behavior
Both tested toolchains produce:
```text
1| |volatile int sink = 0;
2| |
3| 2|template void test() {
4| 2| if constexpr (Stop) {
5| 1| return;
6| 1| }
7| 0| ++sink; // Executes once; merged coverage incorrectly reports zero.
8| 2|}
9| |
10| 1|int main() {
11| 1| test();
12| 1| test();
13| 1| return sink != 1;
14| 1|}
```
With instantiations shown:
```sh
llvm-cov show ./repro -instr-profile=repro.profdata -show-instantiations=true -use-color=false
```
| View | Line 7 count |
| --- | ---: |
| `test()` (`_Z4testILb1EEvv`) | 0 |
| `test()` (`_Z4testILb0EEvv`) | 1 |
| Merged source view | **0** |
The false zero also affects the coverage summary:
```sh
llvm-cov report ./repro -instr-profile=repro.profdata
```
The report shows 10/11 lines covered (90.91%), although the example exercises every executable source line. Region coverage is 3/3 (100%). This can incorrectly fail line-coverage checks.
### Tested versions
All runs used native `aarch64-unknown-linux-gnu` on Ubuntu 26.04, with matching compiler, profile merger, and coverage reader for each toolchain. Both `-O0` and `-O2` reproduce the issue.
| Toolchain | Exact compiler identification |
| --- | --- |
| Official LLVM 23.1.1 release binaries | `clang version 23.1.1 (https://github.com/llvm/llvm-project 6dfe1677ab8dffbc6ec13d53a1e0215d75147689)` |
| LLVM 24 nightly, 2026-09-17, apt.llvm.org Jammy packages | `Ubuntu clang version 24.0.0 (++20260917042618+af86d781e2b6-1~exp1~20260917042630.3356)` |
These were the latest stable release and published nightly checked on 2026-09-17. I have not tested a separate build of the current `main` tip or another execution architecture.
### Controls and workaround
I also tested the following variations. Results were the same on both toolchains at `-O0` and `-O2`. Counts refer to the increment after the conditional, or inside `else` where indicated:
| Variant | Expected target count | Actual target count |
| --- | ---: | ---: |
| Templated `if constexpr`, early return, statement after the `if` | 1 | **0** |
| Same, with an empty `else` and statement still after the `if` | 1 | **0** |
| Statement moved inside `else` | 1 | 1 |
| Templated `if constexpr` without the early return | 2 | 2 |
| Templated runtime `if`, early return | 1 | 1 |
| Non-templated runtime `if`, early return | 1 | 1 |
| Only the `` instantiation exists and is called | 1 | 1 |
| Only the `` instantiation exists and is called | 0 | 0 |
For the two-instantiation cases, each instantiation was called once. The runtime-template control used two distinct integer template arguments and passed `true` to one call and `false` to the other; the non-templated runtime control called the same function with both values. The no-early-return case kept an increment inside the conditional and another after it. The single-instantiation cases instantiated and called only the indicated specialization.
Moving the statement into an `else` gives the expected count of 1:
```cpp
template void test() {
if constexpr (Stop) {
return;
} else {
++sink;
}
}
```
### Diagnostic observations
To inspect the coverage segments:
```sh
llvm-cov export ./repro -instr-profile=repro.profdata > coverage.json
```
In the minimal example's JSON export, the merged file segments include the following. Fields are `[line, column, count, hasCount, isRegionEntry, isGapRegion]`:
```text
[6, 4, 0, true, false, true]
[7, 3, 2, true, false, false]
```
The preceding segment is a zero-count gap. The segment on line 7 has a nonzero count but is not a region entry.
In [`LineCoverageStats::LineCoverageStats` at the tested nightly revision](https://github.com/llvm/llvm-project/blob/af86d781e2b6/llvm/lib/ProfileData/Coverage/CoverageMapping.cpp#L1571-L1613), the line count is initialized from the wrapped segment. If there are no qualifying region entries on the line, it returns that count. This looks relevant to the false zero, but I have not established whether the fix belongs there or in the emitted coverage mapping.
One additional observation: a variant with two successive increments after the conditional reports counts 0 and 2, although each increment executes once:
```cpp
template void test() {
if constexpr (Stop) {
return;
}
++sink; // Reported count: 0; expected: 1.
++sink; // Reported count: 2; expected: 1.
}
```
Both instantiations were called once, so each increment executes only in `test()`. This is also consistent with the merged segment count of 2 above, so simply using that segment's count would still not give the expected count of 1.
[Issue #164431](https://github.com/llvm/llvm-project/issues/164431) also concerns coverage around terminating statements, but its example involves dead closing braces in a runtime `switch`, not an executed statement in merged template coverage. I have not established that the causes are the same.
---
**Disclosure**: this issue was discovered by myself, but Codex has been used to help create and run the standalone reproducers, investigate the coverage output, and draft this report. I then reviewed all of this content myself; it's been used to hopefully make the LLVM dev team's life easier with additional context.
Contributor guide
Research direction
Start by building the standalone C++17 reproducer with clang++, then inspect the merged output from llvm-cov show, report, and export. Read LineCoverageStats::LineCoverageStats in llvm/lib/ProfileData/Coverage/CoverageMapping.cpp and compare its behavior with the emitted segments. Done means adding a regression test and making the merged line count and summary report one covered execution.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- compilers, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 50/100