Skip to content

[clang]: Propagate *noreturn attributes in CFG #146355

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 33 commits into
base: main
Choose a base branch
from

Conversation

negativ
Copy link
Contributor

@negativ negativ commented Jun 30, 2025

Summary

This PR extends Clang's CFG analysis to automatically propagate analyzer_noreturn attributes through function call chains. When a function exclusively calls no-return functions, it is automatically treated as no-return as well, improving static analysis precision.

Problem

Currently, simple forwarder functions to no-return calls are not recognized as no-return themselves:

void terminate() __attribute__((analyzer_noreturn)) {  }

void fatal_error() {
    terminate();  // This "never" returns, but fatal_error() is not marked as no-return
}

void handle_error(const std::optional<int> opt) {
    if (!opt) {
        fatal_error(); // Static analyzer doesn't know this never returns
    }
    *opt = 1;      // False positive: analyzer thinks this is reachable
}

Solution

  1. Extend analyzer_noreturn attribute to take one extra bool param: true if the function should be considered as no-return by analyzer and false otherwise
  2. Extend CFG::BuildOptions with the new option ExtendedNoReturnAnalysis which controls enablement of the new inter-procedural analysis
  3. Extend FunctionDecl class by adding getAnalyzerSinkKind() function which adapts analyzer_noreturn attribute state
  4. When examining callexpr in CFGBuilder check ExtendedNoReturnAnalysis flag and if it is true build new CFG for the callee function and perform no-return analysis by recursively calling function isInevitablySinking
  5. Cache no-return analysis results directly in AST (by calling FunctionDecl::setAnalyzerSinkKind() function)
  6. Add CFG::buildOptions argument to dataflow::diagnoseFunction function to be able to pre-setup CFGBuilder

Known limitations

  1. Due to performance cost, this flow should be explicitly enabled by user via CFG::BuildOptions (see clang-tools-extra/clang-tidy/bugprone/UncheckedOptionalAccessCheck.cpp)
  2. isInevitablySinking() can add and remove FunctionDecl attributes in AST during analysis(!)

Copy link

Thank you for submitting a Pull Request (PR) to the LLVM Project!

This PR will be automatically labeled and the relevant teams will be notified.

If you wish to, you can add reviewers by using the "Reviewers" section on this page.

If this is not working for you, it is probably because you do not have write permissions for the repository. In which case you can instead tag reviewers by name in a comment by using @ followed by their GitHub username.

If you have received no comments on your PR for a week, you can request a review by "ping"ing the PR by adding a comment “Ping”. The common courtesy "ping" rate is once a week. Please remember that you are asking for valuable time from other developers.

If you have further questions, they may be answered by the LLVM GitHub User Guide.

You can also ask questions in a comment on this PR, on the LLVM Discord or on the forums.

@negativ negativ changed the title Clang: analyzer_noreturn propagation in CFG Clang: *noreturn propagation in CFG Jul 1, 2025
Copy link
Contributor

@MikeWeller MikeWeller left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick pass / haven't looked in-depth yet.

  • I would mention in the description/summary that plain noreturn already has this behaviour (at least in one of the CFG.cpp vs. NoReturnFunctionChecker.cpp implementations) so this is adding the same behaviour for analyzer_noreturn.

if (!NoReturn)
NoReturn = FD->isAnalyzerNoReturn() || C->isBuiltinAssumeFalse(*Context);

// Some well-known 'noreturn' functions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we consolidate this list with the one in NoReturnFunctionChecker.cpp? Or at least reference the other one to remind people to update both.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MikeWeller i've added comment in NoreturnFunctionChecker.cpp

@negativ negativ changed the title Clang: *noreturn propagation in CFG [Clang]: *noreturn propagation in CFG Jul 7, 2025
@negativ negativ changed the title [Clang]: *noreturn propagation in CFG [clang]: *noreturn propagation in CFG Jul 7, 2025
@negativ negativ changed the title [clang]: *noreturn propagation in CFG [clang]: Propagate *noreturn attributes in CFG Jul 7, 2025
@negativ negativ marked this pull request as ready for review July 7, 2025 09:57
@llvmbot llvmbot added clang Clang issues not falling into any other category clang-tools-extra clang-tidy clang:frontend Language frontend issues, e.g. anything involving "Sema" clang:static analyzer clang:dataflow Clang Dataflow Analysis framework - https://clang.llvm.org/docs/DataFlowAnalysisIntro.html clang:analysis labels Jul 7, 2025
@llvmbot
Copy link
Member

llvmbot commented Jul 7, 2025

@llvm/pr-subscribers-clang-static-analyzer-1
@llvm/pr-subscribers-clang

@llvm/pr-subscribers-clang-analysis

Author: Andrey Karlov (negativ)

Changes

Summary

This PR extends Clang's CFG analysis to automatically propagate analyzer_noreturn attributes through function call chains. When a function exclusively calls no-return functions, it is automatically treated as no-return as well, improving static analysis precision.

Problem

Currently, simple forwarder functions to no-return calls are not recognized as no-return themselves:

void terminate() __attribute__((analyzer_noreturn)) {  }

void fatal_error() {
    terminate();  // This "never" returns, but fatal_error() is not marked as no-return
}

void handle_error(const std::optional&lt;int&gt; opt) {
    if (!opt) {
        fatal_error(); // Static analyzer doesn't know this never returns
    }
    *opt = 1;      // False positive: analyzer thinks this is reachable
}

Solution

Enhanced isImmediateSinkBlock() in CFG.cpp to perform inter-procedural analysis:

  1. Existing behavior preserved: Direct no-return elements and throw expressions
  2. Enhanced function call analysis:
    • Checks for existing no-return attributes (analyzer_noreturn, noreturn, [[noreturn]], _Noreturn)
    • Recognizes well-known C library functions (exit, abort, __assert_fail, etc.)
    • Supports namespaced functions (BloombergLP::bsls::Assert::invokeHandler, std::terminate)
  3. Recursive CFG analysis: For user-defined functions, builds CFG and checks if all execution paths lead to no-return calls

Implementation Details

  • Modified isImmediateSinkBlock() in lib/Analysis/CFG.cpp
  • Added support for 10+ well-known no-return C and C++ functions
  • Inter-procedural analysis limited to functions with available bodies

Full diff: https://github.com/llvm/llvm-project/pull/146355.diff

7 Files Affected:

  • (modified) clang-tools-extra/test/clang-tidy/checkers/bugprone/unchecked-optional-access.cpp (+36)
  • (modified) clang/include/clang/AST/Decl.h (+5)
  • (modified) clang/lib/AST/Decl.cpp (+4)
  • (modified) clang/lib/Analysis/CFG.cpp (+74-5)
  • (modified) clang/lib/Analysis/FlowSensitive/TypeErasedDataflowAnalysis.cpp (+2-2)
  • (modified) clang/lib/StaticAnalyzer/Checkers/NoReturnFunctionChecker.cpp (+39-31)
  • (modified) clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp (+81)
diff --git a/clang-tools-extra/test/clang-tidy/checkers/bugprone/unchecked-optional-access.cpp b/clang-tools-extra/test/clang-tidy/checkers/bugprone/unchecked-optional-access.cpp
index 3167b85f0e024..f4f82199a0bb5 100644
--- a/clang-tools-extra/test/clang-tidy/checkers/bugprone/unchecked-optional-access.cpp
+++ b/clang-tools-extra/test/clang-tidy/checkers/bugprone/unchecked-optional-access.cpp
@@ -141,6 +141,42 @@ void nullable_value_after_swap(BloombergLP::bdlb::NullableValue<int> &opt1, Bloo
   }
 }
 
+void assertion_handler_imp() __attribute__((analyzer_noreturn));
+
+void assertion_handler() {
+    do {
+       assertion_handler_imp();
+    } while(0);
+}
+
+void function_calling_analyzer_noreturn(const bsl::optional<int>& opt)
+{
+  if (!opt) {
+      assertion_handler();
+  }
+
+  *opt; // no-warning
+}
+
+void abort();
+
+void do_fail() {
+    abort(); // acts like 'abort()' C-function
+}
+
+void invoke_assertion_handler() {
+    do_fail();
+}
+
+void function_calling_well_known_noreturn(const bsl::optional<int>& opt)
+{
+  if (!opt) {
+      invoke_assertion_handler();
+  }
+
+  *opt; // no-warning
+}
+
 template <typename T>
 void function_template_without_user(const absl::optional<T> &opt) {
   opt.value(); // no-warning
diff --git a/clang/include/clang/AST/Decl.h b/clang/include/clang/AST/Decl.h
index f70a039bf3517..6ada8785fd0ba 100644
--- a/clang/include/clang/AST/Decl.h
+++ b/clang/include/clang/AST/Decl.h
@@ -2630,6 +2630,11 @@ class FunctionDecl : public DeclaratorDecl,
   /// an attribute on its declaration or its type.
   bool isNoReturn() const;
 
+  /// Determines whether this function is known to never return for CFG
+  /// analysis. Checks for noreturn attributes on the function declaration
+  /// or its type, including 'analyzer_noreturn' attribute.
+  bool isAnalyzerNoReturn() const;
+
   /// True if the function was a definition but its body was skipped.
   bool hasSkippedBody() const { return FunctionDeclBits.HasSkippedBody; }
   void setHasSkippedBody(bool Skipped = true) {
diff --git a/clang/lib/AST/Decl.cpp b/clang/lib/AST/Decl.cpp
index e0362245d6ecd..19336a8e942cd 100644
--- a/clang/lib/AST/Decl.cpp
+++ b/clang/lib/AST/Decl.cpp
@@ -3595,6 +3595,10 @@ bool FunctionDecl::isNoReturn() const {
   return false;
 }
 
+bool FunctionDecl::isAnalyzerNoReturn() const {
+  return isNoReturn() || hasAttr<AnalyzerNoReturnAttr>();
+}
+
 bool FunctionDecl::isMemberLikeConstrainedFriend() const {
   // C++20 [temp.friend]p9:
   //   A non-template friend declaration with a requires-clause [or]
diff --git a/clang/lib/Analysis/CFG.cpp b/clang/lib/Analysis/CFG.cpp
index d960d5130332b..bc47891216e7a 100644
--- a/clang/lib/Analysis/CFG.cpp
+++ b/clang/lib/Analysis/CFG.cpp
@@ -41,6 +41,7 @@
 #include "llvm/ADT/APSInt.h"
 #include "llvm/ADT/ArrayRef.h"
 #include "llvm/ADT/DenseMap.h"
+#include "llvm/ADT/ScopeExit.h"
 #include "llvm/ADT/STLExtras.h"
 #include "llvm/ADT/SetVector.h"
 #include "llvm/ADT/SmallPtrSet.h"
@@ -2833,8 +2834,37 @@ CFGBlock *CFGBuilder::VisitCallExpr(CallExpr *C, AddStmtChoice asc) {
     if (!FD->isVariadic())
       findConstructionContextsForArguments(C);
 
-    if (FD->isNoReturn() || C->isBuiltinAssumeFalse(*Context))
-      NoReturn = true;
+    if (!NoReturn)
+      NoReturn = FD->isAnalyzerNoReturn() || C->isBuiltinAssumeFalse(*Context);
+
+    // Some well-known 'noreturn' functions
+    if (!NoReturn)
+      NoReturn = llvm::StringSwitch<bool>(FD->getQualifiedNameAsString())
+                     .Case("BloombergLP::bsls::Assert::invokeHandler", true)
+                     .Case("std::terminate", true)
+                     .Case("std::abort", true)
+                     .Case("exit", true)
+                     .Case("abort", true)
+                     .Case("panic", true)
+                     .Case("error", true)
+                     .Case("Assert", true)
+                     .Case("ziperr", true)
+                     .Case("assfail", true)
+                     .Case("db_error", true)
+                     .Case("__assert", true)
+                     .Case("__assert2", true)
+                     .Case("_wassert", true)
+                     .Case("__assert_rtn", true)
+                     .Case("__assert_fail", true)
+                     .Case("dtrace_assfail", true)
+                     .Case("yy_fatal_error", true)
+                     .Case("_XCAssertionFailureHandler", true)
+                     .Case("_DTAssertionFailureHandler", true)
+                     .Case("_TSAssertionFailureHandler", true)
+                     .Case("__builtin_trap", true)
+                     .Case("__builtin_unreachable", true)
+                     .Default(false);
+
     if (FD->hasAttr<NoThrowAttr>())
       AddEHEdge = false;
     if (isBuiltinAssumeWithSideEffects(FD->getASTContext(), C) ||
@@ -6288,6 +6318,12 @@ void CFGBlock::printTerminatorJson(raw_ostream &Out, const LangOptions &LO,
 // There may be many more reasons why a sink would appear during analysis
 // (eg. checkers may generate sinks arbitrarily), but here we only consider
 // sinks that would be obvious by looking at the CFG.
+//
+// This function also performs inter-procedural analysis by recursively
+// examining called functions to detect forwarding chains to noreturn
+// functions. When a function is determined to never return through this
+// analysis, it's automatically marked with analyzer_noreturn attribute
+// for caching and future reference.
 static bool isImmediateSinkBlock(const CFGBlock *Blk) {
   if (Blk->hasNoReturnElement())
     return true;
@@ -6298,10 +6334,43 @@ static bool isImmediateSinkBlock(const CFGBlock *Blk) {
   // at least for now, but once we have better support for exceptions,
   // we'd need to carefully handle the case when the throw is being
   // immediately caught.
-  if (llvm::any_of(*Blk, [](const CFGElement &Elm) {
+  if (llvm::any_of(*Blk, [](const CFGElement &Elm) -> bool {
+        if (std::optional<CFGStmt> StmtElm = Elm.getAs<CFGStmt>())
+          return isa<CXXThrowExpr>(StmtElm->getStmt());
+        return false;
+      }))
+    return true;
+
+  auto HasNoReturnCall = [](const CallExpr *CE) {
+    if (!CE)
+      return false;
+
+    static thread_local llvm::SmallPtrSet<const FunctionDecl *, 32> InProgress;
+
+    auto *FD = CE->getDirectCallee();
+
+    if (!FD || InProgress.count(FD))
+      return false;
+
+    InProgress.insert(FD);
+    auto DoCleanup = llvm::make_scope_exit([&]() { InProgress.erase(FD); });
+
+    auto NoReturnFromCFG = [FD]() {
+      if (!FD->getBody())
+        return false;
+
+      auto CalleeCFG =
+          CFG::buildCFG(FD, FD->getBody(), &FD->getASTContext(), {});
+
+      return CalleeCFG && CalleeCFG->getEntry().isInevitablySinking();
+    };
+
+    return FD->isAnalyzerNoReturn() || NoReturnFromCFG();
+  };
+
+  if (llvm::any_of(*Blk, [&](const CFGElement &Elm) {
         if (std::optional<CFGStmt> StmtElm = Elm.getAs<CFGStmt>())
-          if (isa<CXXThrowExpr>(StmtElm->getStmt()))
-            return true;
+          return HasNoReturnCall(dyn_cast<CallExpr>(StmtElm->getStmt()));
         return false;
       }))
     return true;
diff --git a/clang/lib/Analysis/FlowSensitive/TypeErasedDataflowAnalysis.cpp b/clang/lib/Analysis/FlowSensitive/TypeErasedDataflowAnalysis.cpp
index 1113bbe7f4d9c..c799ca98e4a0d 100644
--- a/clang/lib/Analysis/FlowSensitive/TypeErasedDataflowAnalysis.cpp
+++ b/clang/lib/Analysis/FlowSensitive/TypeErasedDataflowAnalysis.cpp
@@ -283,7 +283,7 @@ computeBlockInputState(const CFGBlock &Block, AnalysisContext &AC) {
   JoinedStateBuilder Builder(AC, JoinBehavior);
   for (const CFGBlock *Pred : Preds) {
     // Skip if the `Block` is unreachable or control flow cannot get past it.
-    if (!Pred || Pred->hasNoReturnElement())
+    if (!Pred || Pred->isInevitablySinking())
       continue;
 
     // Skip if `Pred` was not evaluated yet. This could happen if `Pred` has a
@@ -562,7 +562,7 @@ runTypeErasedDataflowAnalysis(
     BlockStates[Block->getBlockID()] = std::move(NewBlockState);
 
     // Do not add unreachable successor blocks to `Worklist`.
-    if (Block->hasNoReturnElement())
+    if (Block->isInevitablySinking())
       continue;
 
     Worklist.enqueueSuccessors(Block);
diff --git a/clang/lib/StaticAnalyzer/Checkers/NoReturnFunctionChecker.cpp b/clang/lib/StaticAnalyzer/Checkers/NoReturnFunctionChecker.cpp
index 17c3cb4e9e04c..834bd81c2fa21 100644
--- a/clang/lib/StaticAnalyzer/Checkers/NoReturnFunctionChecker.cpp
+++ b/clang/lib/StaticAnalyzer/Checkers/NoReturnFunctionChecker.cpp
@@ -50,37 +50,45 @@ void NoReturnFunctionChecker::checkPostCall(const CallEvent &CE,
       BuildSinks = getFunctionExtInfo(C->getType()).getNoReturn();
   }
 
-  if (!BuildSinks && CE.isGlobalCFunction()) {
-    if (const IdentifierInfo *II = CE.getCalleeIdentifier()) {
-      // HACK: Some functions are not marked noreturn, and don't return.
-      //  Here are a few hardwired ones.  If this takes too long, we can
-      //  potentially cache these results.
-      BuildSinks
-        = llvm::StringSwitch<bool>(StringRef(II->getName()))
-            .Case("exit", true)
-            .Case("panic", true)
-            .Case("error", true)
-            .Case("Assert", true)
-            // FIXME: This is just a wrapper around throwing an exception.
-            //  Eventually inter-procedural analysis should handle this easily.
-            .Case("ziperr", true)
-            .Case("assfail", true)
-            .Case("db_error", true)
-            .Case("__assert", true)
-            .Case("__assert2", true)
-            // For the purpose of static analysis, we do not care that
-            //  this MSVC function will return if the user decides to continue.
-            .Case("_wassert", true)
-            .Case("__assert_rtn", true)
-            .Case("__assert_fail", true)
-            .Case("dtrace_assfail", true)
-            .Case("yy_fatal_error", true)
-            .Case("_XCAssertionFailureHandler", true)
-            .Case("_DTAssertionFailureHandler", true)
-            .Case("_TSAssertionFailureHandler", true)
-            .Default(false);
-    }
-  }
+ if (!BuildSinks && CE.isGlobalCFunction()) {
+   if (const IdentifierInfo *II = CE.getCalleeIdentifier()) {
+     // HACK: Some functions are not marked noreturn, and don't return.
+     //  Here are a few hardwired ones.  If this takes too long, we can
+     //  potentially cache these results.
+     //
+     // (!) In case of function list update, please also update
+     // CFGBuilder::VisitCallExpr (CFG.cpp)
+     BuildSinks =
+         llvm::StringSwitch<bool>(StringRef(II->getName()))
+             .Case("exit", true)
+             .Case("abort", true)
+             .Case("panic", true)
+             .Case("error", true)
+             .Case("Assert", true)
+             // FIXME: This is just a wrapper around throwing an exception.
+             //  Eventually inter-procedural analysis should handle this
+             //  easily.
+             .Case("ziperr", true)
+             .Case("assfail", true)
+             .Case("db_error", true)
+             .Case("__assert", true)
+             .Case("__assert2", true)
+             // For the purpose of static analysis, we do not care that
+             //  this MSVC function will return if the user decides to
+             //  continue.
+             .Case("_wassert", true)
+             .Case("__assert_rtn", true)
+             .Case("__assert_fail", true)
+             .Case("dtrace_assfail", true)
+             .Case("yy_fatal_error", true)
+             .Case("_XCAssertionFailureHandler", true)
+             .Case("_DTAssertionFailureHandler", true)
+             .Case("_TSAssertionFailureHandler", true)
+             .Case("__builtin_trap", true)
+             .Case("__builtin_unreachable", true)
+             .Default(false);
+   }
+ }
 
   if (BuildSinks)
     C.generateSink(C.getState(), C.getPredecessor());
diff --git a/clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp b/clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp
index 9fb7bebdbe41e..9150b3e155533 100644
--- a/clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp
+++ b/clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp
@@ -49,6 +49,7 @@ using namespace ast_matchers;
 using llvm::IsStringMapEntry;
 using ::testing::DescribeMatcher;
 using ::testing::IsEmpty;
+using ::testing::Not;
 using ::testing::NotNull;
 using ::testing::Test;
 using ::testing::UnorderedElementsAre;
@@ -693,6 +694,86 @@ TEST_F(NoreturnDestructorTest, ConditionalOperatorNestedBranchReturns) {
   // FIXME: Called functions at point `p` should contain only "foo".
 }
 
+class AnalyzerNoreturnTest : public Test {
+protected:
+  template <typename Matcher>
+  void runDataflow(llvm::StringRef Code, Matcher Expectations) {
+    tooling::FileContentMappings FilesContents;
+    FilesContents.push_back(
+        std::make_pair<std::string, std::string>("noreturn_test_defs.h", R"(
+      void assertionHandler() __attribute__((analyzer_noreturn));
+
+      void assertionTrampoline() {
+        assertionHandler();
+      }
+
+      void trap() {}
+    )"));
+
+    ASSERT_THAT_ERROR(
+        test::checkDataflow<FunctionCallAnalysis>(
+            AnalysisInputs<FunctionCallAnalysis>(
+                Code, ast_matchers::hasName("target"),
+                [](ASTContext &C, Environment &) {
+                  return FunctionCallAnalysis(C);
+                })
+                .withASTBuildArgs({"-fsyntax-only", "-std=c++17"})
+                .withASTBuildVirtualMappedFiles(std::move(FilesContents)),
+            /*VerifyResults=*/
+            [&Expectations](
+                const llvm::StringMap<
+                    DataflowAnalysisState<FunctionCallLattice>> &Results,
+                const AnalysisOutputs &) {
+              EXPECT_THAT(Results, Expectations);
+            }),
+        llvm::Succeeded());
+  }
+};
+
+TEST_F(AnalyzerNoreturnTest, Breathing) {
+  std::string Code = R"(
+    #include "noreturn_test_defs.h"
+
+    void target() {
+      trap();
+      // [[p]]
+    }
+  )";
+  runDataflow(Code, UnorderedElementsAre(IsStringMapEntry(
+                        "p", HoldsFunctionCallLattice(HasCalledFunctions(
+                                 UnorderedElementsAre("trap"))))));
+}
+
+TEST_F(AnalyzerNoreturnTest, DirectNoReturnCall) {
+  std::string Code = R"(
+    #include "noreturn_test_defs.h"
+
+    void target() {
+      assertionHandler();
+      trap();
+      // [[p]]
+    }
+  )";
+  runDataflow(Code, Not(UnorderedElementsAre(IsStringMapEntry(
+                        "p", HoldsFunctionCallLattice(HasCalledFunctions(
+                                 UnorderedElementsAre("trap")))))));
+}
+
+TEST_F(AnalyzerNoreturnTest, IndirectNoReturnCall) {
+  std::string Code = R"(
+    #include "noreturn_test_defs.h"
+
+    void target() {
+      assertionTrampoline();
+      trap();
+      // [[p]]
+    }
+  )";
+  runDataflow(Code, Not(UnorderedElementsAre(IsStringMapEntry(
+                        "p", HoldsFunctionCallLattice(HasCalledFunctions(
+                                 UnorderedElementsAre("trap")))))));
+}
+
 // Models an analysis that uses flow conditions.
 class SpecialBoolAnalysis final
     : public DataflowAnalysis<SpecialBoolAnalysis, NoopLattice> {

@llvmbot
Copy link
Member

llvmbot commented Jul 7, 2025

@llvm/pr-subscribers-clang-tidy

Author: Andrey Karlov (negativ)

Changes

Summary

This PR extends Clang's CFG analysis to automatically propagate analyzer_noreturn attributes through function call chains. When a function exclusively calls no-return functions, it is automatically treated as no-return as well, improving static analysis precision.

Problem

Currently, simple forwarder functions to no-return calls are not recognized as no-return themselves:

void terminate() __attribute__((analyzer_noreturn)) {  }

void fatal_error() {
    terminate();  // This "never" returns, but fatal_error() is not marked as no-return
}

void handle_error(const std::optional&lt;int&gt; opt) {
    if (!opt) {
        fatal_error(); // Static analyzer doesn't know this never returns
    }
    *opt = 1;      // False positive: analyzer thinks this is reachable
}

Solution

Enhanced isImmediateSinkBlock() in CFG.cpp to perform inter-procedural analysis:

  1. Existing behavior preserved: Direct no-return elements and throw expressions
  2. Enhanced function call analysis:
    • Checks for existing no-return attributes (analyzer_noreturn, noreturn, [[noreturn]], _Noreturn)
    • Recognizes well-known C library functions (exit, abort, __assert_fail, etc.)
    • Supports namespaced functions (BloombergLP::bsls::Assert::invokeHandler, std::terminate)
  3. Recursive CFG analysis: For user-defined functions, builds CFG and checks if all execution paths lead to no-return calls

Implementation Details

  • Modified isImmediateSinkBlock() in lib/Analysis/CFG.cpp
  • Added support for 10+ well-known no-return C and C++ functions
  • Inter-procedural analysis limited to functions with available bodies

Full diff: https://github.com/llvm/llvm-project/pull/146355.diff

7 Files Affected:

  • (modified) clang-tools-extra/test/clang-tidy/checkers/bugprone/unchecked-optional-access.cpp (+36)
  • (modified) clang/include/clang/AST/Decl.h (+5)
  • (modified) clang/lib/AST/Decl.cpp (+4)
  • (modified) clang/lib/Analysis/CFG.cpp (+74-5)
  • (modified) clang/lib/Analysis/FlowSensitive/TypeErasedDataflowAnalysis.cpp (+2-2)
  • (modified) clang/lib/StaticAnalyzer/Checkers/NoReturnFunctionChecker.cpp (+39-31)
  • (modified) clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp (+81)
diff --git a/clang-tools-extra/test/clang-tidy/checkers/bugprone/unchecked-optional-access.cpp b/clang-tools-extra/test/clang-tidy/checkers/bugprone/unchecked-optional-access.cpp
index 3167b85f0e024..f4f82199a0bb5 100644
--- a/clang-tools-extra/test/clang-tidy/checkers/bugprone/unchecked-optional-access.cpp
+++ b/clang-tools-extra/test/clang-tidy/checkers/bugprone/unchecked-optional-access.cpp
@@ -141,6 +141,42 @@ void nullable_value_after_swap(BloombergLP::bdlb::NullableValue<int> &opt1, Bloo
   }
 }
 
+void assertion_handler_imp() __attribute__((analyzer_noreturn));
+
+void assertion_handler() {
+    do {
+       assertion_handler_imp();
+    } while(0);
+}
+
+void function_calling_analyzer_noreturn(const bsl::optional<int>& opt)
+{
+  if (!opt) {
+      assertion_handler();
+  }
+
+  *opt; // no-warning
+}
+
+void abort();
+
+void do_fail() {
+    abort(); // acts like 'abort()' C-function
+}
+
+void invoke_assertion_handler() {
+    do_fail();
+}
+
+void function_calling_well_known_noreturn(const bsl::optional<int>& opt)
+{
+  if (!opt) {
+      invoke_assertion_handler();
+  }
+
+  *opt; // no-warning
+}
+
 template <typename T>
 void function_template_without_user(const absl::optional<T> &opt) {
   opt.value(); // no-warning
diff --git a/clang/include/clang/AST/Decl.h b/clang/include/clang/AST/Decl.h
index f70a039bf3517..6ada8785fd0ba 100644
--- a/clang/include/clang/AST/Decl.h
+++ b/clang/include/clang/AST/Decl.h
@@ -2630,6 +2630,11 @@ class FunctionDecl : public DeclaratorDecl,
   /// an attribute on its declaration or its type.
   bool isNoReturn() const;
 
+  /// Determines whether this function is known to never return for CFG
+  /// analysis. Checks for noreturn attributes on the function declaration
+  /// or its type, including 'analyzer_noreturn' attribute.
+  bool isAnalyzerNoReturn() const;
+
   /// True if the function was a definition but its body was skipped.
   bool hasSkippedBody() const { return FunctionDeclBits.HasSkippedBody; }
   void setHasSkippedBody(bool Skipped = true) {
diff --git a/clang/lib/AST/Decl.cpp b/clang/lib/AST/Decl.cpp
index e0362245d6ecd..19336a8e942cd 100644
--- a/clang/lib/AST/Decl.cpp
+++ b/clang/lib/AST/Decl.cpp
@@ -3595,6 +3595,10 @@ bool FunctionDecl::isNoReturn() const {
   return false;
 }
 
+bool FunctionDecl::isAnalyzerNoReturn() const {
+  return isNoReturn() || hasAttr<AnalyzerNoReturnAttr>();
+}
+
 bool FunctionDecl::isMemberLikeConstrainedFriend() const {
   // C++20 [temp.friend]p9:
   //   A non-template friend declaration with a requires-clause [or]
diff --git a/clang/lib/Analysis/CFG.cpp b/clang/lib/Analysis/CFG.cpp
index d960d5130332b..bc47891216e7a 100644
--- a/clang/lib/Analysis/CFG.cpp
+++ b/clang/lib/Analysis/CFG.cpp
@@ -41,6 +41,7 @@
 #include "llvm/ADT/APSInt.h"
 #include "llvm/ADT/ArrayRef.h"
 #include "llvm/ADT/DenseMap.h"
+#include "llvm/ADT/ScopeExit.h"
 #include "llvm/ADT/STLExtras.h"
 #include "llvm/ADT/SetVector.h"
 #include "llvm/ADT/SmallPtrSet.h"
@@ -2833,8 +2834,37 @@ CFGBlock *CFGBuilder::VisitCallExpr(CallExpr *C, AddStmtChoice asc) {
     if (!FD->isVariadic())
       findConstructionContextsForArguments(C);
 
-    if (FD->isNoReturn() || C->isBuiltinAssumeFalse(*Context))
-      NoReturn = true;
+    if (!NoReturn)
+      NoReturn = FD->isAnalyzerNoReturn() || C->isBuiltinAssumeFalse(*Context);
+
+    // Some well-known 'noreturn' functions
+    if (!NoReturn)
+      NoReturn = llvm::StringSwitch<bool>(FD->getQualifiedNameAsString())
+                     .Case("BloombergLP::bsls::Assert::invokeHandler", true)
+                     .Case("std::terminate", true)
+                     .Case("std::abort", true)
+                     .Case("exit", true)
+                     .Case("abort", true)
+                     .Case("panic", true)
+                     .Case("error", true)
+                     .Case("Assert", true)
+                     .Case("ziperr", true)
+                     .Case("assfail", true)
+                     .Case("db_error", true)
+                     .Case("__assert", true)
+                     .Case("__assert2", true)
+                     .Case("_wassert", true)
+                     .Case("__assert_rtn", true)
+                     .Case("__assert_fail", true)
+                     .Case("dtrace_assfail", true)
+                     .Case("yy_fatal_error", true)
+                     .Case("_XCAssertionFailureHandler", true)
+                     .Case("_DTAssertionFailureHandler", true)
+                     .Case("_TSAssertionFailureHandler", true)
+                     .Case("__builtin_trap", true)
+                     .Case("__builtin_unreachable", true)
+                     .Default(false);
+
     if (FD->hasAttr<NoThrowAttr>())
       AddEHEdge = false;
     if (isBuiltinAssumeWithSideEffects(FD->getASTContext(), C) ||
@@ -6288,6 +6318,12 @@ void CFGBlock::printTerminatorJson(raw_ostream &Out, const LangOptions &LO,
 // There may be many more reasons why a sink would appear during analysis
 // (eg. checkers may generate sinks arbitrarily), but here we only consider
 // sinks that would be obvious by looking at the CFG.
+//
+// This function also performs inter-procedural analysis by recursively
+// examining called functions to detect forwarding chains to noreturn
+// functions. When a function is determined to never return through this
+// analysis, it's automatically marked with analyzer_noreturn attribute
+// for caching and future reference.
 static bool isImmediateSinkBlock(const CFGBlock *Blk) {
   if (Blk->hasNoReturnElement())
     return true;
@@ -6298,10 +6334,43 @@ static bool isImmediateSinkBlock(const CFGBlock *Blk) {
   // at least for now, but once we have better support for exceptions,
   // we'd need to carefully handle the case when the throw is being
   // immediately caught.
-  if (llvm::any_of(*Blk, [](const CFGElement &Elm) {
+  if (llvm::any_of(*Blk, [](const CFGElement &Elm) -> bool {
+        if (std::optional<CFGStmt> StmtElm = Elm.getAs<CFGStmt>())
+          return isa<CXXThrowExpr>(StmtElm->getStmt());
+        return false;
+      }))
+    return true;
+
+  auto HasNoReturnCall = [](const CallExpr *CE) {
+    if (!CE)
+      return false;
+
+    static thread_local llvm::SmallPtrSet<const FunctionDecl *, 32> InProgress;
+
+    auto *FD = CE->getDirectCallee();
+
+    if (!FD || InProgress.count(FD))
+      return false;
+
+    InProgress.insert(FD);
+    auto DoCleanup = llvm::make_scope_exit([&]() { InProgress.erase(FD); });
+
+    auto NoReturnFromCFG = [FD]() {
+      if (!FD->getBody())
+        return false;
+
+      auto CalleeCFG =
+          CFG::buildCFG(FD, FD->getBody(), &FD->getASTContext(), {});
+
+      return CalleeCFG && CalleeCFG->getEntry().isInevitablySinking();
+    };
+
+    return FD->isAnalyzerNoReturn() || NoReturnFromCFG();
+  };
+
+  if (llvm::any_of(*Blk, [&](const CFGElement &Elm) {
         if (std::optional<CFGStmt> StmtElm = Elm.getAs<CFGStmt>())
-          if (isa<CXXThrowExpr>(StmtElm->getStmt()))
-            return true;
+          return HasNoReturnCall(dyn_cast<CallExpr>(StmtElm->getStmt()));
         return false;
       }))
     return true;
diff --git a/clang/lib/Analysis/FlowSensitive/TypeErasedDataflowAnalysis.cpp b/clang/lib/Analysis/FlowSensitive/TypeErasedDataflowAnalysis.cpp
index 1113bbe7f4d9c..c799ca98e4a0d 100644
--- a/clang/lib/Analysis/FlowSensitive/TypeErasedDataflowAnalysis.cpp
+++ b/clang/lib/Analysis/FlowSensitive/TypeErasedDataflowAnalysis.cpp
@@ -283,7 +283,7 @@ computeBlockInputState(const CFGBlock &Block, AnalysisContext &AC) {
   JoinedStateBuilder Builder(AC, JoinBehavior);
   for (const CFGBlock *Pred : Preds) {
     // Skip if the `Block` is unreachable or control flow cannot get past it.
-    if (!Pred || Pred->hasNoReturnElement())
+    if (!Pred || Pred->isInevitablySinking())
       continue;
 
     // Skip if `Pred` was not evaluated yet. This could happen if `Pred` has a
@@ -562,7 +562,7 @@ runTypeErasedDataflowAnalysis(
     BlockStates[Block->getBlockID()] = std::move(NewBlockState);
 
     // Do not add unreachable successor blocks to `Worklist`.
-    if (Block->hasNoReturnElement())
+    if (Block->isInevitablySinking())
       continue;
 
     Worklist.enqueueSuccessors(Block);
diff --git a/clang/lib/StaticAnalyzer/Checkers/NoReturnFunctionChecker.cpp b/clang/lib/StaticAnalyzer/Checkers/NoReturnFunctionChecker.cpp
index 17c3cb4e9e04c..834bd81c2fa21 100644
--- a/clang/lib/StaticAnalyzer/Checkers/NoReturnFunctionChecker.cpp
+++ b/clang/lib/StaticAnalyzer/Checkers/NoReturnFunctionChecker.cpp
@@ -50,37 +50,45 @@ void NoReturnFunctionChecker::checkPostCall(const CallEvent &CE,
       BuildSinks = getFunctionExtInfo(C->getType()).getNoReturn();
   }
 
-  if (!BuildSinks && CE.isGlobalCFunction()) {
-    if (const IdentifierInfo *II = CE.getCalleeIdentifier()) {
-      // HACK: Some functions are not marked noreturn, and don't return.
-      //  Here are a few hardwired ones.  If this takes too long, we can
-      //  potentially cache these results.
-      BuildSinks
-        = llvm::StringSwitch<bool>(StringRef(II->getName()))
-            .Case("exit", true)
-            .Case("panic", true)
-            .Case("error", true)
-            .Case("Assert", true)
-            // FIXME: This is just a wrapper around throwing an exception.
-            //  Eventually inter-procedural analysis should handle this easily.
-            .Case("ziperr", true)
-            .Case("assfail", true)
-            .Case("db_error", true)
-            .Case("__assert", true)
-            .Case("__assert2", true)
-            // For the purpose of static analysis, we do not care that
-            //  this MSVC function will return if the user decides to continue.
-            .Case("_wassert", true)
-            .Case("__assert_rtn", true)
-            .Case("__assert_fail", true)
-            .Case("dtrace_assfail", true)
-            .Case("yy_fatal_error", true)
-            .Case("_XCAssertionFailureHandler", true)
-            .Case("_DTAssertionFailureHandler", true)
-            .Case("_TSAssertionFailureHandler", true)
-            .Default(false);
-    }
-  }
+ if (!BuildSinks && CE.isGlobalCFunction()) {
+   if (const IdentifierInfo *II = CE.getCalleeIdentifier()) {
+     // HACK: Some functions are not marked noreturn, and don't return.
+     //  Here are a few hardwired ones.  If this takes too long, we can
+     //  potentially cache these results.
+     //
+     // (!) In case of function list update, please also update
+     // CFGBuilder::VisitCallExpr (CFG.cpp)
+     BuildSinks =
+         llvm::StringSwitch<bool>(StringRef(II->getName()))
+             .Case("exit", true)
+             .Case("abort", true)
+             .Case("panic", true)
+             .Case("error", true)
+             .Case("Assert", true)
+             // FIXME: This is just a wrapper around throwing an exception.
+             //  Eventually inter-procedural analysis should handle this
+             //  easily.
+             .Case("ziperr", true)
+             .Case("assfail", true)
+             .Case("db_error", true)
+             .Case("__assert", true)
+             .Case("__assert2", true)
+             // For the purpose of static analysis, we do not care that
+             //  this MSVC function will return if the user decides to
+             //  continue.
+             .Case("_wassert", true)
+             .Case("__assert_rtn", true)
+             .Case("__assert_fail", true)
+             .Case("dtrace_assfail", true)
+             .Case("yy_fatal_error", true)
+             .Case("_XCAssertionFailureHandler", true)
+             .Case("_DTAssertionFailureHandler", true)
+             .Case("_TSAssertionFailureHandler", true)
+             .Case("__builtin_trap", true)
+             .Case("__builtin_unreachable", true)
+             .Default(false);
+   }
+ }
 
   if (BuildSinks)
     C.generateSink(C.getState(), C.getPredecessor());
diff --git a/clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp b/clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp
index 9fb7bebdbe41e..9150b3e155533 100644
--- a/clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp
+++ b/clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp
@@ -49,6 +49,7 @@ using namespace ast_matchers;
 using llvm::IsStringMapEntry;
 using ::testing::DescribeMatcher;
 using ::testing::IsEmpty;
+using ::testing::Not;
 using ::testing::NotNull;
 using ::testing::Test;
 using ::testing::UnorderedElementsAre;
@@ -693,6 +694,86 @@ TEST_F(NoreturnDestructorTest, ConditionalOperatorNestedBranchReturns) {
   // FIXME: Called functions at point `p` should contain only "foo".
 }
 
+class AnalyzerNoreturnTest : public Test {
+protected:
+  template <typename Matcher>
+  void runDataflow(llvm::StringRef Code, Matcher Expectations) {
+    tooling::FileContentMappings FilesContents;
+    FilesContents.push_back(
+        std::make_pair<std::string, std::string>("noreturn_test_defs.h", R"(
+      void assertionHandler() __attribute__((analyzer_noreturn));
+
+      void assertionTrampoline() {
+        assertionHandler();
+      }
+
+      void trap() {}
+    )"));
+
+    ASSERT_THAT_ERROR(
+        test::checkDataflow<FunctionCallAnalysis>(
+            AnalysisInputs<FunctionCallAnalysis>(
+                Code, ast_matchers::hasName("target"),
+                [](ASTContext &C, Environment &) {
+                  return FunctionCallAnalysis(C);
+                })
+                .withASTBuildArgs({"-fsyntax-only", "-std=c++17"})
+                .withASTBuildVirtualMappedFiles(std::move(FilesContents)),
+            /*VerifyResults=*/
+            [&Expectations](
+                const llvm::StringMap<
+                    DataflowAnalysisState<FunctionCallLattice>> &Results,
+                const AnalysisOutputs &) {
+              EXPECT_THAT(Results, Expectations);
+            }),
+        llvm::Succeeded());
+  }
+};
+
+TEST_F(AnalyzerNoreturnTest, Breathing) {
+  std::string Code = R"(
+    #include "noreturn_test_defs.h"
+
+    void target() {
+      trap();
+      // [[p]]
+    }
+  )";
+  runDataflow(Code, UnorderedElementsAre(IsStringMapEntry(
+                        "p", HoldsFunctionCallLattice(HasCalledFunctions(
+                                 UnorderedElementsAre("trap"))))));
+}
+
+TEST_F(AnalyzerNoreturnTest, DirectNoReturnCall) {
+  std::string Code = R"(
+    #include "noreturn_test_defs.h"
+
+    void target() {
+      assertionHandler();
+      trap();
+      // [[p]]
+    }
+  )";
+  runDataflow(Code, Not(UnorderedElementsAre(IsStringMapEntry(
+                        "p", HoldsFunctionCallLattice(HasCalledFunctions(
+                                 UnorderedElementsAre("trap")))))));
+}
+
+TEST_F(AnalyzerNoreturnTest, IndirectNoReturnCall) {
+  std::string Code = R"(
+    #include "noreturn_test_defs.h"
+
+    void target() {
+      assertionTrampoline();
+      trap();
+      // [[p]]
+    }
+  )";
+  runDataflow(Code, Not(UnorderedElementsAre(IsStringMapEntry(
+                        "p", HoldsFunctionCallLattice(HasCalledFunctions(
+                                 UnorderedElementsAre("trap")))))));
+}
+
 // Models an analysis that uses flow conditions.
 class SpecialBoolAnalysis final
     : public DataflowAnalysis<SpecialBoolAnalysis, NoopLattice> {

Copy link
Collaborator

@Xazax-hun Xazax-hun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a good change in general, but I wonder what is the performance impact.
Note that clang CFGs have many clients and some of them might not need this information. If there is a measurable performance cost, we might want to put this behind a flag, especially (if I remember correctly) as there are some on by default warnings that build the CFG.


// Some well-known 'noreturn' functions
if (!NoReturn)
NoReturn = llvm::StringSwitch<bool>(FD->getQualifiedNameAsString())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AaronBallman I wonder how you feel about having a list of well known (non standard library) functions hardcoded in the compiler?

I wonder if we want to have a more principled approach here long term, e.g., having one place in the compiler that injects annotations (like noreturn), and the rest of the code paths only handling that one annotation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AaronBallman I wonder how you feel about having a list of well known (non standard library) functions hardcoded in the compiler?

Ultimately, I determined that maintaining a hardcoded list of functions in the compiler was quite risky and could potentially cause unexpected behavior, so I removed it from the CFG implementation.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I don't think it's risky per se, but it would be frustrating to not be something the user can configure themselves. If we find we need something like this, that suggest a clang-tidy check may be a more appropriate way to surface the functionality (they've got per-check configuration options for these sort of situations).

return false;

auto CalleeCFG =
CFG::buildCFG(FD, FD->getBody(), &FD->getASTContext(), {});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible that we end up building the CFG for the same functions over and over again? This sounds like potentially wasteful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand it (please correct me if I'm wrong), FunctionDecl* instances within the constructed AST will be reused when building the CFG - if so, we could do something like:

if( FD->isAnalyzerNoReturn() || NoReturnFromCFG() ) {
     const_cast<FunctionDecl *>(FD)->addAttr(AnalyzerNoReturnAttr::Create(
      FD->getASTContext(), FD->getLocation()));
     return true;
   }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we decide to create attrs, be sure to create them as implicit, as they were not spelled in source.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we decide to create attrs, be sure to create them as implicit, as they were not spelled in source.

Fixed. Thanks for the tip!

Copy link

github-actions bot commented Jul 7, 2025

✅ With the latest revision this PR passed the C/C++ code formatter.

Copy link
Contributor

@steakhal steakhal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for working on this. I think its a great step forward.
The code looks good, but I found a couple issues, including a blocker.
I've not spent too much on the review, I might have missed something.

Thanks again for the PR :)

return CalleeCFG && CalleeCFG->getEntry().isInevitablySinking();
};

return FD->isAnalyzerNoReturn() || NoReturnFromCFG();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we have redecls, does it matter which has the attribute, or do we always bind the attribute to the canonical decl?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we have redecls, does it matter which has the attribute, or do we always bind the attribute to the canonical decl?

Good catch! I'll fix my code to use the canonical decl.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see explicit handling of this, could you also write unittests for this? The tests you added to clang/unittests/Analysis/FlowSensitive/TypeErasedDataflowAnalysisTest.cpp would be a good place IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@5chmidti i've updated both TypeErasedDataflowAnalysisTest.cpp and unhecked-optional-access.cpp files.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we have redecls, does it matter which has the attribute, or do we always bind the attribute to the canonical decl?

Switched to using canonical decl for analyzer_noreturn lookup & caching. BTW: since AnalyzerNoReturnAttr inherits from InheritableAttr, we can check any function decl.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, what does an InheritableAttr do? I couldn't find documentation for it. Do you know?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has to do with whether an attribute is automatically inherited on redeclarations. e.g., https://godbolt.org/z/1PEvG8q7M

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I swapped the do_inherit declarations, then the first declaration didn't inherit the attribute if that was put on the second declaration. If I'm not mistaken that this would mean that any references to the do_inherit function (such as calls to it) would refer to the decl without the attribute. Consequently, we can't rely on the attribute inheritance.

MutCD->addAttr(AnalyzerNoReturnAttr::CreateImplicit(
CanCD->getASTContext(), false, CanCD->getLocation()));

auto CalleeCFG =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While performance-wise this is a big step ahead, we might still end up building the CFG twice for large functions. Once when we first do this inter-procedural no-return analysis and once when we run the actual analysis on them. Did you do any benchmarking if this has any affect on the compilation with some of the frequently used configurations? (default warnings, -Wall, -Wall -Wextra)

In case there is a measurable regression, we might want to move this behind a flag.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've discussed this with @Xazax-hun and I agree with him that we should have a look at the performance of some warnings. I think they live under the AnalysisBasedWarnings.cpp file.
There could be many, but I can recall one which is the noreturn analysis, like CheckFallThrough.

Another problem with the use of this analyzer_noreturn(bool) attribute is that it will basically touch/pollute every FunctionDecl in the AST - if I understand it correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@steakhal @Xazax-hun I'm thinking about making this feature configurable through a flag. It will clearly have significant performance implications, but it's really only required for specific use cases (like clang-tidy checkers). It might be safer to explicitly opt-in only where necessary and where performance drops are more acceptable (e.g., in the unchecked-optional-access checker)

Comment on lines 2651 to 2657
/// Determines whether this function is known to never return for CFG
/// analysis. Checks for noreturn attributes on the function declaration
/// or its type, including 'analyzer_noreturn' attribute.
///
/// Returns 'std::nullopt' if function declaration has no '*noreturn'
/// attributes
std::optional<bool> getAnalyzerNoReturn() const;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of this function would suggest that it only checks the presence of analyzer_noreturn.
How about if we would rename this to something like isNoreturnForAnalyses?

BTW, how about other callables that are not FunctionDecls?
Such as ObjCMethodDecl or BlockDecl?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@steakhal i've renamed this function to getAnalyzerSinkKind().

@@ -974,7 +974,8 @@ def AnalyzerNoReturn : InheritableAttr {
// vendor namespace, or should it use a vendor namespace specific to the
// analyzer?
let Spellings = [GNU<"analyzer_noreturn">];
// TODO: Add subject list.
let Args = [DefaultBoolArgument<"Value", /*default=*/1, /*fake=*/0>];
let Subjects = SubjectList<[Function]>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could attach this attribute to ObjCMethodDecl and BlockDecl too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could attach this attribute to ObjCMethodDecl and BlockDecl too.

I'd prefer not to overcomplicate things right now and just focus on the core approach and C++ implementation - mainly because I'm completely clueless about testing ObjC code =)

MutCD->addAttr(AnalyzerNoReturnAttr::CreateImplicit(
CanCD->getASTContext(), false, CanCD->getLocation()));

auto CalleeCFG =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've discussed this with @Xazax-hun and I agree with him that we should have a look at the performance of some warnings. I think they live under the AnalysisBasedWarnings.cpp file.
There could be many, but I can recall one which is the noreturn analysis, like CheckFallThrough.

Another problem with the use of this analyzer_noreturn(bool) attribute is that it will basically touch/pollute every FunctionDecl in the AST - if I understand it correctly.

@steakhal
Copy link
Contributor

The CFG construction doesn't seem to be the right place to propagate noreturn attributes. It is already fairly complicated and the annotations seem like an orthogonal concern.

Adding deduced attributes should be a separate step in the ASTConsumers pipeline.
We take the AST, do some analysis and mutate the AST by adding the deduced attributes to the relevant AST nodes, and only to those where it makes sense.

This worked for us downstream for a similar use-cases. This also side-steps patching the CFG construction, because by the time we get there, we are already done with the annotation phase thus, it would see the implict (deduced) analyzer_noreturn attributes.

We build the CallGraph and iterate it in Post-order to make sure leaf calls are inferred first, thus the callers would get to see the deduced analyzer_noreturn attributes too and build from the bottom up.

If I had time, I could probably try to upstream this part.

The beauty of this approach is that its a separate component, and we would not need to piggyback on a default bool parameter of the analyzer_noreturn attribute.

@negativ
Copy link
Contributor Author

negativ commented Jul 25, 2025

Adding deduced attributes should be a separate step in the ASTConsumers pipeline. We take the AST, do some analysis and mutate the AST by adding the deduced attributes to the relevant AST nodes, and only to those where it makes sense.

Could you please point to some good example of using ASTConsumers pipeline?

This worked for us downstream for a similar use-cases. This also side-steps patching the CFG construction, because by the time we get there, we are already done with the annotation phase thus, it would see the implict (deduced) analyzer_noreturn attributes.

Wouldn't that mean we'd have to reimplement CFG's useful features like unreachable code detection?

If I had time, I could probably try to upstream this part.

I'I'd appreciate it if you could point me to parts of the codebase I could look at to learn and try implementing the feature myself =)

@negativ
Copy link
Contributor Author

negativ commented Jul 25, 2025

I’m thinking of splitting this into two PRs: one for basic analyzer_noreturn attribute processing, and another for implementing the propagation mechanism.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
clang:analysis clang:dataflow Clang Dataflow Analysis framework - https://clang.llvm.org/docs/DataFlowAnalysisIntro.html clang:frontend Language frontend issues, e.g. anything involving "Sema" clang:static analyzer clang Clang issues not falling into any other category clang-tidy clang-tools-extra
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants