Benedek Kaibás
Allegheny College, United States
Commits on GitHub | Pull requests
Clang can warn about lifetime bugs. Behind -Wlifetime-safety [1] it reads the lifetime annotations a function carries and reports a warning when a pointer or a reference that outlives the object it refers to escapes the function, or when the dead object is accessed through it. The analysis runs on every translation unit of every build and examines one function at a time, so an unannotated call is treated optimistically as having no lifetime effects: the origin of the value is lost at the call and bugs that depend on it go unreported. This project extends the Clang Static Analyzer (later referred to as CSA) to cover those cases. It reads the same annotations and follows the value through the calls the compiler cannot enter, and the three checkers that came out of it are in Clang today, reporting bugs the compiler does not.
These are the patches that closed the gap, taking the analyzer from missing this class of bugs to catching it.
UseAfterLifetimeEnd checker, which reports a value returned from a function while it still borrows from a local of that function.LifetimeModeling checker and moved every state operation out of UseAfterLifetimeEnd into it.BugReporterVisitor for UseAfterLifetimeEnd that adds the note showing where the value's lifetime was bound to its source.DanglingPtrDeref, which reports a use of a pointer whose pointee has gone out of scope.DanglingPtrDeref checker's filename.lifetimebound method is called during the destruction of an object.checkPostCall to DanglingPtrDeref so a dangling pointer passed as a call argument is reported.llvm::any_of.trackExpressionValue to DanglingPtrDeref so the report shows where the dangling value originated.getString() with getDescriptiveName() in the diagnostics and switched the path notes to trackStoredValue().getEndPath and finalizeVisitor with VisitNode.markAsReported, so DanglingPtrDeref reports only the first dereference of a given variable.UseAfterLifetimeEnd and DanglingPtrDeref.Nothing can be reported before the Clang Static Analyzer records where a value borrows from, so that recording came first.
int *test_func(int *p [[clang::lifetimebound]]);
Clang attaches [[clang::lifetimebound]] to the annotated ParmVarDecl, so the contract is in the AST before the analyzer starts.
int *test_func(int *p [[clang::lifetimebound]]);
int *variable_return() {
int y = 5;
int *p = test_func(&y); // checkPostCall records that the result borrows from 'y'
// ...
}
In checkPostCall the call has been evaluated, so the return value has an SVal and the checker can pair it with the region of the argument it borrows from. That pair goes into the ProgramState, the object the analyzer carries from node to node, and a state belongs to one node.
int *test_func(int *p [[clang::lifetimebound]]);
int *variable_return() {
int y = 5;
int *p = test_func(&y); // checkPostCall records that the result borrows from 'y'
return p; // checkEndFunction callback reports here:
// warning: Returning value bound to 'y' that will go out of scope
}
The checkEndFunction callback asks the state what the returned value borrows from and whether those regions belong to the frame that is about to die.
The two nodes below are consecutive in the ExplodedGraph and sit at the same program point. State 603 is the engine having evaluated the call. State 550 is one node later, after LifetimeModeling ran, and it carries the borrow as Origin ... contains Loan y.


-Wlifetime-safety is intra-procedural by default and path-insensitive: it analyzes one function at a time and merges the states at a join point (where branches meet), so a borrow taken on one branch is indistinguishable from one taken on the other. The analyzer performs an inter-procedural and path-sensitive analysis: it can enter a callee whose body is available (if the analyzer's budget allows it) and explores each path separately, so what it records on one path never shows up on the other.
With the borrow recorded, the next question was who is allowed to write it. LifetimeModeling owns everything that touches the program state and exposes nothing but queries. The reporting checkers (UseAfterLifetimeEnd and DanglingPtrDeref) read it, decide whether what they see is a bug and build the report that explains the path to it.
[[clang::lifetimebound]] end of a scope
\ /
v v
+--------------------------------------------------+
| LifetimeModeling |
| the only checker that writes state |
| |
| what borrows from what | what has died |
+--------------------------------------------------+
| |
| queries | queries
v v
+----------------------+ +--------------------------+
| UseAfterLifetimeEnd | | DanglingPtrDeref |
| a returned value | | a pointer used after |
| outlives its frame | | its object is gone |
+----------------------+ +--------------------------+
The annotated case is now covered. The second kind of lifetime error needs no annotations at all: a pointer used after the object it points to has gone out of scope. While the modelling was taking shape in June, Arseniy Zaostrovnykh and Tomasz Kaminski added CFGLifetimeEnds handling to the analyzer, which turns every end of a scope into a checkLifetimeEnd event and lets the CSA reason about lifetimes directly #201123. I picked it up as soon as it landed and built DanglingPtrDeref on top of it. LifetimeModeling listens to that event and adds the region to DeallocatedSourceSet. checkPreStmt(DeclStmt) clears a variable's region from that set whenever its declaration is executed, so a stale entry cannot make fresh storage look dead.
DanglingPtrDeref subscribes to check::Location and asks on every load and store through a pointer whether the pointee is gone.
void use_after_scope() {
int *ptr = nullptr;
{
int num = 5;
ptr = #
}
*ptr = 6; // warning: Use of 'num' after its lifetime ended
}
num lives only inside the block, so by the last line its storage is gone while ptr still holds its address. checkLifetimeEnd fires at the closing brace and puts num into DeallocatedSourceSet. On the dereference checkLocation asks whether the pointee is still alive, finds the region in that set and reports.
While evaluating the first version of the checker I found a case it did not cover: a dangling pointer handed to another function. check::Location does not fire there because the load at a call site is the load of the pointer variable itself and not an access through the pointer. DanglingPtrDeref now also implements checkPostCall and inspects every argument, but only when the analyzer did not step into the callee's body. If it did, checkLocation already covers every dereference in there and reporting at the call site as well would produce the same bug twice.
void escape(int *ptr);
void passing_dangling_ptr_to_opaque_func() {
int *ptr = nullptr;
{
int num = 5;
ptr = #
}
escape(ptr); // warning: Use of 'num' after its lifetime ended
}
escape(ptr);
|
+-- check::Location fires once, for the load of 'ptr'
| location it is given: the region of 'ptr' (alive)
| 'num' is only the value that comes out of that load, never the location
| so the event has nothing dead to look at
|
+-- checkPostCall sees the call after it was evaluated
argument SVal: &num -> its lifetime has ended -> report
Reporting at the call site is a deliberate choice. A dangling pointer cannot be used safely by anyone, so handing one to a function is already a bug even if the callee never dereferences it, and the caller should reset it to nullptr instead. The same holds for what a call can reach indirectly, an output parameter of type int ** or a std::pair<int *, int>: the dangling pointer should be cleared rather than passed on. Some of these reports can be argued to be false positives. Flagging them anyway is a deliberate choice, because once a dangling pointer is handed on there is nothing safe left to do with it.
So far we only talked about primitive types, but there are aggregates and, in C++, inheritance as well. A pointer can point at a field, an array element or a base class subobject, while what the modeling checker recorded as deallocated was the region of the whole object.
struct MyBuffer {
char buffer[8];
};
char member_subregion_dangling_deref() {
const char *p = nullptr;
{
MyBuffer tmp_buffer = {};
p = tmp_buffer.buffer;
}
return *p; // warning: Use of 'tmp_buffer.buffer[0]' after its lifetime ended
}
p points at the first element of the array inside tmp_buffer, not at tmp_buffer itself, while checkLifetimeEnd records the region of the whole variable that died. isDeallocated, the query the reporting checkers use to ask whether a region is dead, now looks up the base region of the region it is asked about, which walks the element up through the field to tmp_buffer and finds it:
p = tmp_buffer.buffer;
p points at ElementRegion tmp_buffer.buffer[0] ---+
| |
v |
FieldRegion tmp_buffer.buffer | getBaseRegion()
| |
v |
VarRegion tmp_buffer <------------+
Before this change the checker only recognized pointers to whole variables, so a pointer into a field, an array element or a base class subobject went unreported. Also this change costs nothing in precision, because a subobject cannot outlive the object that contains it: if tmp_buffer is gone, every region inside it is gone with it.
Running the checkers on a real project does two things. It shows they hold up outside the cases I wrote tests for and it measures the property that decides whether a checker can stop being experimental: how rarely it reports something that is not a bug (false positive). A core checker is expected to have very few false positives and that number cannot come from a hand written suite. The LLVM monorepo is large, actively maintained and heavily reviewed, so it exercises shapes my tests never reach. Every report there has to be read on its own and the ones I went through fell into three classes, each a place where the model was too coarse. Closing them made both checkers more precise and moved them closer to the bar a core checker has to meet.
lifetimebound call during destructionA destructor is the one place where handing out a borrow of the dying object is normal: the caller is the destructor itself and the borrow does not escape it. The fix walks the frames on the current stack and suppresses the report if any of them is a destructor, which is coarse on purpose: it asks whether a destructor is on the stack, not whether the borrow belongs to the object being destroyed, so a function called from a destructor that returns a borrow of its own local goes unreported.
struct S { call stack
int x;
int *get() [[clang::lifetimebound]] +----------------------+
{ return &x; } | outer() |
~S() { get(); } +----------------------+
}; | passThrough() | the S dies here
+----------------------+
S passThrough(S param) { return param; } | S::~S() | <- destructor frame
+----------------------+
void outer() { | S::get() | returns &x, a borrow
auto f = passThrough(S()); -----------------> +----------------------+ of *this
}
a destructor frame is on the stack, so the borrow get() hands out is meant to die
with the object -> no report
This was the largest of the three classes. A returned value can only dangle if the frame that owns its lifetime source is the frame being left. When the source belongs to a caller that stays alive, or to a frame the analyzer never entered, the storage outlives the value and there is nothing to report.
const char *first(const char *buf [[clang::lifetimebound]]);
void use(const char *p); call stack
const char *helper(const char *buf) { +----------------------+
return first(buf); | caller() | owns 'buf'
} +----------------------+
| helper() | <- frame being left
void caller() { +----------------------+
char buf[64] = {}; returns a value bound
use(helper(buf)); ----------------------------> to 'buf'
}
'buf' belongs to caller(), which stays alive when helper() returns -> no report
checkLocation fires on every access through a pointer, so a dead variable used five times produced five warnings that all describe the same bug. markAsReported keeps the first and drops the rest.
before after
*p = 1; warning *p = 1; warning
*p = 2; warning *p = 2; -
use(p); warning use(p); -
Two of the cases below come from how -Wlifetime-safety works: it analyzes one function at a time and treats a call as opaque, so the only thing it learns about a callee is what the callee's declaration says. The third is a gap inside the analyzer itself: before the lifetime checkers it had no model of [[clang::lifetimebound]] and nothing that reported a stack object used after its scope ended.
Every analyzer warning quoted in this section comes from the same command, so any of the examples can be reproduced by pasting it into a file and running:
$ clang -cc1 -analyze \
-analyzer-checker=core,alpha.cplusplus.UseAfterLifetimeEnd,alpha.cplusplus.DanglingPtrDeref \
-analyzer-config cfg-lifetime=true -analyzer-output=text example.cpp
The compiler handles the direct case. With the local passed straight to the annotated parameter, -Wlifetime-safety says so:
const char *findOption(const char *config [[clang::lifetimebound]], const char *key);
const char *readTimeoutDirect() {
char config[64] = "timeout=30";
return findOption(config, "timeout"); // warning: stack memory associated with local variable
} // 'config' is returned [-Wlifetime-safety-return-stack-addr]
Put one unannotated function between the local and the annotated call and the warning disappears, because the analysis cannot look inside optionValue and optionValue carries no contract of its own. A chain only has to lose one link for the diagnostic to go with it.
// Defined in another translation unit.
const char *findOption(const char *config [[clang::lifetimebound]], const char *key);
const char *optionValue(const char *config, const char *key) {
return findOption(config, key);
}
const char *readTimeout() {
char config[64] = "timeout=30";
return optionValue(config, "timeout"); // warning: Returning value bound to 'config[0]'
} // that will go out of scope
This is where inlining matters. When the body of a callee is available, the analyzer does not treat the call as a black box: it enters the body and keeps exploring the same path inside it, carrying the caller's state in, so everything that happens in the callee is visible to the checkers on the way out. When the body is not available the call is evaluated conservatively and the analyzer knows only what the declaration says. Here optionValue is inlined, the annotated call inside it is reached and the contract on findOption stands in for the body that is not there. The report names config[0] because that is the region the array decays to at the call.
void use_after_scope() {
int *ptr = nullptr;
{
int num = 5;
ptr = #
}
*ptr = 6; // warning: Use of 'num' after its lifetime ended
}
None of the analyzer's existing checkers for this kind of error cover it, core.StackAddressEscape and cplusplus.InnerPointer included: with -analyzer-checker=core,cplusplus the analyzer produces no diagnostic at all. DanglingPtrDeref reports it, and the path in the report runs from the initialization of num through the assignment to ptr to the closing brace where the storage dies.
void submit(const int *sample);
const int *identity(const int *value) { return value; }
void collectSample(int reading) {
const int *sample = nullptr;
{
int corrected = reading + 1;
sample = identity(&corrected);
}
submit(sample); // warning: Use of 'corrected' after its lifetime ended
}
Neither function is annotated and sample is never dereferenced in collectSample. -Wlifetime-safety sees identity as an opaque call and has no origin to follow, so it stays silent. The CSA inlines identity, follows the pointer back to corrected and reports the call that passes it on.
Together the two checkers move lifetime checking past the point where the compiler has to stop. -Wlifetime-safety remains the right tool for the common case, because it is cheap enough to run on every build. What it cannot do is follow a value through a call it cannot see into, and a chain only has to lose one link for the warning to disappear. Every report the checkers produce carries the execution path from the borrow to the death of the storage, including the branch that had to be taken, so the reader can judge it without rerunning the analysis. Both checkers ship in Clang today, both have been run over the LLVM monorepo, and LifetimeModeling is a base that the next lifetime checker can consume instead of rebuilding.
Support for LazyCompoundVal. std::string_view and std::span are among the types the annotations are written on. They are class types returned by value, which the analyzer represents as a LazyCompoundVal and none of the maps in the modeling checker cover that today, so the lifetime origin is lost:
struct View { int *p; };
View makeView(int &x [[clang::lifetimebound]]);
void caller_view() {
int v = 42;
View w = makeView(v);
// FIXME: Currently none of the maps cover LazyCompoundVal.
}
Covering it brings the checkers to the code the annotations were introduced for.
Interoperability with the MallocChecker. Both checkers reason about stack regions, so a lifetime source that lives on the heap is out of their reach. The plan is to extend them to heap sources through the interface the MallocChecker already exposes for this in AllocationState.h, which is how cplusplus.InnerPointer works with it today, instead of modeling the heap in the lifetime checkers.
The [[clang::lifetime_capture_by(X)]] annotation. The original proposal covered both annotations. During the summer I have prioritized [[clang::lifetimebound]], because it is the annotation people currently use. In the LLVM monorepo alone libc++ applies _LIBCPP_LIFETIMEBOUND around 80 times across 22 headers, while lifetime_capture_by does not appear in the library at all, and outside LLVM the same asymmetry holds: Abseil ships ABSL_ATTRIBUTE_LIFETIME_BOUND [2] and uses it throughout its string and container implementations, while lifetime_capture_by is a much newer addition [3]. Supporting the capture annotation is the work I intend to do after the summer.
I would like to say thank you to my mentors, in the order they appear on the LLVM GSoC page: Gábor Horváth, Balázs Benics and Dániel Domján, for mentoring this project, for the reviews and design discussions that shaped both checkers and for answering my questions throughout the summer. The time they put into the project and into teaching me went well beyond what I expected. My questions were answered quickly and my patches were reviewed even at weekends. This was the greatest summer of my life so far without a doubt. I am grateful for the opportunity and for my mentors for all their help and guidance. This project gave me a huge motivation to stay active in the LLVM community and continue working on the lifetime checkers and the Clang Static Analyzer.
I would also like to say thank you to Donát Nagy for his code reviews on some of these patches and to Arseniy Zaostrovnykh and Tomasz Kaminski for the CFGLifetimeEnds support in the CFG.
[1] U. Saxena, D. Hrybenko, Y. Mandelbaum, J. Voung and K. Yasuda, "[RFC] Intra-procedural lifetime analysis in Clang", LLVM Discussion Forums, May 2025. https://discourse.llvm.org/t/rfc-intra-procedural-lifetime-analysis-in-clang/86291
[2] Abseil, definition of ABSL_ATTRIBUTE_LIFETIME_BOUND in absl/base/attributes.h. https://github.com/abseil/abseil-cpp/blob/master/absl/base/attributes.h
[3] G. Horvath and U. Saxena, "[RFC] Introduce [[clang::lifetime_capture_by(X)]]", LLVM Discussion Forums, November 2024. https://discourse.llvm.org/t/rfc-introduce-clang-lifetime-capture-by-x/81371