[clang] [LifetimeSafety] Model scope-exit destructor and cleanup callback as a use (PR #204650)
Gábor Horváth via cfe-commits
cfe-commits at lists.llvm.org
Mon Jun 29 07:54:07 PDT 2026
https://github.com/Xazax-hun updated https://github.com/llvm/llvm-project/pull/204650
>From 29933ec455d0074028cd5acb9d132ce313db1c9d Mon Sep 17 00:00:00 2001
From: Gabor Horvath <gaborh at apple.com>
Date: Thu, 18 Jun 2026 18:39:11 +0100
Subject: [PATCH] [LifetimeSafety] Model scope-exit destructor and cleanup
callback as a use
A non-trivial destructor or __attribute__((cleanup(fn))) callback running at
scope exit may read a borrow the object holds (e.g. a [[gsl::Pointer]] whose
out-of-line ~T() or cleanup function dereferences its captured view). The
analysis never sees those bodies, so model the action as a use of the object,
keeping the borrow live to that point: a borrowed-from object destroyed earlier
(reverse-declaration order) is now reported instead of silently dangling.
The implicit use has no source expression, so UseFact gains a SourceLocation
anchor and reportUseAfterScope/reportUseAfterInvalidation gain SourceLocation
overloads for the "used here" note. Owners are excluded from the destructor
case (their destruction frees their own storage, already modeled by the
ExpireFact).
Assisted-by: Claude Opus 4.8
---
.../Analysis/Analyses/LifetimeSafety/Facts.h | 14 +++++
.../Analyses/LifetimeSafety/FactsGenerator.h | 2 +
.../Analyses/LifetimeSafety/LifetimeSafety.h | 14 +++++
clang/lib/Analysis/LifetimeSafety/Checker.cpp | 20 +++++-
.../LifetimeSafety/FactsGenerator.cpp | 33 ++++++++++
.../Analysis/LifetimeSafety/LiveOrigins.cpp | 2 +-
clang/lib/Sema/SemaLifetimeSafety.h | 43 +++++++++++++
.../Sema/LifetimeSafety/invalidations.cpp | 50 +++++++++++++++
clang/test/Sema/LifetimeSafety/safety.cpp | 61 +++++++++++++++++++
9 files changed, 236 insertions(+), 3 deletions(-)
diff --git a/clang/include/clang/Analysis/Analyses/LifetimeSafety/Facts.h b/clang/include/clang/Analysis/Analyses/LifetimeSafety/Facts.h
index 5c671a93b149c..4e1188d5899d5 100644
--- a/clang/include/clang/Analysis/Analyses/LifetimeSafety/Facts.h
+++ b/clang/include/clang/Analysis/Analyses/LifetimeSafety/Facts.h
@@ -240,16 +240,30 @@ class UseFact : public Fact {
// True if this use is a write operation (e.g., left-hand side of assignment).
// Write operations are exempted from use-after-free checks.
bool IsWritten = false;
+ // For an implicit use with no source expression (a scope-exit destructor or
+ // cleanup callback reading a borrow): the location to anchor diagnostics at.
+ SourceLocation ImplicitLoc;
public:
static bool classof(const Fact *F) { return F->getKind() == Kind::Use; }
UseFact(const Expr *UseExpr, const OriginList *OList)
: Fact(Kind::Use), UseExpr(UseExpr), OList(OList) {}
+ UseFact(SourceLocation ImplicitLoc, const OriginList *OList)
+ : Fact(Kind::Use), UseExpr(nullptr), OList(OList),
+ ImplicitLoc(ImplicitLoc) {}
const OriginList *getUsedOrigins() const { return OList; }
void setUsedOrigins(const OriginList *NewList) { OList = NewList; }
const Expr *getUseExpr() const { return UseExpr; }
+ /// True if this use has no source expression; use getImplicitLoc() instead.
+ bool isImplicit() const { return UseExpr == nullptr; }
+ SourceLocation getImplicitLoc() const { return ImplicitLoc; }
+ /// The location to anchor diagnostics at: the use expression's location, or
+ /// the explicit anchor location for an implicit use (no source expression).
+ SourceLocation getUseLoc() const {
+ return UseExpr ? UseExpr->getExprLoc() : ImplicitLoc;
+ }
void markAsWritten() { IsWritten = true; }
bool isWritten() const { return IsWritten; }
diff --git a/clang/include/clang/Analysis/Analyses/LifetimeSafety/FactsGenerator.h b/clang/include/clang/Analysis/Analyses/LifetimeSafety/FactsGenerator.h
index 9821078ec1d1e..4a566fdc89a67 100644
--- a/clang/include/clang/Analysis/Analyses/LifetimeSafety/FactsGenerator.h
+++ b/clang/include/clang/Analysis/Analyses/LifetimeSafety/FactsGenerator.h
@@ -86,6 +86,8 @@ class FactsGenerator : public ConstStmtVisitor<FactsGenerator> {
void handleLifetimeEnds(const CFGLifetimeEnds &LifetimeEnds);
+ void handleCleanupFunction(const CFGCleanupFunction &CleanupFunction);
+
void handleFullExprCleanup(const CFGFullExprCleanup &FullExprCleanup);
void handleExitBlock();
diff --git a/clang/include/clang/Analysis/Analyses/LifetimeSafety/LifetimeSafety.h b/clang/include/clang/Analysis/Analyses/LifetimeSafety/LifetimeSafety.h
index 80a23f18baebd..831f1f9969c37 100644
--- a/clang/include/clang/Analysis/Analyses/LifetimeSafety/LifetimeSafety.h
+++ b/clang/include/clang/Analysis/Analyses/LifetimeSafety/LifetimeSafety.h
@@ -68,6 +68,12 @@ class LifetimeSafetySemaHelper {
const Expr *MovedExpr,
SourceLocation FreeLoc,
llvm::ArrayRef<const Expr *> ExprChain) {}
+ // Variant for an implicit use with no source expression; `UseLoc` anchors the
+ // "used here" note.
+ virtual void reportUseAfterScope(const Expr *IssueExpr, SourceLocation UseLoc,
+ const Expr *MovedExpr,
+ SourceLocation FreeLoc,
+ llvm::ArrayRef<const Expr *> ExprChain) {}
virtual void reportUseAfterReturn(const Expr *IssueExpr,
const Expr *ReturnExpr,
@@ -91,6 +97,14 @@ class LifetimeSafetySemaHelper {
virtual void reportUseAfterInvalidation(const ParmVarDecl *PVD,
const Expr *UseExpr,
const Expr *InvalidationExpr) {}
+ // Variants for an implicit use with no source expression; `UseLoc` anchors
+ // the "used here" note.
+ virtual void reportUseAfterInvalidation(const Expr *IssueExpr,
+ SourceLocation UseLoc,
+ const Expr *InvalidationExpr) {}
+ virtual void reportUseAfterInvalidation(const ParmVarDecl *PVD,
+ SourceLocation UseLoc,
+ const Expr *InvalidationExpr) {}
virtual void reportInvalidatedField(const Expr *IssueExpr,
const FieldDecl *Field,
const Expr *InvalidationExpr) {}
diff --git a/clang/lib/Analysis/LifetimeSafety/Checker.cpp b/clang/lib/Analysis/LifetimeSafety/Checker.cpp
index 746ebbfb15c39..0b4d8f90b0016 100644
--- a/clang/lib/Analysis/LifetimeSafety/Checker.cpp
+++ b/clang/lib/Analysis/LifetimeSafety/Checker.cpp
@@ -73,7 +73,7 @@ class LifetimeChecker {
static SourceLocation
GetFactLoc(llvm::PointerUnion<const UseFact *, const OriginEscapesFact *> F) {
if (const auto *UF = F.dyn_cast<const UseFact *>())
- return UF->getUseExpr()->getExprLoc();
+ return UF->getUseLoc();
if (const auto *OEF = F.dyn_cast<const OriginEscapesFact *>()) {
if (auto *ReturnEsc = dyn_cast<ReturnEscapeFact>(OEF))
return ReturnEsc->getReturnExpr()->getExprLoc();
@@ -258,7 +258,23 @@ class LifetimeChecker {
SourceLocation ExpiryLoc = Warning.ExpiryLoc;
if (const auto *UF = CausingFact.dyn_cast<const UseFact *>()) {
- if (Warning.InvalidatedByExpr) {
+ // An implicit use has no source expression; anchor diagnostics at its
+ // location.
+ if (UF->isImplicit()) {
+ if (Warning.InvalidatedByExpr) {
+ if (IssueExpr)
+ SemaHelper->reportUseAfterInvalidation(
+ IssueExpr, UF->getImplicitLoc(), Warning.InvalidatedByExpr);
+ else if (InvalidatedPVD)
+ SemaHelper->reportUseAfterInvalidation(InvalidatedPVD,
+ UF->getImplicitLoc(),
+ Warning.InvalidatedByExpr);
+ } else {
+ SemaHelper->reportUseAfterScope(
+ IssueExpr, UF->getImplicitLoc(), MovedExpr, ExpiryLoc,
+ getExprChain(LoanPropagation.buildOriginFlowChain(UF, LID)));
+ }
+ } else if (Warning.InvalidatedByExpr) {
if (IssueExpr)
// Use-after-invalidation of an object on stack.
SemaHelper->reportUseAfterInvalidation(IssueExpr, UF->getUseExpr(),
diff --git a/clang/lib/Analysis/LifetimeSafety/FactsGenerator.cpp b/clang/lib/Analysis/LifetimeSafety/FactsGenerator.cpp
index 8358c69a5165a..f05539649bf21 100644
--- a/clang/lib/Analysis/LifetimeSafety/FactsGenerator.cpp
+++ b/clang/lib/Analysis/LifetimeSafety/FactsGenerator.cpp
@@ -138,6 +138,9 @@ void FactsGenerator::run() {
else if (std::optional<CFGLifetimeEnds> LifetimeEnds =
Element.getAs<CFGLifetimeEnds>())
handleLifetimeEnds(*LifetimeEnds);
+ else if (std::optional<CFGCleanupFunction> CleanupFunction =
+ Element.getAs<CFGCleanupFunction>())
+ handleCleanupFunction(*CleanupFunction);
else if (std::optional<CFGFullExprCleanup> FullExprCleanup =
Element.getAs<CFGFullExprCleanup>()) {
handleFullExprCleanup(*FullExprCleanup);
@@ -822,6 +825,20 @@ void FactsGenerator::handleLifetimeEnds(const CFGLifetimeEnds &LifetimeEnds) {
const VarDecl *LifetimeEndsVD = LifetimeEnds.getVarDecl();
if (!LifetimeEndsVD)
return;
+ // A non-trivial destructor at scope exit may read a borrow the object holds
+ // (e.g. a [[gsl::Pointer]] whose out-of-line ~T() dereferences its view). The
+ // analysis never sees that body, so model the destruction as a use, keeping
+ // the borrow live to that point so a borrowed-from object destroyed earlier
+ // (reverse-declaration order) is reported. Owners are excluded: their
+ // destruction frees their own storage (modeled by the ExpireFact), not a
+ // borrow into another object.
+ QualType VDTy = LifetimeEndsVD->getType();
+ if (const CXXRecordDecl *RD = VDTy->getAsCXXRecordDecl();
+ RD && RD->hasDefinition() && RD->hasNonTrivialDestructor() &&
+ !isGslOwnerType(VDTy) && hasOrigins(VDTy))
+ if (OriginList *List = getOriginsList(*LifetimeEndsVD))
+ CurrentBlockFacts.push_back(FactMgr.createFact<UseFact>(
+ LifetimeEnds.getTriggerStmt()->getEndLoc(), List));
// Expire the origin when its variable's lifetime ends to ensure liveness
// doesn't persist through loop back-edges.
std::optional<OriginID> ExpiredOID;
@@ -837,6 +854,22 @@ void FactsGenerator::handleLifetimeEnds(const CFGLifetimeEnds &LifetimeEnds) {
ExpiredOID));
}
+void FactsGenerator::handleCleanupFunction(
+ const CFGCleanupFunction &CleanupFunction) {
+ // A variable with __attribute__((cleanup(fn))) has fn(&var) called at scope
+ // exit; like a non-trivial destructor, that callback may read a borrow the
+ // variable holds, so model it as a use of the variable. Unlike the destructor
+ // case, owners are not excluded: the cleanup callback receives the variable's
+ // address explicitly and may read whatever it holds, regardless of whether
+ // the variable is a gsl::Owner.
+ const VarDecl *VD = CleanupFunction.getVarDecl();
+ if (!VD || !hasOrigins(VD->getType()))
+ return;
+ if (OriginList *List = getOriginsList(*VD))
+ CurrentBlockFacts.push_back(
+ FactMgr.createFact<UseFact>(VD->getLocation(), List));
+}
+
void FactsGenerator::handleFullExprCleanup(
const CFGFullExprCleanup &FullExprCleanup) {
for (const auto *MTE : FullExprCleanup.getExpiringMTEs())
diff --git a/clang/lib/Analysis/LifetimeSafety/LiveOrigins.cpp b/clang/lib/Analysis/LifetimeSafety/LiveOrigins.cpp
index 69b903c813555..70df8100bce5b 100644
--- a/clang/lib/Analysis/LifetimeSafety/LiveOrigins.cpp
+++ b/clang/lib/Analysis/LifetimeSafety/LiveOrigins.cpp
@@ -56,7 +56,7 @@ struct Lattice {
static SourceLocation GetFactLoc(CausingFactType F) {
if (const auto *UF = F.dyn_cast<const UseFact *>())
- return UF->getUseExpr()->getExprLoc();
+ return UF->getUseLoc();
if (const auto *OEF = F.dyn_cast<const OriginEscapesFact *>()) {
if (auto *ReturnEsc = dyn_cast<ReturnEscapeFact>(OEF))
return ReturnEsc->getReturnExpr()->getExprLoc();
diff --git a/clang/lib/Sema/SemaLifetimeSafety.h b/clang/lib/Sema/SemaLifetimeSafety.h
index 3b9ea9eafbec5..895ad959108bc 100644
--- a/clang/lib/Sema/SemaLifetimeSafety.h
+++ b/clang/lib/Sema/SemaLifetimeSafety.h
@@ -127,6 +127,27 @@ class LifetimeSafetySemaHelperImpl : public LifetimeSafetySemaHelper {
<< UseExpr->getSourceRange();
}
+ void reportUseAfterScope(const Expr *IssueExpr, SourceLocation UseLoc,
+ const Expr *MovedExpr, SourceLocation FreeLoc,
+ llvm::ArrayRef<const Expr *> ExprChain) override {
+ unsigned DiagID = MovedExpr
+ ? diag::warn_lifetime_safety_use_after_scope_moved
+ : diag::warn_lifetime_safety_use_after_scope;
+ std::string DestroyedSubject = getDiagSubjectDescription(IssueExpr);
+
+ S.Diag(IssueExpr->getExprLoc(), DiagID)
+ << DestroyedSubject << IssueExpr->getSourceRange();
+ if (MovedExpr)
+ S.Diag(MovedExpr->getExprLoc(), diag::note_lifetime_safety_moved_here)
+ << MovedExpr->getSourceRange();
+ S.Diag(FreeLoc, diag::note_lifetime_safety_destroyed_here)
+ << DestroyedSubject;
+
+ reportAliasingChain(ExprChain);
+
+ S.Diag(UseLoc, diag::note_lifetime_safety_used_here);
+ }
+
void reportUseAfterReturn(const Expr *IssueExpr, const Expr *ReturnExpr,
const Expr *MovedExpr) override {
unsigned DiagID = MovedExpr
@@ -214,6 +235,28 @@ class LifetimeSafetySemaHelperImpl : public LifetimeSafetySemaHelper {
S.Diag(UseExpr->getExprLoc(), diag::note_lifetime_safety_used_here)
<< UseExpr->getSourceRange();
}
+ void reportUseAfterInvalidation(const Expr *IssueExpr, SourceLocation UseLoc,
+ const Expr *InvalidationExpr) override {
+ auto WarnDiag = isa<CXXDeleteExpr>(InvalidationExpr)
+ ? diag::warn_lifetime_safety_use_after_free
+ : diag::warn_lifetime_safety_invalidation;
+ std::string InvalidatedSubject = getDiagSubjectDescription(IssueExpr);
+ S.Diag(IssueExpr->getExprLoc(), WarnDiag)
+ << InvalidatedSubject << IssueExpr->getSourceRange();
+ reportInvalidationSite(InvalidationExpr, InvalidatedSubject);
+ S.Diag(UseLoc, diag::note_lifetime_safety_used_here);
+ }
+ void reportUseAfterInvalidation(const ParmVarDecl *PVD, SourceLocation UseLoc,
+ const Expr *InvalidationExpr) override {
+ auto WarnDiag = isa<CXXDeleteExpr>(InvalidationExpr)
+ ? diag::warn_lifetime_safety_use_after_free
+ : diag::warn_lifetime_safety_invalidation;
+ std::string InvalidatedSubject = getDiagSubjectDescription(PVD);
+ S.Diag(PVD->getSourceRange().getBegin(), WarnDiag)
+ << InvalidatedSubject << PVD->getSourceRange();
+ reportInvalidationSite(InvalidationExpr, InvalidatedSubject);
+ S.Diag(UseLoc, diag::note_lifetime_safety_used_here);
+ }
void reportInvalidatedField(const Expr *IssueExpr,
const FieldDecl *DanglingField,
diff --git a/clang/test/Sema/LifetimeSafety/invalidations.cpp b/clang/test/Sema/LifetimeSafety/invalidations.cpp
index be1acc6bc7fbc..1e9b25157046a 100644
--- a/clang/test/Sema/LifetimeSafety/invalidations.cpp
+++ b/clang/test/Sema/LifetimeSafety/invalidations.cpp
@@ -937,3 +937,53 @@ void invalid_after_ternary_reset(bool flag) {
}
} // namespace unique_ptr_invalidation
+
+// A non-trivial destructor at scope exit is modeled as an implicit use (a
+// UseFact with no source expression). The live-origins join helper reads such a
+// fact's explicit location rather than dereferencing its null use-expression, so
+// a function with both a tracked borrow and such an implicit use (e.g. a
+// std::function local) is analyzed without crashing.
+namespace implicit_use_join {
+void view_and_callable() {
+ std::string text = "long enough heap-allocated backing string value here!";
+ std::string_view tok = text; // a tracked borrow (live origin)
+ std::function<void()> c = [] {}; // non-trivial dtor at scope exit
+ (void)c;
+ (void)tok.size(); // no-warning (must not crash)
+}
+
+void view_and_callable_mutation() {
+ std::string text = "long enough heap-allocated backing string value here!";
+ std::string_view tok = text; // expected-warning {{local variable 'text' is later invalidated}}
+ std::function<void()> c = [] {};
+ (void)c;
+ text.push_back('x'); // expected-note {{local variable 'text' is invalidated here}}
+ (void)tok.size(); // expected-note {{later used here}}
+}
+} // namespace implicit_use_join
+
+// A borrow read by a scope-exit destructor or cleanup callback (an implicit use
+// with no source expression) that was freed earlier via `delete` is a
+// use-after-free; the diagnostic is anchored at the implicit use's location.
+namespace implicit_use_after_free {
+struct [[gsl::Pointer]] Ref {
+ const int *p;
+ Ref &operator=(const int *q [[clang::lifetime_capture_by(this)]]);
+ ~Ref(); // non-trivial, out-of-line: may read p
+};
+void cleanup_ref(Ref *r); // out-of-line: may read r->p
+
+void via_destructor() {
+ Ref r;
+ int *h = new int(7); // expected-warning {{allocated object does not live long enough}}
+ r = h;
+ delete h; // expected-note {{allocated object is freed here}}
+} // expected-note {{later used here}}
+
+void via_cleanup() {
+ Ref g __attribute__((cleanup(cleanup_ref))); // expected-note {{later used here}}
+ int *h = new int(7); // expected-warning {{allocated object does not live long enough}}
+ g = h;
+ delete h; // expected-note {{allocated object is freed here}}
+}
+} // namespace implicit_use_after_free
diff --git a/clang/test/Sema/LifetimeSafety/safety.cpp b/clang/test/Sema/LifetimeSafety/safety.cpp
index 2cbf651eb46b5..bfd50a279d8d4 100644
--- a/clang/test/Sema/LifetimeSafety/safety.cpp
+++ b/clang/test/Sema/LifetimeSafety/safety.cpp
@@ -4047,3 +4047,64 @@ void test_loop_cond_bind(bool cond) {
consume_loop_cond_bind(cond ? &x : &y); // no-warning
}
}
+
+// A scope-exit destructor or cleanup callback may read a borrow the object
+// holds. The analysis never sees the out-of-line body, so it is modeled as a
+// use of the object: a borrowed-from object destroyed earlier (reverse-
+// declaration order) is reported.
+namespace ScopeExitUse {
+struct [[gsl::Pointer]] Ref {
+ std::string_view sv;
+ Ref() = default;
+ Ref &operator=(std::string_view s [[clang::lifetime_capture_by(this)]]);
+ ~Ref(); // non-trivial, out-of-line: may read sv
+};
+
+void dtor_reverse_order() {
+ Ref r; // destroyed LAST
+ std::string backing; // destroyed FIRST
+ r = backing; // expected-warning {{local variable 'backing' does not live long enough}}
+} // expected-note {{local variable 'backing' is destroyed here}} expected-note {{later used here}}
+
+void dtor_safe_order() {
+ std::string backing;
+ Ref r;
+ r = backing; // no-warning
+}
+
+struct [[gsl::Pointer]] TrivialRef {
+ std::string_view sv;
+ TrivialRef &operator=(std::string_view s [[clang::lifetime_capture_by(this)]]);
+ // trivial destructor cannot read sv
+};
+void trivial_dtor_no_use() {
+ TrivialRef r;
+ std::string backing;
+ r = backing; // no-warning
+}
+
+void cleanup_ref(Ref *r); // out-of-line: may read r->sv
+void cleanup_reverse_order() {
+ Ref g __attribute__((cleanup(cleanup_ref))); // cleaned up LAST // expected-note {{later used here}}
+ std::string backing; // destroyed FIRST
+ g = backing; // expected-warning {{local variable 'backing' does not live long enough}}
+} // expected-note {{local variable 'backing' is destroyed here}}
+
+void cleanup_safe_order() {
+ std::string backing;
+ Ref g __attribute__((cleanup(cleanup_ref)));
+ g = backing; // no-warning
+}
+
+// The destructor-as-use is not limited to gsl::Pointer: any origin-holding type
+// with a non-trivial destructor qualifies. A lambda capturing a Ref by value is
+// not itself a gsl::Pointer, but holds the Ref's origin and has a non-trivial
+// destructor -- so ~closure runs ~Ref(), which reads the captured borrow.
+void closure_dtor_reads_captured_borrow() {
+ std::function<void()> fn = [] {}; // destroyed LAST
+ std::string backing; // destroyed FIRST
+ Ref r;
+ r = backing; // expected-warning {{local variable 'backing' does not live long enough}}
+ fn = [r] {}; // expected-note {{local variable 'r' aliases the storage of local variable 'backing'}}
+} // expected-note {{local variable 'backing' is destroyed here}} expected-note {{later used here}}
+} // namespace ScopeExitUse
More information about the cfe-commits
mailing list