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
0c130cda66d7from 9 February 2026. The ClangIR incubator was frozen on 20 February 2026. LifetimeCheck remained an incubator-only experimental pass and was not transferred tollvm-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.
| 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:
Aggregate stores — If the destination is an aggregate type (a record tracked in the
aggregatesmap), callupdatePointsTo()only when the source is aConstantOp, to update exploded field psets. Return after this branch even when the source is not constant.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.Non-Pointer stores — If the stored value is a tracked coroutine task temporary, call
checkCoroTaskStore()to bind its captured locals. Otherwise, callcheckLambdaCaptureStore()to handle potential lambda captures by reference. Return after this branch.Pointer stores — For a Pointer-category destination, call
updatePointsTo()to update its pset. This is the standard path for a store such asptr = &x;.
Post-freeze note (added 26 July 2026).
A later audit corrected three overstatements in the earlier overview:
StoreOpdoes not represent every C++ assignment, aggregate destinations return before the Value heuristic, andcheckMovedFromValue()does not prove an rvalue initialization. Its inability to distinguishstd::movefrom 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).
checkLoad()The load logic follows this shape:
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.
Pointer type handling — If the address is a pointer type:
If
isDerefisfalse, this is loading the pointer value itself (e.g., copying a pointer), which is safe. Return.If
isDerefistrue, this is dereferencing the pointer (e.g.,*ptr). CallcheckPointerDeref()to verify it is not invalid or null.
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_ptrspelling 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
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}
| 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:
Never taken — The loop condition is false from the start, so the body never executes. The pmap remains unchanged from before the loop.
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.
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*/ }
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.
The JOIN algorithm (P1179 §2.3) (Sutter 2019) is simple but powerful:
For each tracked address
addrin the pmap, collect its pset from each branch being merged.Compute the set union of all these psets.
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 containsinvalid, 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
defaultand 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 ®ion) {
2 // Create new scope
3 LexicalScopeContext lexScope{®ion};
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.
| 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,
owneris removed from the Owner, Pointer, and task category sets. Its own pmap entry is not erased bykill()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 frozenkill()body skips the map entry whose key equalsownerand 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()orvec.data()is recorded as the call result itself; the pass does not derive anState::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.,
&xwherexis 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
localValuescleanup 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
localValuesduring 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, ®ionPmap};
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.
| 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 tocallOp.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:
The call result is recognized as a temporary coroutine task.
Storing that result binds the temporary argument used by the call to the task’s pset.
The argument temporary reaches the end of its full-expression, so KILL replaces its occurrence in the task pset with
invalid.The later
co_await taskchecks 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 asco_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 ¶meter are significant for the frozen implementation: binding the literal creates the local temporary address thatcheckCoroTaskStore()can add to the task pset. An arbitrary argument passed only by value would not establish that tracked local relation. In addition,checkCall()returns beforetrackCallToCoroutine()when a call has no argument operands, so a direct zero-argument free coroutine call is not registered through this path. The incubator test’sgo(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 systemstd::vectoris not classified as an Owner from its name alone, and an ordinary constsize()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 anis_temporaryattribute to AllocaOp during CIRGen.Field-sensitive analysis: The pass tracks first-level aggregate fields that are actually reached through
GetMemberOpuses. Nested aggregate fields and unused fields are not fully modeled.Custom smart pointers: The special smart-pointer path recognizes only
unique_ptrandshared_ptrnames. 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.
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:
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.
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.
SSA helps dataflow: CIR’s SSA operands make value flow explicit, and each
mlir::Valuehas exactly one definition. Local object state is still tracked separately through loads, stores, and the pass pmap.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.
Location tracking: MLIR requires every operation to carry a
Location, although that location may beUnknownLoc. 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.
Discussion
Register with a username and password to join the discussion.