[RFC][Clang][Coverage] Add opt-in call-continuation counters to source-based coverage

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:

  1. The call expression was reached.

  2. 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 musttail call, because no local continuation exists.

For example:

if (g())
  work();

The following are separate facts:

  1. g() was called.

  2. g() returned normally.

  3. 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_twice cases 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-profdata and llvm-cov workflows.

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

  1. Should continuation counters be represented inside RegionCounterMap, in a separate map, or through another region-based representation?

  2. Should every eligible call receive a continuation counter, or should Clang reuse existing successor counters when possible?

  3. Which call expressions should be included in the first implementation, particularly implicit constructors, destructors, coroutine operations, and compiler-generated calls?

  4. Should calls that may throw increment the continuation counter only on the normal return path?

  5. How should this interact with branch coverage and MC/DC coverage?

  6. Should the initial implementation support single-byte coverage counters?

  7. What level of compatibility is required with older versions of llvm-profdata and llvm-cov?

  8. 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.

1 Like

I don’t have enough expertise with code coverage to say much more than “thank you for the RFC” and that it seems like a reasonable idea to me. I believe others have expressed the viewpoint that noreturn calls are orthogonal to code coverage and this behavior is intentional, but from my naive position I think it makes sense to at least have an opt-in way for code coverage to highlight code that’s not covered because of noreturn function calls.

CC @chapuni @evodius96 for awareness