This article finishes the ClangIR lifetime analysis series. We will use the points-to model from Building the LifetimeCheck Pass and the moved-from state tracking from AST Semantics and Use-After-Move Detection. The remaining question is how the same analysis behaves when the program has stores, loads, branches, loops, scopes, coroutines, and returned lambdas.

Post-freeze note (added 26 July 2026).

This article is dated 11 February 2026. Its historical discussion follows the LifetimeCheck implementation at incubator commit 0c130cda66d7 from 9 February 2026. The ClangIR incubator was frozen on 20 February 2026. LifetimeCheck remained an incubator-only experimental pass and was not transferred to llvm-project. Observations added after the freeze are marked explicitly. Where a source example uses a standard-library owner, read it as C++ motivation unless the text ties it to an annotated test-local stand-in: the frozen classifier did not infer Owner status from the system-library type alone.

Memory operations

Store and load operations are the workhorses of lifetime tracking. Nearly every assignment, initialization, and value use flows through checkStore() and checkLoad(). Understanding how these operations dispatch to specialized handlers is crucial for understanding the complete picture of lifetime analysis.

Store operation dispatch

When a StoreOp is encountered, the pass must determine what kind of memory store it is and handle it appropriately. Many scalar and pointer assignments or initializations lower through this operation; class special members instead appear as CallOps. Different store patterns have different lifetime semantics.

Store operation dispatch decision flow in checkStore()
Condition Frozen checkStore() action
Aggregate destination If the stored value is a ConstantOp, update exploded field psets; otherwise do nothing. Return in either case.
Non-aggregate destination Run the Value-to-Value load/store heuristic in checkMovedFromValue().
Non-Pointer destination If the value is a tracked temporary coroutine task, bind its captured locals; otherwise check for a lambda capture store. Then return.
Pointer destination Update the destination’s pset with updatePointsTo().

The store dispatch logic follows this order:

  1. Aggregate stores — If the destination is an aggregate type (a record tracked in the aggregates map), call updatePointsTo() only when the source is a ConstantOp, to update exploded field psets. Return after this branch even when the source is not constant.

  2. Value-source heuristic — For a non-aggregate destination, call checkMovedFromValue() to diagnose an already-invalid Value source or mark a Value source invalid for the qualifying load/store shape. This is the heuristic explained in the previous article.

  3. Non-Pointer stores — If the stored value is a tracked coroutine task temporary, call checkCoroTaskStore() to bind its captured locals. Otherwise, call checkLambdaCaptureStore() to handle potential lambda captures by reference. Return after this branch.

  4. Pointer stores — For a Pointer-category destination, call updatePointsTo() to update its pset. This is the standard path for a store such as ptr = &x;.

Post-freeze note (added 26 July 2026).

A later audit corrected three overstatements in the earlier overview: StoreOp does not represent every C++ assignment, aggregate destinations return before the Value heuristic, and checkMovedFromValue() does not prove an rvalue initialization. Its inability to distinguish std::move from an ordinary Value copy is documented in the preceding article’s dated note.

Implementation pattern from LifetimeCheck.cpp, simplified:

1void LifetimeCheckPass::checkStore(StoreOp storeOp) {
2  auto addr = storeOp.getAddr();
3
4  // Handle aggregate stores (field-wise update)
5  if (aggregates.count(addr)) {
6    auto data = storeOp.getValue();
7    if (data.getDefiningOp<cir::ConstantOp>()) {
8      updatePointsTo(addr, data, data.getLoc());
9    }
10    return;
11  }
12
13  // Apply the Value-to-Value load/store heuristic.
14  checkMovedFromValue(storeOp);
15
16  // Special handling for non-pointer types
17  if (!ptrs.count(addr)) {
18    if (currScope->localTempTasks.count(storeOp.getValue()))
19      checkCoroTaskStore(storeOp);
20    else
21      checkLambdaCaptureStore(storeOp);
22    return;
23  }
24
25  // Standard pointer store: update points-to set
26  updatePointsTo(addr, storeOp.getValue(), storeOp.getValue().getLoc());
27}

Key insight: The order of checks matters. Aggregate stores are decomposed first and may return early. For the remaining store cases, the pass checks for a moved-from value before it handles pointer updates, coroutine task stores, or lambda captures, so use-after-move is detected before the store is interpreted as a normal assignment.

Coroutine task stores

Coroutine tasks are special because their frame may outlive local variables whose references were passed to the coroutine. When a task is initialized, the pass tracks qualifying local argument values and binds them to the task’s pset.

1void LifetimeCheckPass::checkCoroTaskStore(StoreOp storeOp) {
2  auto taskTmp = storeOp.getValue();
3  auto taskAddr = storeOp.getAddr();
4
5  // Pattern: %tmp_task = cir.call @coroutine_call(%arg0, %arg1, ...)
6  //          cir.store %tmp_task, %task
7  //
8  // Bind local values used as arguments to pset(task)
9  if (auto call = taskTmp.getDefiningOp<cir::CallOp>()) {
10    bool potentialTaintedTask = false;
11    for (auto arg : call.getArgOperands()) {
12      auto alloca = arg.getDefiningOp<cir::AllocaOp>();
13      if (alloca && currScope->localValues.count(alloca)) {
14        // Task now depends on this local value
15        getPmap()[taskAddr].insert(State::getLocalValue(alloca));
16        potentialTaintedTask = true;
17      }
18    }
19
20    // Only tasks that depend on a local value need dereference checks.
21    if (potentialTaintedTask)
22      tasks.insert(taskAddr);
23    return;
24  }
25
26  llvm_unreachable("expecting cir.call defining op");
27}

For this registered shape, when the local value goes out of scope (triggering KILL), the task pset gains an invalid state. The diagnostic is emitted when the invalid task is used, not when it is returned. The following pattern comes from the incubator coroutine test:

1Task<int> go(int const &value);
2
3Task<int> useTask() {
4  auto task = go(1);
5  // The temporary bound to 'value' expires at the end of the
6  // preceding full-expression, which invalidates 'task'.
7  co_return co_await task;  // WARNING: dangling reference in task
8}

Lambda capture stores

Lambda captures by reference can similarly create dangling references. When a local value is stored into a lambda capture field, the pass binds that local to the lambda’s pset:

1void LifetimeCheckPass::checkLambdaCaptureStore(StoreOp storeOp) {
2  auto localByRefAddr = storeOp.getValue();
3  auto lambdaCaptureAddr = storeOp.getAddr();
4
5  if (!localByRefAddr.getDefiningOp<cir::AllocaOp>())
6    return;
7  auto lambdaAddr = getLambdaFromMemberAccess(lambdaCaptureAddr);
8  if (!lambdaAddr)
9    return;
10
11  // Bind captured local to lambda's pset
12  if (currScope->localValues.count(localByRefAddr))
13    getPmap()[lambdaAddr].insert(State::getLocalValue(localByRefAddr));
14}

Example:

1auto makeLambda() {
2  int local = 42;
3  auto lambda = [&local]() { return local + 1; };
4  // lambda captures local by reference
5  // End of scope: KILL(local) invalidates lambda
6  return lambda;  // WARNING: lambda references destroyed local
7}

Load operation and dereference

Load operations are simpler than stores—they primarily check validity when dereferencing pointers. The key insight is the isDeref flag, which distinguishes between loading a pointer value (safe) and dereferencing it (requires validity check).

Load operation decision flow in checkLoad()

The load logic follows this shape:

  1. Check if tracked — If the address is not in the pmap, it is not being tracked for lifetime purposes (e.g., it might be a global). Return early.

  2. Pointer type handling — If the address is a pointer type:

    • If isDeref is false, this is loading the pointer value itself (e.g., copying a pointer), which is safe. Return.

    • If isDeref is true, this is dereferencing the pointer (e.g., *ptr). Call checkPointerDeref() to verify it is not invalid or null.

  3. Value type handling — If the address is a value type, check if it is moved-from using isValueTypeMovedFrom(). If so, emit a diagnostic for use-after-move.

Implementation pattern from LifetimeCheck.cpp, simplified:

1void LifetimeCheckPass::checkLoad(LoadOp loadOp) {
2  auto addr = loadOp.getAddr();
3  if (!getPmap().count(addr))
4    return;  // Not tracked
5
6  // For pointer types, only check on dereference
7  if (ptrs.count(addr)) {
8    if (!loadOp.getIsDeref())
9      return;  // Loading pointer value is safe
10    checkPointerDeref(addr, loadOp.getLoc());
11    return;
12  }
13
14  // For value types, check if moved-from
15  if (isValueTypeMovedFrom(addr)) {
16    checkPointerDeref(addr, loadOp.getLoc());
17    return;
18  }
19}

The isDeref flag is set by CIRGen based on the C++ operation:

1int *ptr = ...;
2int *ptr2 = ptr;    // LoadOp(ptr, isDeref=false) - loading pointer value
3int value = *ptr;   // LoadOp(ptr, isDeref=true) - dereferencing pointer

This distinction allows the pass to avoid false positives: copying a null pointer is legal, but dereferencing it is not.

Control flow analysis

ClangIR provides high-level control flow operations that make branch analysis easier than with raw LLVM IR. While MLIR offers a built-in dataflow analysis framework (mlir::dataflow::DataFlowSolver), the LifetimeCheck pass implements custom control flow analysis for fine-grained control over state tracking and error reporting.

The pass builds on MLIR’s base infrastructure, which provides the fundamental Region/Block/Operation hierarchy that structures the IR, along with traversal iterators such as region.getBlocks() and block.getOperations() for walking through the program structure. MLIR also provides operation type dispatch mechanisms (isa<>(), cast<>()) and region accessors (getThenRegion(), getElseRegion()) that enable navigation through control flow constructs. On top of this infrastructure, LifetimeCheckPass implements its own custom logic: it maintains state tracking through the pointer map (pmap) and scope management, implements state merging logic via joinPmaps() to handle control flow merge points, and defines the actual lifetime checking algorithms that detect use-after-move and dangling pointer errors.

The pass handles branches by merging state:

1void LifetimeCheckPass::checkIf(IfOp ifOp) {
2  // Collect pmaps from all branches for joining
3  llvm::SmallVector<PMapType, 2> pmapOps;
4
5  {
6    PMapType localThenPmap = getPmap();
7    PmapGuard pmapGuard{*this, &localThenPmap};
8    checkRegionWithScope(ifOp.getThenRegion());
9    pmapOps.push_back(localThenPmap);
10  }
11
12  // In case there's no 'else' branch, use the incoming pmap
13  if (!ifOp.getElseRegion().empty()) {
14    PMapType localElsePmap = getPmap();
15    PmapGuard pmapGuard{*this, &localElsePmap};
16    checkRegionWithScope(ifOp.getElseRegion());
17    pmapOps.push_back(localElsePmap);
18  } else {
19    pmapOps.push_back(getPmap());
20  }
21
22  joinPmaps(pmapOps);
23}

The merge operation conservatively handles both paths:

1// If a pointer is invalid in any branch, it's invalid after the if
2if (psetThen.count(State::getInvalid()) ||
3    psetElse.count(State::getInvalid())) {
4  mergedPset.insert(State::getInvalid());
5}

Post-freeze note (added 26 July 2026).

In the control-flow examples below, the std::unique_ptr spelling is schematic shorthand for the incubator test’s annotated, test-local Owner stand-in. The frozen classifier did not infer Owner status from the system-library type name alone. The pmap states and warnings described for these examples depend on that annotation.

Example:

1std::unique_ptr<int> ptr = std::make_unique<int>(42);
2if (condition) {
3  auto ptr2 = std::move(ptr);  // ptr invalid in 'then' branch
4} else {
5  // ptr still valid in 'else' branch
6}
7// After merge: ptr is potentially invalid
8int value = *ptr;  // WARNING: might be invalid
Control flow state merging in if/else branches

The union retains an invalid state that appears in any analyzed branch, so the variable is treated as potentially invalid after the merge. This may cause a false positive. It does not recover a control-flow path that the surrounding visitor failed to model.

Loop analysis

Loops present a challenge for lifetime analysis because they may execute zero or more times, and the number of iterations affects what values are valid. The LifetimeCheck pass uses a loop unrolling model that analyzes loops as if they were the first two iterations unrolled into conditional statements.

This approach, specified in P1179 §2.4.9, treats a loop:

1for (/*init*/; /*cond*/; /*incr*/) {
2  /*body*/
3}

as if it were:

1if (/*init*/; /*cond*/) {
2  /*body*/; /*incr*/
3}
4if (/*cond*/) {
5  /*body*/
6}
The three loop states constructed by the frozen implementation
Frozen path Starting pmap Regions checked
Never taken Pre-loop state None; preserve the incoming pmap.
First taken Pre-loop state Execution-order regions with the optional step region removed.
Subsequent taken First-taken exit state All execution-order regions, including the optional step region.

Post-freeze note (added 26 July 2026).

The P1179 conceptual rewrite above places the increment in the first simulated iteration and omits it from the second. The frozen implementation’s region selection does the opposite: it drops the optional step on the first-taken path and keeps it on the subsequent-taken path. An earlier diagram followed the conceptual ordering while the prose described the code. The table and listing here now record the literal implementation.

The three paths represent:

  1. Never taken — The loop condition is false from the start, so the body never executes. The pmap remains unchanged from before the loop.

  2. Taken once — The condition is true initially, so the loop body executes once. In the implementation below, the first-taken path walks the regions in execution order but drops the step region when the loop has one. This is the shape of the pass as implemented here.

  3. Taken twice or more — The condition is true at least twice. The pass starts from the first-taken exit pmap and checks the execution-order regions again, keeping the step region on this subsequent path.

After analyzing all three paths, the pass joins the resulting pmaps using the JOIN operation (explained below). Within those analyzed paths:

  • If a pointer becomes invalid in any iteration, it is treated as potentially invalid after the loop.

  • If a loop may execute zero times, the analysis preserves the pre-loop state as one possibility.

Implementation pattern from LifetimeCheck.cpp, simplified:

1void LifetimeCheckPass::checkLoop(LoopOpInterface loopOp) {
2  // Treat loop as first two iterations unrolled with if statements
3  llvm::SmallVector<PMapType, 4> pmapOps;
4  llvm::SmallVector<Region *, 4> regionsToCheck;
5
6  auto setupLoopRegionsToCheck = [&](bool isSubsequentTaken = false) {
7    regionsToCheck = loopOp.getRegionsInExecutionOrder();
8    if (loopOp.maybeGetStep() && !isSubsequentTaken)
9      regionsToCheck.pop_back();
10  };
11
12  // Path 1: Never taken
13  pmapOps.push_back(getPmap());
14
15  // Path 2: Taken once (condition true, then false)
16  PMapType loopExitPmap;
17  {
18    loopExitPmap = getPmap();
19    PmapGuard pmapGuard{*this, &loopExitPmap};
20    setupLoopRegionsToCheck();
21    for (auto *region : regionsToCheck)
22      checkRegion(*region);
23    pmapOps.push_back(loopExitPmap);
24  }
25
26  // Path 3: Taken 2+ times (condition true at least twice)
27  if (getPmap() != loopExitPmap) {
28    PMapType otherTakenPmap = loopExitPmap;
29    PmapGuard pmapGuard{*this, &otherTakenPmap};
30    setupLoopRegionsToCheck(/*isSubsequentTaken=*/true);
31    for (auto *region : regionsToCheck)
32      checkRegion(*region);
33    pmapOps.push_back(otherTakenPmap);
34  }
35
36  // Conservatively merge all three paths
37  joinPmaps(pmapOps);
38}

The exact region choice is an implementation detail of this pass. The important analysis property is that the never-taken pmap, the first-taken pmap, and the subsequent-taken pmap are all joined at the loop exit.

Example demonstrating loop lifetime tracking:

1std::unique_ptr<int> ptr = std::make_unique<int>(42);
2for (int i = 0; i < n; ++i) {
3  if (i == 0) {
4    auto ptr2 = std::move(ptr);  // ptr invalidated on first iteration
5  }
6  // After loop merge: ptr potentially invalid
7}
8*ptr;  // WARNING: pointer might be invalid

The analysis correctly identifies that ptr may be invalid after the loop because it could be moved on the first iteration (if n > 0).

Switch statement analysis

Switch statements are analyzed similarly to loops: they are transformed into conditional paths and then joined. The pass handles switch statements in simple form—cases with single regions and explicit break or fallthrough behavior.

Consider a switch with fallthrough:

1switch (a) {
2  case 1: /*1*/
3  case 2: /*2*/ break;
4  default: /*3*/
5}

A fallthrough-faithful way to view the analyzed paths is:

1if (auto& a=a; a==1) { /*1*/ /*2*/ }
2else if (a==2) { /*2*/ }
3else { /*3*/ }
Switch statement analysis with fallthrough handling

Key aspects of switch analysis:

  • Fallthrough semantics — When a case does not end with break, execution falls through to the next case. The pass models this by sequentially checking both case regions and merging the final state.

  • Break semantics — When a case ends with break, execution jumps to after the switch. Each such path contributes independently to the final joined pmap.

  • Default case — If present, the default case is checked as another independent path. In this implementation, a switch without a default does not add the incoming pmap for the path on which no case matches.

Implementation pattern from LifetimeCheck.cpp, simplified:

1void LifetimeCheckPass::checkSwitch(SwitchOp switchOp) {
2  llvm::SmallVector<PMapType, 2> pmapOps;
3
4  // Only handle switch in simple form
5  llvm::SmallVector<CaseOp> cases;
6  if (!switchOp.isSimpleForm(cases))
7    return;
8
9  auto isCaseFallthroughTerminated = [&](Region &r) -> bool {
10    Block &block = r.back();
11    auto yieldOp = dyn_cast<YieldOp>(block.back());
12    return !!yieldOp;
13  };
14
15  // Start one path at each case.
16  for (size_t current = 0; current < cases.size(); ++current) {
17    PMapType localCasePmap = getPmap();
18    PmapGuard pmapGuard{*this, &localCasePmap};
19
20    // Continue through every fallthrough case until the path stops.
21    size_t index = current;
22    while (index < cases.size()) {
23      checkRegion(cases[index].getRegion());
24      if (!isCaseFallthroughTerminated(cases[index].getRegion()))
25        break;
26      ++index;
27    }
28
29    pmapOps.push_back(localCasePmap);
30  }
31
32  // Merge all case paths
33  joinPmaps(pmapOps);
34}

Post-freeze note (added 26 July 2026).

A later review found a precision gap in the no-default case. Consider an uninitialized pointer that is initialized by every explicit case. If the switch value matches no case, the pointer is still invalid. Because the implementation does not join the incoming pmap, it can forget this path:

1int first = 1;
2int second = 2;
3int *ptr;
4switch (value) {
5case 1: ptr = &first; break;
6case 2: ptr = &second; break;
7}
8*ptr;  // The unmatched path is not represented by checkSwitch().

This is a limitation of the frozen incubator pass, not behavior supplied by llvm-project.

Example demonstrating switch lifetime tracking:

1std::unique_ptr<int> ptr = std::make_unique<int>(42);
2switch (value) {
3  case 1:
4    auto ptr2 = std::move(ptr);  // ptr invalidated in case 1
5    break;
6  case 2:
7    // ptr still valid in case 2
8    break;
9  default:
10    // ptr still valid in default
11    break;
12}
13// After switch: ptr potentially invalid (could have taken case 1)
14*ptr;  // WARNING: pointer might be invalid

The JOIN operation

The JOIN operation is the fundamental mechanism for merging program state from multiple control flow paths. It implements a conservative union strategy: if a variable has different states in different branches, the merged state must be conservative enough to cover all possibilities.

JOIN operation: conservative union of points-to sets from multiple branches

The JOIN algorithm (P1179 §2.3) (Sutter 2019) is simple but powerful:

  1. For each tracked address addr in the pmap, collect its pset from each branch being merged.

  2. Compute the set union of all these psets.

  3. Replace pmap[addr] with the union result.

Implementation pattern from LifetimeCheck.cpp:

1void LifetimeCheckPass::joinPmaps(SmallVectorImpl<PMapType> &pmaps) {
2  for (auto &mapEntry : getPmap()) {
3    auto &val = mapEntry.first;
4
5    // Collect pset from each branch
6    PSetType joinPset;
7    for (auto &pmapOp : pmaps)
8      llvm::set_union(joinPset, pmapOp[val]);
9
10    // Update with union of all psets
11    getPmap()[val] = joinPset;
12  }
13}

Why this is conservative:

  • If a pointer is {invalid} in any branch, the merged pset contains invalid, so any subsequent dereference triggers a warning.

  • If a pointer is {null} in some branches and {valid} in others, the merged pset is {null, valid}, indicating potential null.

  • If a pointer points to different objects in different branches, the merged pset contains all possibilities.

Example demonstrating JOIN with three branches:

1int x = 0;
2int *ptr;
3switch (value) {
4  case 1: ptr = nullptr; break;          // pmap[ptr] = {null}
5  case 2: ptr = &x; break;               // pmap[ptr] = {x}
6  case 3: {
7    int local = 0;
8    ptr = &local;
9  }                                      // KILL(local): {invalid}
10  break;
11  default: ptr = &x; break;
12}
13// After JOIN: pmap[ptr] = {null, x, invalid}
14*ptr;  // WARNING: may be invalid or null

The union is conservative over the branch states that reach joinPmaps(): an invalid or null possibility present in one input is not discarded. That local property can produce false positives, but it does not make the entire frozen pass sound.

Post-freeze note (added 26 July 2026).

An earlier version generalized this JOIN property into a claim that the analysis preferred false positives to false negatives everywhere. The frozen pass has known false-negative paths, including the missing unmatched path for a switch without default and the one-hop raw-pointer representation documented in Part 2. The statement above is therefore limited to states that actually reach this union.

Scope and lifetime management

One of the fundamental operations in the C++ Core Guidelines lifetime safety profile (P1179) is the KILL operation. Understanding KILL is essential to understanding how the LifetimeCheck pass propagates scope invalidation to diagnose some use-after-free bugs.

Lexical scope tracking

The pass tracks lexical scopes using a LexicalScopeContext stack. Each scope maintains localValues, the subset of local addresses registered for cleanup. When a scope ends (e.g., at the closing brace of a function or block), the guard applies KILL to those registered entries.

This is managed using the RAII pattern with LexicalScopeGuard:

1void LifetimeCheckPass::checkRegionWithScope(Region &region) {
2  // Create new scope
3  LexicalScopeContext lexScope{&region};
4  LexicalScopeGuard scopeGuard{*this, &lexScope};
5
6  // Check operations in this scope
7  for (auto &block : region)
8    checkBlock(block);
9
10  // Scope guard destructor runs here, killing registered localValues
11}

When the LexicalScopeGuard destructor runs (at scope exit), it calls kill() for each entry registered in the scope’s localValues set.

The KILL operation

When a registered local Owner goes out of scope, the pass scans tracked psets for direct occurrences of the local state or the resource it owns. This follows the KILL operation from the C++ Core Guidelines lifetime safety profile (Sutter 2019):

KILL(x) means to replace all occurrences of x and x’ and x” (etc.) in the pmap with invalid.

Actual cascading effect of KILL in the 9 February snapshot
Pmap key Before KILL(owner) After KILL(owner)
owner {owner’} The entry is skipped and remains in the pmap; at end of scope, owner is removed from the category sets.
ptr1 {owner’} {invalid} because owner’ is State::getOwnedBy(owner).
ptr2 {owner} {invalid} because the pset contains State::getLocalValue(owner).

The KILL operation has cascading semantics:

  • The pass scans psets other than the entry keyed by owner.

  • Occurrences of the Owner’s local-value state and owned-resource state are removed and replaced with invalid.

  • When the invalidation style is end-of-scope, owner is removed from the Owner, Pointer, and task category sets. Its own pmap entry is not erased by kill() in this snapshot.

Post-freeze note (added 26 July 2026).

A later audit corrected an earlier diagram that labelled pmap[owner] as deleted. Although the implementation comment says the entry will be deleted, the frozen kill() body skips the map entry whose key equals owner and does not erase it. The table above shows the literal snapshot behaviour.

Implementation pattern from LifetimeCheck.cpp, simplified:

1void LifetimeCheckPass::killInPset(mlir::Value ptrKey,
2                                   const State &s,
3                                   InvalidStyle invalidStyle,
4                                   mlir::Location loc) {
5  auto &pset = getPmap()[ptrKey];
6  if (pset.contains(s)) {
7    pset.erase(s);
8    markPsetInvalid(ptrKey, invalidStyle, loc);
9  }
10}
11
12// KILL(x): replace all occurrences of x, x', x'' in pmap with invalid
13void LifetimeCheckPass::kill(const State &s,
14                             InvalidStyle invalidStyle,
15                             mlir::Location loc) {
16  assert(s.hasValue() && "does not know how to kill other types");
17  mlir::Value v = s.getData();
18
19  for (auto &mapEntry : getPmap()) {
20    auto ptr = mapEntry.first;
21
22    // Skip the entry being deleted
23    if (v == ptr)
24      continue;
25
26    // Replace all occurrences of x' (owned object)
27    if (s.isLocalValue() && owners.count(v))
28      killInPset(ptr, State::getOwnedBy(v), invalidStyle, loc);
29
30    // Replace all occurrences of x (the value itself)
31    killInPset(ptr, s, invalidStyle, loc);
32  }
33
34  // Remove scoped local from category tracking sets
35  if (invalidStyle == InvalidStyle::EndOfScope) {
36    owners.erase(v);
37    ptrs.erase(v);
38    tasks.erase(v);
39  }
40}
41
42// LexicalScopeGuard destructor KILLs registered localValues
43void LifetimeCheckPass::LexicalScopeGuard::cleanup() {
44  auto *localScope = Pass.currScope;
45  for (auto pointee : localScope->localValues)
46    Pass.kill(State::getLocalValue(pointee),
47              InvalidStyle::EndOfScope,
48              getEndLocForHist(*localScope));
49}

Example demonstrating KILL in action:

1struct [[gsl::Owner(int)]] MyIntOwner {
2  int value;
3  explicit MyIntOwner(int v) : value(v) {}
4  int &operator*();
5};
6
7struct [[gsl::Pointer(int)]] MyIntPointer {
8  int *ptr;
9  MyIntPointer(int *p = nullptr) : ptr(p) {}
10  MyIntPointer(const MyIntOwner &);
11  int &operator*();
12};
13
14void dangling_owner() {
15  MyIntPointer ptr;
16  {
17    MyIntOwner owner(42);
18    ptr = owner; // pmap[ptr] = {owner__1'}
19    *ptr = 3;    // OK: owner is alive
20  }             // KILL(owner) invalidates owner__1' in pmap[ptr]
21  *ptr = 4;     // WARNING: use of invalid pointer 'ptr'
22}

The constructor from the annotated Owner is the special case that establishes the State::getOwnedBy(owner) relation. This is the same test-local pattern used by the incubator’s owner test.

Post-freeze note (added 26 July 2026).

A later review found that an arbitrary call result such as owner.get() or vec.data() is recorded as the call result itself; the pass does not derive an State::getOwnedBy(owner) relation from that call. Such calls must therefore not be substituted for the annotated constructor pattern above when describing what the frozen checker detects.

For every tracked pset that directly contains the local-value or owned-resource state, KILL replaces that state with invalid. A subsequent dereference of such a directly invalidated pointer can trigger a diagnostic from checkPointerDeref(). The one-hop pointer-copy limitation means this propagation is not a transitive walk over every semantic alias.

The relationship between KILL and the four classifier results is important:

  • Owners registered for cleanup are killed when they go out of scope, invalidating psets that directly mention the owned resource.

  • Values are killed when they go out of scope, invalidating any pointers directly to the value’s address (e.g., &x where x is an int).

  • Aggregates follow the Value allocation path for scope cleanup, while addressable fields may also be exploded and tracked separately.

  • Pointers are registered in the pass’s pointer set, but the frozen allocation path does not also register an ordinary Pointer-category local in the scope’s localValues cleanup set.

Post-freeze note (added 26 July 2026).

A later review corrected an earlier claim that Pointer-category locals were themselves KILLed at scope exit. The 9 February implementation iterates only localValues during scope cleanup. Owners, Values, and Aggregates handled through the Value path enter that set; an ordinary Pointer allocation does not.

This snapshot-specific registration scheme determines which variables the end-of-scope KILL pass can invalidate.

Coroutines and async code

Coroutines introduce unique lifetime challenges because they can suspend execution and resume later, potentially after local variables have gone out of scope. The LifetimeCheck pass has specialized handling for coroutine operations to detect these bugs.

Await operation handling

The co_await operator suspends the active coroutine and may execute several regions of code: the awaiter’s await_ready(), await_suspend(), and await_resume() methods. The pass analyzes each region independently from the same incoming pmap and joins the resulting states.

Implementation pattern from LifetimeCheck.cpp:

1void LifetimeCheckPass::checkAwait(AwaitOp awaitOp) {
2  // Analyze each region from the incoming state.
3  llvm::SmallVector<PMapType, 4> pmapOps;
4
5  for (auto r : awaitOp.getRegions()) {
6    PMapType regionPmap = getPmap();
7    PmapGuard pmapGuard{*this, &regionPmap};
8    checkRegion(*r);
9    pmapOps.push_back(regionPmap);
10  }
11
12  // Join states from all awaiter methods
13  joinPmaps(pmapOps);
14}

This merge retains an invalid possibility produced by any analyzed region. State produced by one region is not used as the input to the next region. Example:

1Task<void> example(std::unique_ptr<int> ptr) {
2  co_await suspendPoint();
3  // After await: ptr might still be valid (conservative)
4  *ptr;  // OK if suspendPoint doesn't move ptr
5}

Post-freeze note (added 26 July 2026).

The frozen source comment describes the regions as sequential, but the implementation copies the same incoming pmap before checking each region. Therefore the code implements an independent-path merge rather than sequential state propagation.

Coroutine task lifetime tracking

Coroutine tasks (the objects returned by coroutine functions) require special lifetime tracking because they can capture references to local variables passed as arguments. When a task is created, the pass tracks which locals are bound to it, ensuring that when those locals are destroyed, the task is invalidated.

Coroutine-task pset evolution at the 9 February snapshot
Event Task pset in the frozen implementation
Task alloca pmap[task] = {task}.
Store coroutine result The registered local argument is inserted without clearing the task’s own state: pmap[task] = {task, local}.
KILL(local) The local state is removed and invalid is inserted: pmap[task] = {task, invalid}.
Later task use The invalid state can produce the dangling-reference diagnostic.

Post-freeze note (added 26 July 2026).

An earlier diagram showed only {local} before KILL and {invalid} afterward. The actual task alloca retains its own local-value state throughout; binding and KILL insert the additional states shown in the table.

The task tracking logic is implemented in two parts:

1. Identifying coroutine calls:

1void LifetimeCheckPass::trackCallToCoroutine(CallOp callOp) {
2  if (auto fnName = callOp.getCallee()) {
3    auto calleeFuncOp = getCalleeFromSymbol(theModule, *fnName);
4    if (calleeFuncOp &&
5        (calleeFuncOp.getCoroutine() ||
6         (calleeFuncOp.isDeclaration() && callOp->getNumResults() > 0 &&
7          isTaskType(callOp->getResult(0))))) {
8      currScope->localTempTasks.insert(callOp->getResult(0));
9    }
10    return;
11  }
12  // Handle indirect calls to coroutines, for instance when
13  // lambda coroutines are involved with invokers.
14  if (callOp->getNumResults() > 0 && isTaskType(callOp->getResult(0))) {
15    currScope->localTempTasks.insert(callOp->getResult(0));
16  }
17}

Post-freeze note (added 26 July 2026).

The pre-freeze change 799d6390, authored on 13 February 2026 and committed on 17 February 2026, refactored call resolution to callOp.getDirectCallee(theModule). The listing above keeps the 9 February API because it belongs to the historical article body. The refactoring did not add a new LifetimeCheck analysis rule.

2. Binding locals to task pset (covered earlier in checkCoroTaskStore()):

When the task is stored to a variable, any local values passed as arguments are added to the task’s pset. When those locals are destroyed (KILL operation), the task becomes invalid.

For the earlier go(1) example, the most important steps are as follows:

  1. The call result is recognized as a temporary coroutine task.

  2. Storing that result binds the temporary argument used by the call to the task’s pset.

  3. The argument temporary reaches the end of its full-expression, so KILL replaces its occurrence in the task pset with invalid.

  4. The later co_await task checks the invalid task and emits the dangling-reference diagnostic.

Post-freeze note (added 26 July 2026).

The incubator implementation does not diagnose a task merely because the task is returned. Its checkReturn() path handles lambda return types only. Coroutine-task diagnostics in the tests occur at a later use such as co_await task.

Temporary vs persistent tasks

Most temporaries in C++ can be ignored for move tracking because they are destroyed at the end of the full expression. However, coroutine task temporaries are an exception because they may be captured by coroutine frames and outlive their expression.

1bool LifetimeCheckPass::isSkippableTemporary(mlir::Value v) {
2  auto allocaOp = v.getDefiningOp<cir::AllocaOp>();
3  if (!allocaOp)
4    return false;
5
6  // Temporaries have "ref.tmp" prefix
7  auto name = allocaOp.getName();
8  if (!name.starts_with("ref.tmp"))
9    return false;
10
11  // IMPORTANT: Do not skip coroutine tasks
12  // They need lifetime tracking even as temporaries
13  if (isTaskType(v))
14    return false;
15
16  return true;  // Other temporaries can be skipped
17}

This distinction allows:

1// Regular temporary - can skip move tracking
2SomeClass x = std::move(SomeClass());  // temporary SomeClass() can be skipped
3
4// Task temporary - must track
5Task<int> createTask(int const &value);
6auto result = co_await createTask(1);  // argument-bearing task call is tracked

Post-freeze note (added 26 July 2026).

Both the argument and its int const & parameter are significant for the frozen implementation: binding the literal creates the local temporary address that checkCoroTaskStore() can add to the task pset. An arbitrary argument passed only by value would not establish that tracked local relation. In addition, checkCall() returns before trackCallToCoroutine() when a call has no argument operands, so a direct zero-argument free coroutine call is not registered through this path. The incubator test’s go(1) call has the same const-reference temporary shape used here.

Lambda captures by reference

Lambdas that capture local variables by reference create similar lifetime issues to coroutine tasks. The checkLambdaCaptureStore() function (covered earlier) binds captured locals to the lambda’s pset.

Common lambda capture bug:

1auto create_lambda() {
2  int local = 42;
3  auto lambda = [&local]() { return local + 1; };
4  // lambda captures local by reference
5  // End of scope: KILL(local) invalidates lambda
6  return lambda;  // WARNING: lambda captures destroyed local
7}

Post-freeze note (added 26 July 2026).

An earlier version labelled a subsequent vec.size() call as a LifetimeCheck warning. The frozen pass does not issue that diagnostic: a system std::vector is not classified as an Owner from its name alone, and an ordinary const size() method would not take the moved-from checking path even on an annotated stand-in. In C++, the call is well-defined because the moved-from vector remains valid, although its value is unspecified. It is therefore language-level motivation, not checker behaviour.

The pass detects the by-reference capture pattern by tracking stores to lambda capture fields and binding the captured locals to the lambda’s pset. When a captured local goes out of scope, the KILL operation propagates through the pset and invalidates the lambda.

Return value safety

Returning references or pointers to local variables is a classic C++ bug. The LifetimeCheck pass handles one important form of this problem: returning lambdas that may capture local references. Plain reference and pointer returns are a natural extension point.

Implementation pattern from LifetimeCheck.cpp:

1void LifetimeCheckPass::checkReturn(ReturnOp retOp) {
2  // Record a returned lambda for the deferred scope-exit check.
3  if (retOp.getNumOperands() == 0)
4    return;
5
6  auto retTy = retOp.getOperand(0).getType();
7  // Currently only handles lambda return types
8  if (!isLambdaType(retTy))
9    return;
10
11  // The return value is loaded from the return slot
12  auto loadOp = retOp.getOperand(0).getDefiningOp<cir::LoadOp>();
13  assert(loadOp && "expected cir.load");
14  if (!loadOp.getAddr().getDefiningOp<cir::AllocaOp>())
15    return;
16
17  // Track lambda for later checking
18  // Actual check happens at scope exit (LexicalScopeGuard)
19  currScope->localRetLambdas.insert(
20      std::make_pair(loadOp.getAddr(), loadOp.getLoc()));
21}

The clever aspect is that the pass does not check the lambda immediately. Instead, it defers the check until the scope ends (in LexicalScopeGuard::cleanup()). This allows it to determine which locals the lambda captured and whether any are being destroyed at scope exit.

From LexicalScopeGuard::cleanup():

1void LifetimeCheckPass::LexicalScopeGuard::cleanup() {
2  auto *localScope = Pass.currScope;
3  // Kill the addresses registered for scope cleanup.
4  for (auto pointee : localScope->localValues)
5    Pass.kill(State::getLocalValue(pointee), InvalidStyle::EndOfScope,
6              getEndLocForHist(*localScope));
7
8  // Check returned lambdas for dangling references
9  for (auto l : localScope->localRetLambdas)
10    Pass.checkPointerDeref(l.first, l.second, DerefStyle::RetLambda);
11}

Example demonstrating return safety checking:

1auto return_dangling_lambda() {
2  int local = 42;
3  auto lambda = [&local]() { return local; };
4  return lambda;  // WARNING: lambda captures local by reference
5  // At scope exit:
6  //   1. KILL(local) invalidates direct local state
7  //      in the lambda pset
8  //   2. checkPointerDeref on lambda detects it's invalid
9}
10
11int& return_reference_to_local() {
12  int local = 42;
13  return local;  // BUG: returning reference to local
14  // Future work for this pass: diagnose plain reference returns too.
15}

The implementation focuses on lambda returns because they are common in modern C++ (especially with std::function and callbacks). One natural extension would be to detect all dangling reference returns, not just lambdas.

Implementation structure

The individual checks in LifetimeCheck are small; the difficulty comes from combining them without changing the state-transition rules. The implementation therefore uses semantic helpers such as isValueTypeMovedFrom(), RAII guards for temporary pmaps and lexical scopes, and early returns for operation patterns that the pass does not handle. These building blocks keep the control-flow handlers focused on the corresponding KILL and JOIN operations.

Limitations and future work

The LifetimeCheck pass has known limitations:

  • Interprocedural analysis: The pass analyzes each function independently. It does not track lifetime across function boundaries, which can lead to false negatives when a dangling pointer is returned from a function.

  • Temporary detection: The pass uses a string prefix (ref.tmp) to identify temporaries. A more robust approach would be adding an is_temporary attribute to AllocaOp during CIRGen.

  • Field-sensitive analysis: The pass tracks first-level aggregate fields that are actually reached through GetMemberOp uses. Nested aggregate fields and unused fields are not fully modeled.

  • Custom smart pointers: The special smart-pointer path recognizes only unique_ptr and shared_ptr names. User-defined smart pointers following the same patterns are not handled specially.

Future improvements could address these by:

  • Adding summary information to function signatures.

  • Enhancing CIRGen to mark temporaries explicitly.

  • Implementing field-sensitive tracking using CIR’s GetMemberOp.

  • Allowing user annotations for custom smart pointer types.

Workflow from C++ source to lifetime diagnostics

The LifetimeCheck pass operates on ClangIR after CIRGen and before lowering to LLVM IR. This is the useful point in the pipeline: the pass still has access to AST semantic information, but it can also use IR structure for data-flow reasoning.

Lessons learned

Studying the LifetimeCheck pass demonstrates several advantages of ClangIR for compiler analysis:

  1. AST attributes preserve semantics: Access to AST information through interfaces makes it easy to answer questions like “Is this parameter an rvalue reference?” or “Is this type a std::unique_ptr?” that would be difficult or impossible with LLVM IR.

  2. High-level operations clarify intent: CIR’s StoreOp, LoadOp, and CallOp carry more semantic meaning than LLVM’s generic instructions, making the analysis code more readable and maintainable.

  3. SSA helps dataflow: CIR’s SSA operands make value flow explicit, and each mlir::Value has exactly one definition. Local object state is still tracked separately through loads, stores, and the pass pmap.

  4. Structured control flow: CIR’s IfOp, SwitchOp, and LoopOp are easier to inspect than LLVM’s unstructured basic block CFG. The analysis must still represent every executable path before it joins the resulting states.

  5. Location tracking: MLIR requires every operation to carry a Location, although that location may be UnknownLoc. When CIR preserves a concrete source location, the pass can attach its diagnostic to the corresponding source construct.

The LifetimeCheck pass serves as a practical template for building CIR analysis passes. The patterns demonstrated—AST attribute access, state tracking, operation visitation, and diagnostic emission—apply broadly to many kinds of static analysis tasks.

Sutter, Herb. 2019. Lifetime safety: Preventing common dangling.” {C++ Standards Committee Paper} P1179R1. ISO/IEC JTC1/SC22/WG21. https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1179r1.pdf.