This article continues the discussion from Static Analysis with ClangIR. We now move from motivation to the concrete pass structure. The goal is to see which state the LifetimeCheck pass keeps, how it classifies values, and how common CIR operations update the analysis.
Post-freeze note (added 26 July 2026).
This article is dated 11 February 2026. Its historical LifetimeCheck baseline is ClangIR incubator commit
0c130cda66d7, from 9 February 2026. The incubator was frozen on 20 February 2026. LifetimeCheck remained an incubator-only experiment and was not transferred tollvm-project. Observations added after the freeze are explicitly identified as post-freeze notes. 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.
LifetimeCheck pass architecture
Pass structure and operation visitation
The LifetimeCheck pass LifetimeCheck.cpp
walks CIR operations and tracks the lifetime state of program variables.
The pass is structured as an operation visitor:
1struct LifetimeCheckPass : public LifetimeCheckBase<LifetimeCheckPass> {
2 void runOnOperation() override;
3
4 void checkOperation(Operation *op);
5 void checkStore(StoreOp op);
6 void checkLoad(LoadOp op);
7 void checkCall(CallOp op);
8 // ... other operation handlers
9
10private:
11 // State tracking
12 llvm::DenseMap<mlir::Value, PSet> pmap;
13 llvm::DenseSet<mlir::Value> owners;
14 llvm::DenseSet<mlir::Value> ptrs;
15 // ... other state
16};
The pass processes each operation in the CIR module, updates state
maps (pmap, owners, ptrs), and
emits diagnostics when invalid operations are detected.
Type categories and points-to sets
The lifetime safety profile (Sutter 2019) distinguishes SharedOwner, Owner, Pointer, Indirection, Aggregate, and Value. The classifier in the 9 February snapshot declares the same names, but its decision function returns only Owner, Pointer, Aggregate, or Value. This article therefore starts with the four categories that the implementation actually produces:
Owner: Record types carrying the
[[gsl::Owner]]attribute. They own resources and manage their lifetime. When an owner goes out of scope, it destroys what it owns.Pointer: Types that reference memory without owning it (raw pointers, references, and records carrying
[[gsl::Pointer]]). Pointers become dangling if their target is destroyed.Value: Everything else—primitives (
int,float), structs without pointer/owner semantics. Values themselves do not dangle.Aggregate: record types with pointer-typed fields that the pass explodes and tracks separately.
Post-freeze note (added 26 July 2026).
P1179 gives shared ownership its own SharedOwner category. A later review of the frozen pass found that, although its local enumeration declares
SharedOwner,localStyle()never returns it. The minimalstd::shared_ptrclass in the tests is instead declared with[[gsl::Owner(T)]]; thereforeisOwnerType()sends it through the Owner branch. This is a property of the incubator implementation, not a change to the P1179 taxonomy.
The implementation recognizes raw CIR pointer types directly and
recognizes owner and pointer record types through
[[gsl::Owner(T)]] and [[gsl::Pointer(T)]]. The
test smart-pointer and string-like types are annotated this way. Plain
standard-library smart pointers, containers, strings, views, and
iterators are not inferred by this classifier; treating them as if they
were annotated remains a TODO in the snapshot.
| Kind | Examples | Lifetime issues |
|---|---|---|
| Owner | Annotated unique_ptr<T>
test type |
Can be moved-from (becomes null) |
| Owner | Annotated shared_ptr<T>
test type (P1179: SharedOwner) |
Can be moved-from (becomes null) |
| Owner | Annotated container-like type | Can be moved-from (unspecified state) |
| Owner | Annotated string-like type | Can be moved-from (unspecified state) |
| Pointer | T* |
Can dangle if target destroyed |
| Pointer | T& |
Can dangle if target destroyed |
| Pointer | Annotated view-like type | Can dangle if owner destroyed |
| Pointer | Annotated iterator-like type | Can be invalidated |
| Value | int, float |
Move is a copy; no moved-from state |
| Value | bool, char |
Move is a copy; no moved-from state |
| Value | Plain class types | Can be tracked as moved-from intent warnings |
| Aggregate | Structs with pointer-typed fields | Field pointers can dangle |
Type classification on allocation
When an AllocaOp is encountered,
the pass must categorize the variable into one of the implementation
categories before it can track lifetime state. This classification
happens in classifyAndInitTypeCategories(),
which is called for every local variable declaration.
The classification determines how the variable’s lifetime will be tracked throughout its scope. The algorithm follows a decision tree based on the variable’s type:
The classification process follows these steps in order:
Check if type is a pointer or reference —
isPointerType(t)returns true for raw pointers (T*) and references (T&). These types reference memory without owning it. They are categorized as Pointer, added to theptrsset, and initialized withpmap[addr] = {invalid}to indicate they are uninitialized and must not be dereferenced until assigned.1Check if type is an Owner —
isOwnerType(t)returns true for record types carrying the[[gsl::Owner]]attribute. Annotated smart-pointer test types and custom owners are categorized as Owner, added to theownersset, and initialized withpmap[addr] = {owned_object}whereowned_objectis represented asaddr’(“addr prime”) indicating the owner manages a distinct resource.Check if type is an Aggregate —
isAggregateType(t)returns true for non-lambda record types that contain pointer-typed members. This is a deliberately narrower implementation rule than the full P1179 aggregate definition. The pass performs field explosion by tracking member addresses obtained fromGetMemberOpoperations. This allows tracking individual fields with pointer semantics within a larger struct. The pass limits explosion to one level deep to avoid excessive complexity.Default to Value — All other types (primitives like
int,float, plain structs without special semantics) are categorized as Value. They are initialized withpmap[addr] = {addr}, meaning the value points to itself.
This categorization is critical because it determines the subsequent tracking behavior:
Owners can be moved-from (becoming invalid) or destroyed (invalidating directly tracked psets that mention the owned resource).
Pointers can become dangling when their target is destroyed, and must be checked on every dereference.
Values cannot dangle because they do not reference external memory. Class-type values may still be tracked as moved-from intent warnings; scalar values are copied by
std::moveand have no language-level moved-from state.
Here is the implementation pattern from LifetimeCheck.cpp,
simplified:
1void LifetimeCheckPass::classifyAndInitTypeCategories(
2 mlir::Value addr, mlir::Type t, mlir::Location loc,
3 unsigned nestLevel) {
4 getPmap()[addr] = {}; // Initialize empty pset
5
6 enum TypeCategory {
7 Unknown = 0,
8 SharedOwner = 1,
9 Owner = 1 << 2,
10 Pointer = 1 << 3,
11 Indirection = 1 << 4,
12 Aggregate = 1 << 5,
13 Value = 1 << 6,
14 };
15
16 // SharedOwner and Indirection are declared but not selected here.
17 auto localStyle = [&]() {
18 if (isPointerType(t))
19 return TypeCategory::Pointer;
20 if (isOwnerType(t))
21 return TypeCategory::Owner;
22 if (isAggregateType(this, t))
23 return TypeCategory::Aggregate;
24 return TypeCategory::Value;
25 }();
26
27 switch (localStyle) {
28 case TypeCategory::Pointer:
29 // Add to pointer set and mark as uninitialized
30 ptrs.insert(addr);
31 markPsetInvalid(addr, InvalidStyle::NotInitialized, loc);
32 break;
33
34 case TypeCategory::Owner:
35 // Add to owner set and initialize with owned object
36 addOwner(addr);
37 getPmap()[addr].insert(State::getOwnedBy(addr));
38 currScope->localValues.insert(addr);
39 break;
40
41 case TypeCategory::Aggregate: {
42 // Only track first level of aggregate fields
43 if (nestLevel > 1)
44 break;
45
46 auto members = mlir::cast<cir::RecordType>(t).getMembers();
47 llvm::SmallVector<mlir::Value, 4> fieldVals;
48 fieldVals.assign(members.size(), {});
49
50 // Track fields accessed via GetMemberOp
51 std::for_each(addr.use_begin(), addr.use_end(), [&](auto &use) {
52 auto op = dyn_cast<cir::GetMemberOp>(use.getOwner());
53 if (!op || op.getResult().use_empty())
54 return;
55 // Recursively classify each field
56 auto eltAddr = op.getResult();
57 auto eltTy = eltAddr.getType().getPointee();
58 classifyAndInitTypeCategories(eltAddr, eltTy, loc, ++nestLevel);
59 fieldVals[op.getIndex()] = eltAddr;
60 });
61
62 addAggregate(addr, fieldVals);
63
64 // Pointers can refer to the aggregate itself, so also create its value.
65 LLVM_FALLTHROUGH;
66 }
67 case TypeCategory::Value:
68 // Initialize to point to itself
69 getPmap()[addr].insert(State::getLocalValue(addr));
70 currScope->localValues.insert(addr);
71 break;
72
73 default:
74 llvm_unreachable("NYI");
75 }
76}
Post-freeze note (added 26 July 2026).
The frozen listing literally passes
++nestLevelfrom the lambda that visits field uses. Despite the preceding “first level” comment, this mutates the captured counter across sibling fields; after one visited field, a later aggregate-valued sibling can be skipped depending on use order. PassingnestLevel + 1would express the intended per-child depth without mutating sibling state, but the historical listing above keeps the snapshot code.
The key insight is that this single categorization decision made at allocation time drives all subsequent lifetime analysis for the variable. For example, once a variable is classified as an Owner, the pass knows to track move operations and invalidate dependent pointers when it goes out of scope.
For each address being tracked, the pass maintains a points-to set (pset) that records what that address points to at that program point:
1// Maps from address (mlir::Value) to what it points to
2llvm::DenseMap<mlir::Value, PSet> pmap;
3
4// PSet can contain:
5// - Concrete addresses (what this pointer points to)
6// - nullptr (known null)
7// - invalid (dangling or moved-from)
Example state tracking:
1int x = 42;
2int *p = &x;
3int *q = p;
4
5// After these operations:
6// pmap[x] = {x} // x points to itself (it's a value)
7// pmap[p] = {x} // p directly records x
8// pmap[q] = {p} // the loaded address is recorded one hop deep
| Statement | Operation | State after execution |
|---|---|---|
int x = 42; |
AllocaOp + StoreOp | pmap[x] = {x} |
owners: {}, ptrs: {} |
||
int *p = &x; |
AllocaOp + StoreOp | pmap[x] = {x}, pmap[p] = {x} |
owners: {}, ptrs: {p} |
||
int *q = p; |
LoadOp + StoreOp | pmap[x] = {x} |
pmap[p] = {x} |
||
pmap[q] = {p} |
||
owners: {}, ptrs: {p, q} |
||
int val = *p; |
Dereference LoadOp | OK: p still points to
x |
This table demonstrates the one-hop state recorded by the frozen
pass. The direct pointer p records x; copying
p makes q record the address p,
rather than copying p’s pset. A later dereference of
p is still valid. Owner and value moves are the cases where
the source may become invalid. The implementation shown here is more
conservative for an address passed to an rvalue-reference parameter:
after checking whether the Pointer-category value is already invalid,
checkArgForRValueRef()
marks it moved-from.2
Post-freeze note (added 26 July 2026).
A later audit corrected an earlier idealized pointer-copy example. For a
LoadOp,updatePointsTo()recurses on the loaded address; when that address isp’sAllocaOp, it recordspitself. It does not copypmap[p]intopmap[q]. Because pointer dereference checking is not recursive in this snapshot, this one-hop representation can also miss an invalidation ofxreached throughqwhilepremains in scope.
Points-to set updates
The core of lifetime tracking is updating points-to sets when values
are stored. The updatePointsTo()
function handles multiple cases depending on the source of the data
being stored. In the frozen checkStore() path
it is used for Pointer-category destinations and for constant or zero
initialization of exploded aggregate fields; it is not a generic path
for every C++ assignment.
updatePointsTo()The update algorithm handles different data sources:
ConstantOp source — When storing a constant value:
If the constant is a null pointer (
cstOp.isNullPtr()), setpmap[addr] = {null}usingmarkPsetNull().If the constant is an aggregate (
ConstRecordAttr), callupdatePointsToForConstRecord()to handle memberwise initialization of fields.Zero initialization (
ZeroAttr) for records callsupdatePointsToForZeroRecord()to set field psets appropriately.
AllocaOp source — When taking the address of a local variable (
p = &x;), setpmap[addr] = {x}. The pointer now references the local variable.LoadOp source — When the data comes from loading another address, recursively call
updatePointsTo(addr, loadOp.getAddr(), loc). If that loaded address is anAllocaOp, the destination records the address itself, as inpmap[q] = {p}above; the source pset is not copied.CallOp source — When the data is a function call result (for example, an iterator returned by
begin()), associate the destination with a local-value state for the call result. The tracked state names the call result itself.Other sources — Operations like
PtrStrideOp(pointer arithmetic used for array subscripting),GetElementOp(array-element address), or undefined values may require special handling or can be safely ignored depending on context.
The ordinary store path reaches this routine for Pointer-category
destinations. Aggregate constant and zero initialization is the
exception: checkStore() also
calls it to update exploded pointer fields. These cases do not all
represent an ownership transfer:
Pointer stores record a direct source address. Address-of stores record the pointee, while a store fed by loading another pointer records that source pointer one hop deep rather than copying its pset.
Call-result stores associate the destination with the SSA result produced by the call.
Owner moves are handled by call-operation checks, which mark the source moved-from. The
updatePointsTo()function does not transfer an Owner’sState::getOwnedBy(owner)relation to the destination.Value stores are checked separately for moved-from uses.
Here is the implementation pattern from LifetimeCheck.cpp,
simplified:
1void LifetimeCheckPass::updatePointsTo(mlir::Value addr,
2 mlir::Value data,
3 mlir::Location loc) {
4 auto dataSrcOp = data.getDefiningOp();
5
6 // Handle function arguments (block arguments from entry block)
7 if (!dataSrcOp) {
8 auto blockArg = cast<BlockArgument>(data);
9 if (!blockArg.getOwner()->isEntryBlock())
10 return;
11 getPmap()[addr].clear();
12 getPmap()[addr].insert(State::getLocalValue(data));
13 return;
14 }
15
16 // Ignore bitcasts and get actual source operation
17 dataSrcOp = ignoreBitcasts(dataSrcOp);
18
19 // Handle constant initialization
20 if (auto cstOp = dyn_cast<ConstantOp>(dataSrcOp)) {
21 // For aggregates, update fields individually
22 if (aggregates.count(addr)) {
23 if (auto constRecord =
24 mlir::dyn_cast<cir::ConstRecordAttr>(cstOp.getValue())) {
25 updatePointsToForConstRecord(addr, constRecord, loc);
26 return;
27 }
28 if (auto zero = mlir::dyn_cast<cir::ZeroAttr>(cstOp.getValue())) {
29 if (auto zeroRecordTy = dyn_cast<RecordType>(zero.getType())) {
30 updatePointsToForZeroRecord(addr, zeroRecordTy, loc);
31 return;
32 }
33 }
34 return;
35 }
36
37 // Null pointer initialization
38 assert(cstOp.isNullPtr() && "other than null not implemented");
39 markPsetNull(addr, loc);
40 return;
41 }
42
43 // Taking address of local variable: p = &x;
44 if (auto allocaOp = dyn_cast<AllocaOp>(dataSrcOp)) {
45 getPmap()[addr].clear();
46 getPmap()[addr].insert(State::getLocalValue(allocaOp.getAddr()));
47 return;
48 }
49
50 // Array subscript: p = &a[0];
51 if (auto ptrStrideOp = dyn_cast<PtrStrideOp>(dataSrcOp)) {
52 auto array = getArrayFromSubscript(ptrStrideOp);
53 if (array) {
54 getPmap()[addr].clear();
55 getPmap()[addr].insert(State::getLocalValue(array));
56 }
57 return;
58 }
59
60 // Pointer to an element remains related to the base.
61 if (auto getElemOp = dyn_cast<GetElementOp>(dataSrcOp)) {
62 getPmap()[addr].clear();
63 getPmap()[addr].insert(State::getLocalValue(getElemOp.getBase()));
64 return;
65 }
66
67 // Iterator/pointer from method calls: iter = vec.begin()
68 if (auto callOp = dyn_cast<CallOp>(dataSrcOp)) {
69 getPmap()[addr].clear();
70 getPmap()[addr].insert(State::getLocalValue(callOp.getResult()));
71 }
72
73 // Handle indirections through loads (e.g., temporaries copying 'this')
74 if (auto loadOp = dyn_cast<LoadOp>(dataSrcOp)) {
75 updatePointsTo(addr, loadOp.getAddr(), loc);
76 return;
77 }
78}
The key insight is that updatePointsTo()
translates CIR operations into abstract points-to relationships. For
example, when it sees AllocaOp, it
knows this represents taking an address, so it creates a points-to
relationship. When it sees LoadOp,
it recurses on the loaded address. If that address is an AllocaOp, the destination records the
address itself; it does not propagate the source address’s pset.
Post-freeze note (added 26 July 2026).
A later review of this case found that
CallOprecords the call result and not an owner’s abstract resource. Therefore, an arbitrary call such asowner.get()does not by itself prove that the returned pointer has been related toState::getOwnedBy(owner).
This abstraction layer allows the rest of the analysis to work with high-level points-to sets rather than low-level IR operations, greatly simplifying the checking logic.
| CIR operation | Tracked by | Lifetime action |
|---|---|---|
AllocaOp |
alloca check | Categorize variable, initialize pset |
StoreOp |
store check | Update pointer psets; diagnose an already-invalid Value source; apply the Value-to-Value invalidation heuristic |
LoadOp |
load check | Check loading from a moved-from Value; ignore a non-dereference Pointer load |
LoadOp(isDeref) |
load check | Check pointer dereference validity |
CallOp (move ctor) |
move ctor check | Mark source as moved-from |
CallOp (Owner move assign) |
move assignment | Invalidate relationships to the destination’s old resource; mark source moved-from |
CallOp (Pointer move assign) |
move assignment | Copy source pset to destination; mark source moved-from |
CallOp (free-function rvalue ref) |
rvalue-ref args | Check source, classify category, then mark conservatively |
CallOp (method) |
method dispatch | Dispatch overloaded operators or ordinary methods |
IfOp |
branch merge | Merge then/else states conservatively |
Call operation dispatch
The CallOp operation is the most
complex to analyze because a single function call in C++ can have many
different semantics depending on what is being called. A CallOp could represent:
A move constructor (a source-language ownership transfer; the checker marks the source moved-from)
A copy constructor (recognized, but not implemented in this snapshot)
A move assignment operator (a source-language transfer; the checker invalidates tracked relationships and marks the source moved-from)
An operator method on an owner/pointer (
operator*,operator->)A regular function with rvalue reference parameters (indicating moves)
A method call requiring validity checks on
thisA coroutine call requiring task tracking
The checkCall() method
dispatches to specialized handlers based on what the call represents.
Understanding this dispatch is crucial because lifetime bugs often occur
through function calls.
| Call category | Snapshot dispatch |
|---|---|
| Every call with arguments | Run coroutine-result tracking, then attempt the free-function rvalue-reference check; method AST attributes are not accepted by that helper. |
| Non-Owner/Pointer call | Run the general method and function argument checks. |
| Constructor on Owner/Pointer | Run checkCtor(); a move
constructor continues to checkMoveCtor(), while copy
construction is NYI. |
| Assignment on Owner/Pointer | Dispatch move assignment to
checkMoveAssignment() and copy assignment to
checkCopyAssignment(). |
| Overloaded operator on tracked Owner/Pointer | Run checkOperators(). |
| Other Owner method | For a non-const use, run
checkNonConstUseOfOwner(). |
| Other Pointer method | Run the pointer-dereference check. |
The implementation uses the following dispatch sequence (simplified
from checkCall()):
Ignore calls without arguments — calls with no arguments cannot move or dereference a tracked local value through an argument, so the pass returns early.
Coroutine tracking — the pass first calls
trackCallToCoroutine()to record temporary task values returned by coroutine calls.Free-function rvalue reference parameter check — the pass calls
checkMoveInCallArgs(). This helper resolves the callee withgetCalleeFromSymbol(theModule, *callOp.getCallee()), reads anASTFunctionDeclAttr, and then callscheckArgForRValueRef()for parameters whose type isT&&. A C++ method instead carries anASTCXXMethodDeclAttr, so this helper returns without processing that method’s parameters in the 9 February snapshot.General method/function checks — if the call is not a method on an owner or pointer category, the pass calls
checkOtherMethodsAndFunctions()to validate tracked arguments conservatively.Special member check — for owner and pointer class methods, the pass resolves the named callee with
getCalleeFromSymbol(theModule, *fnName). A constructor callscheckCtor(), which in turn handles move construction throughcheckMoveCtor(). A move assignment callscheckMoveAssignment(), and a copy assignment callscheckCopyAssignment().Operator and non-const method checks — overloaded operators are checked with
checkOperators(). Other non-const owner method calls invalidate the owner’s old resource withcheckNonConstUseOfOwner().
Key insight: the dispatch stages run in sequence, but the declaration
attribute determines which stage can act. A free-function call may be
examined first by checkMoveInCallArgs()
and then by the general argument checks. Owner and Pointer methods
bypass that rvalue-reference helper and continue through the
special-member, operator, or ordinary-method paths. A move constructor
is dispatched through checkCtor(), which
calls checkMoveCtor()
when the constructor attribute identifies a move constructor.
Example code demonstrating multiple dispatch paths:
Post-freeze note (added 26 July 2026).
A later audit found that an earlier Owner-method example could not reach
checkArgForRValueRef()in the frozen implementation because its callee carried anASTCXXMethodDeclAttr. The example below therefore follows the incubator test exactly in the relevant respects: it uses an annotated test-localstd::unique_ptrand passes it to a free function with an rvalue-reference parameter.
1namespace std {
2template <typename T>
3T &&move(T &value) {
4 return static_cast<T &&>(value);
5}
6
7template <typename T>
8struct [[gsl::Owner(T)]] unique_ptr {
9 explicit unique_ptr(T *);
10 unique_ptr(unique_ptr &&);
11 T &operator*() const;
12};
13} // namespace std
14
15void consume_unique_ptr(std::unique_ptr<int> &&) {}
16
17void example() {
18 std::unique_ptr<int> p(new int(42));
19 consume_unique_ptr(std::move(p)); // checkMoveInCallArgs,
20 // checkArgForRValueRef
21 *p; // checkOperators -> ERROR
22}
The dispatch mechanism demonstrates why ClangIR’s AST attributes are useful: without them, distinguishing a move constructor from a regular constructor, or identifying rvalue reference parameters, would be extremely difficult at the IR level.
Conclusion
The pass architecture gives us the basic language of the analysis: owners, pointers, values, points-to sets, and operation handlers. The next article, AST Semantics and Use-After-Move Detection, shows how this state model becomes precise when we combine it with AST attributes and C++ move semantics.
Discussion
Register with a username and password to join the discussion.