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 to llvm-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};
LifetimeCheck pass architecture and data flow

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:

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

  2. Pointer: Types that reference memory without owning it (raw pointers, references, and records carrying [[gsl::Pointer]]). Pointers become dangling if their target is destroyed.

  3. Value: Everything else—primitives (int, float), structs without pointer/owner semantics. Values themselves do not dangle.

  4. 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 minimal std::shared_ptr class in the tests is instead declared with [[gsl::Owner(T)]]; therefore isOwnerType() 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 categories produced by the snapshot’s classifier

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:

Conceptual type classification flow. The Owner names in the diagram are P1179 examples; the snapshot recognizes such records only through GSL-style attributes.

The classification process follows these steps in order:

  1. Check if type is a pointer or referenceisPointerType(t) returns true for raw pointers (T*) and references (T&). These types reference memory without owning it. They are categorized as Pointer, added to the ptrs set, and initialized with pmap[addr] = {invalid} to indicate they are uninitialized and must not be dereferenced until assigned.1

  2. Check if type is an OwnerisOwnerType(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 the owners set, and initialized with pmap[addr] = {owned_object} where owned_object is represented as addr’ (“addr prime”) indicating the owner manages a distinct resource.

  3. Check if type is an AggregateisAggregateType(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 from GetMemberOp operations. This allows tracking individual fields with pointer semantics within a larger struct. The pass limits explosion to one level deep to avoid excessive complexity.

  4. Default to Value — All other types (primitives like int, float, plain structs without special semantics) are categorized as Value. They are initialized with pmap[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::move and 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 ++nestLevel from 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. Passing nestLevel + 1 would 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
Points-to set evolution through program execution

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 is p’s AllocaOp, it records p itself. It does not copy pmap[p] into pmap[q]. Because pointer dereference checking is not recursive in this snapshot, this one-hop representation can also miss an invalidation of x reached through q while p remains 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.

Points-to set update algorithm decision flow in updatePointsTo()

The update algorithm handles different data sources:

  1. ConstantOp source — When storing a constant value:

    • If the constant is a null pointer (cstOp.isNullPtr()), set pmap[addr] = {null} using markPsetNull().

    • If the constant is an aggregate (ConstRecordAttr), call updatePointsToForConstRecord() to handle memberwise initialization of fields.

    • Zero initialization (ZeroAttr) for records calls updatePointsToForZeroRecord() to set field psets appropriately.

  2. AllocaOp source — When taking the address of a local variable (p = &x;), set pmap[addr] = {x}. The pointer now references the local variable.

  3. LoadOp source — When the data comes from loading another address, recursively call updatePointsTo(addr, loadOp.getAddr(), loc). If that loaded address is an AllocaOp, the destination records the address itself, as in pmap[q] = {p} above; the source pset is not copied.

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

  5. 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’s State::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 CallOp records the call result and not an owner’s abstract resource. Therefore, an arbitrary call such as owner.get() does not by itself prove that the returned pointer has been related to State::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 operations and their lifetime checking actions
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 this

  • A 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 dispatch at the 9 February snapshot
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()):

  1. Ignore calls without arguments — calls with no arguments cannot move or dereference a tracked local value through an argument, so the pass returns early.

  2. Coroutine tracking — the pass first calls trackCallToCoroutine() to record temporary task values returned by coroutine calls.

  3. Free-function rvalue reference parameter check — the pass calls checkMoveInCallArgs(). This helper resolves the callee with getCalleeFromSymbol(theModule, *callOp.getCallee()), reads an ASTFunctionDeclAttr, and then calls checkArgForRValueRef() for parameters whose type is T&&. A C++ method instead carries an ASTCXXMethodDeclAttr, so this helper returns without processing that method’s parameters in the 9 February snapshot.

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

  5. Special member check — for owner and pointer class methods, the pass resolves the named callee with getCalleeFromSymbol(theModule, *fnName). A constructor calls checkCtor(), which in turn handles move construction through checkMoveCtor(). A move assignment calls checkMoveAssignment(), and a copy assignment calls checkCopyAssignment().

  6. Operator and non-const method checks — overloaded operators are checked with checkOperators(). Other non-const owner method calls invalidate the owner’s old resource with checkNonConstUseOfOwner().

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 an ASTCXXMethodDeclAttr. The example below therefore follows the incubator test exactly in the relevant respects: it uses an annotated test-local std::unique_ptr and 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.

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.