Summary
This RFC proposes an opt-in Clang source-based coverage mode that records whether execution returns normally from eligible call expressions.
The proposed option is:
-fcoverage-call-continuations
When enabled, Clang emits a continuation counter after eligible calls and uses that counter when constructing coverage regions for the source code following the call.
The purpose is to distinguish these two runtime facts:
-
The call expression was reached.
-
Control returned normally from the call and reached the following source statement.
Prototype implementation:
Original source-based coverage discussion:
Related issue:
Earlier mapping and local-inference experiment:
Motivation
Clang source-based coverage can report a statement following a call as covered even when execution never returned from that call.
For example:
// main.c
extern void f1(void);
int main(void) {
f1();
puts("continued");
}
// file1.c
extern void f2(void);
void f1(void) {
f2();
}
// file2.c
#include <stdlib.h>
void f2(void) {
exit(0);
}
While compiling main.c, Clang cannot see that f1() eventually terminates the process.
The line containing f1() was reached, but the following puts() statement was not reached. However, the existing source-based coverage mapping may place both statements in a region using the same counter, causing the following statement to be shown as covered.
This occurs in large projects where terminating behavior may be hidden behind:
-
Cross-translation-unit call chains.
-
Shared libraries.
-
Function pointers.
-
Dynamically selected implementations.
-
Functions that are not annotated
noreturn.
The proposed continuation counter records the runtime fact that control actually returned from the call. It does not attempt to prove statically that the callee is noreturn.
Why a coverage-mapping-only change is insufficient
Consider:
void test(void) {
external_call();
statement_after_call();
}
There are two possible executions:
Execution A:
test() is entered
external_call() returns normally
statement_after_call() executes
Execution B:
test() is entered
external_call() terminates the process
statement_after_call() does not execute
If the only runtime counter records entry into the source region containing both statements, both executions can produce the same profile information.
Coverage mapping can change how existing counters are associated with source regions, but it cannot recover whether execution returned from external_call() when no runtime observation was recorded after the call.
Local control-flow analysis can solve cases where the terminating behavior is visible in the current translation unit. It cannot generally solve cross-translation-unit or dynamically dispatched call chains.
Proposed semantics
A call-continuation counter records the number of times control reaches the normal continuation point immediately following an eligible call expression.
It records normal continuation from the call. It does not record:
-
Entry into the call.
-
Exceptional or unwind continuation.
-
Process termination through
exit,_Exit,abort, or an unknown downstream terminating function. -
Control transfer through
longjmp. -
Continuation from a
musttailcall, because no local continuation exists.
For example:
if (g())
work();
The following are separate facts:
-
g()was called. -
g()returned normally. -
The value returned by
g()evaluated to true.
The continuation counter represents the second fact.
Proposed user interface
The feature would initially be opt-in:
-fprofile-instr-generate -fcoverage-mapping \
-fcoverage-call-continuations
Existing source-based coverage behavior would remain unchanged when the option is not provided.
The feature is opt-in because adding continuation counters increases the number of instrumentation counters and may affect compile time, profile size, binary size, and coverage-processing cost.
Prototype
PR #201079 implements an initial version of this proposal.
The current prototype handles:
-
Ordinary post-call continuations.
-
Cross-function terminating chains.
-
Calls used in loop, branch, and logical-expression conditions.
-
returns_twicecases conservatively. -
Calls with no normal continuation, such as
musttail. -
Coverage region construction using the continuation count.
The implementation currently stores continuation counters separately from the existing RegionCounterMap.
This is one of the main design questions for this RFC rather than a finalized implementation decision.
Counter representation challenge
Clang’s existing RegionCounterMap associates a Stmt * with a CounterPair used by the region-based coverage infrastructure.
A continuation counter has a different semantic meaning from the existing statement or branch counter.
For a simple example:
f();
return 1;
the continuation counter could potentially be represented as an executed counter associated with a continuation region.
A more difficult case is:
if (returns_twice() && g())
return 1;
The CallExpr for g() may already be associated with counter information used to represent condition or branch evaluation.
The continuation counter answers whether g() returned normally. The existing condition counter answers how the logical expression was evaluated.
A call may return normally with a false result, so these counters cannot always be treated as equivalent.
Because RegionCounterMap is keyed by Stmt *, directly associating both meanings with the same CallExpr requires either:
-
Extending the existing counter representation.
-
Introducing a separate continuation-counter map.
-
Representing continuations using synthetic coverage points or another region-based mechanism.
-
Reusing an existing successor counter where one is independently available.
The prototype uses a separate map, but the RFC is requesting feedback on the appropriate representation.
Initial performance measurements
A synthetic call-heavy benchmark was used to measure the prototype.
| Mode | Compile median | Runtime median | Binary size | LLVM counter slots |
|---|---|---|---|---|
| Existing Clang source coverage | 0.60 s | 0.48 s | 171,280 bytes | 136 |
| With call continuations | 0.99 s | 0.47 s | 178,240 bytes | 272 |
The benchmark intentionally contains 128 calls in one function and is intended as a stress case.
In this benchmark, the main observed costs were additional counter slots and compile time rather than runtime. More representative measurements from a large codebase are still needed, including profile size and llvm-cov processing cost.
Alternatives considered
Keep the existing behavior
This avoids additional instrumentation overhead but continues to report some post-call source statements as covered when the call did not return.
Infer terminating calls locally
Clang can identify some terminating calls when the callee body is visible or a declaration has a suitable attribute.
This helps direct calls, same-translation-unit wrappers, and locally visible infinite loops. It does not generally solve cross-translation-unit chains, indirect calls, shared-library calls, or missing annotations.
Require noreturn annotations
This has low runtime overhead, but its accuracy depends on declarations and call chains being correctly annotated.
The coverage result would also depend on information available during compilation rather than what happened at runtime.
Use LTO or interprocedural analysis
Whole-program analysis may identify more terminating call chains, but coverage correctness would depend on linkage visibility, optimization configuration, and whether LTO is enabled.
Add runtime continuation counters
This directly records whether the call returned normally and works without visibility into the callee implementation.
The tradeoff is additional instrumentation and profile-processing overhead.
Add continuation counters selectively
Clang could potentially avoid a new counter when an existing independently counted successor region already proves that execution continued after the call.
This could reduce overhead but would make counter assignment and coverage mapping more complex.
Goals
-
Distinguish reaching a call from reaching its normal continuation.
-
Support calls whose implementations are unavailable in the current translation unit.
-
Preserve existing behavior unless the feature is explicitly enabled.
-
Reuse existing coverage profile and mapping infrastructure where possible.
-
Produce results usable through the existing
llvm-profdataandllvm-covworkflows.
Non-goals
-
Proving that arbitrary functions are
noreturn. -
Performing whole-program call-graph analysis.
-
Enabling the behavior by default in the initial implementation.
-
Providing general path coverage.
-
Recording every possible interprocedural control-flow edge.
Open questions
-
Should continuation counters be represented inside
RegionCounterMap, in a separate map, or through another region-based representation? -
Should every eligible call receive a continuation counter, or should Clang reuse existing successor counters when possible?
-
Which call expressions should be included in the first implementation, particularly implicit constructors, destructors, coroutine operations, and compiler-generated calls?
-
Should calls that may throw increment the continuation counter only on the normal return path?
-
How should this interact with branch coverage and MC/DC coverage?
-
Should the initial implementation support single-byte coverage counters?
-
What level of compatibility is required with older versions of
llvm-profdataandllvm-cov? -
Is an opt-in Clang option the preferred interface, or should this behavior be associated with another coverage mode?
Feedback on the required semantics and the appropriate counter representation would be particularly helpful.