[clang-tools-extra] [clang-tidy] Add concurrency-lambda-coroutine-capture check (PR #182916)

Willem Kaufmann via cfe-commits cfe-commits at lists.llvm.org
Mon Feb 23 11:46:49 PST 2026


https://github.com/WillemKauf updated https://github.com/llvm/llvm-project/pull/182916

>From ebbfb8ea6a18c70f1e0d72c73571ede0c3daec62 Mon Sep 17 00:00:00 2001
From: Willem Kaufmann <willem.kaufmann at gmail.com>
Date: Mon, 23 Feb 2026 13:53:00 -0500
Subject: [PATCH 1/2] [clang-tidy] Add `concurrency-lambda-coroutine-capture
 check`

Adds a new check which finds lambda coroutines that capture variables
without using the C++23 "deducing this" explicit object parameter,
which can cause use-after-free when the lambda is destroyed while
the coroutine is still suspended (the "lambda coroutine fiasco") [1].

The check provides fix-it hints to insert `this auto` as the first
parameter, which moves captures into the coroutine frame and
decouples their lifetime from the lambda object.

[1]: https://github.com/scylladb/seastar/blob/master/doc/lambda-coroutine-fiasco.md
---
 .../clang-tidy/concurrency/CMakeLists.txt     |   1 +
 .../concurrency/ConcurrencyTidyModule.cpp     |   3 +
 .../LambdaCoroutineCaptureCheck.cpp           |  72 ++++++++++
 .../concurrency/LambdaCoroutineCaptureCheck.h |  41 ++++++
 clang-tools-extra/docs/ReleaseNotes.rst       |   8 ++
 .../concurrency/lambda-coroutine-capture.rst  |  46 ++++++
 .../docs/clang-tidy/checks/list.rst           |   1 +
 .../concurrency/lambda-coroutine-capture.cpp  | 132 ++++++++++++++++++
 8 files changed, 304 insertions(+)
 create mode 100644 clang-tools-extra/clang-tidy/concurrency/LambdaCoroutineCaptureCheck.cpp
 create mode 100644 clang-tools-extra/clang-tidy/concurrency/LambdaCoroutineCaptureCheck.h
 create mode 100644 clang-tools-extra/docs/clang-tidy/checks/concurrency/lambda-coroutine-capture.rst
 create mode 100644 clang-tools-extra/test/clang-tidy/checkers/concurrency/lambda-coroutine-capture.cpp

diff --git a/clang-tools-extra/clang-tidy/concurrency/CMakeLists.txt b/clang-tools-extra/clang-tidy/concurrency/CMakeLists.txt
index 91c6cedabcc80..ffd871bb1059c 100644
--- a/clang-tools-extra/clang-tidy/concurrency/CMakeLists.txt
+++ b/clang-tools-extra/clang-tidy/concurrency/CMakeLists.txt
@@ -5,6 +5,7 @@ set(LLVM_LINK_COMPONENTS
 
 add_clang_library(clangTidyConcurrencyModule STATIC
   ConcurrencyTidyModule.cpp
+  LambdaCoroutineCaptureCheck.cpp
   MtUnsafeCheck.cpp
   ThreadCanceltypeAsynchronousCheck.cpp
 
diff --git a/clang-tools-extra/clang-tidy/concurrency/ConcurrencyTidyModule.cpp b/clang-tools-extra/clang-tidy/concurrency/ConcurrencyTidyModule.cpp
index 0d02a3d9b6e2b..c071f11009359 100644
--- a/clang-tools-extra/clang-tidy/concurrency/ConcurrencyTidyModule.cpp
+++ b/clang-tools-extra/clang-tidy/concurrency/ConcurrencyTidyModule.cpp
@@ -8,6 +8,7 @@
 
 #include "../ClangTidy.h"
 #include "../ClangTidyModule.h"
+#include "LambdaCoroutineCaptureCheck.h"
 #include "MtUnsafeCheck.h"
 #include "ThreadCanceltypeAsynchronousCheck.h"
 
@@ -18,6 +19,8 @@ namespace {
 class ConcurrencyModule : public ClangTidyModule {
 public:
   void addCheckFactories(ClangTidyCheckFactories &CheckFactories) override {
+    CheckFactories.registerCheck<LambdaCoroutineCaptureCheck>(
+        "concurrency-lambda-coroutine-capture");
     CheckFactories.registerCheck<concurrency::MtUnsafeCheck>(
         "concurrency-mt-unsafe");
     CheckFactories.registerCheck<ThreadCanceltypeAsynchronousCheck>(
diff --git a/clang-tools-extra/clang-tidy/concurrency/LambdaCoroutineCaptureCheck.cpp b/clang-tools-extra/clang-tidy/concurrency/LambdaCoroutineCaptureCheck.cpp
new file mode 100644
index 0000000000000..446e3298505b2
--- /dev/null
+++ b/clang-tools-extra/clang-tidy/concurrency/LambdaCoroutineCaptureCheck.cpp
@@ -0,0 +1,72 @@
+//===----------------------------------------------------------------------===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+
+#include "LambdaCoroutineCaptureCheck.h"
+#include "clang/AST/ASTContext.h"
+#include "clang/ASTMatchers/ASTMatchFinder.h"
+
+using namespace clang::ast_matchers;
+
+namespace clang::tidy::concurrency {
+
+namespace {
+
+AST_MATCHER(LambdaExpr, hasCoroutineBody) {
+  const Stmt *Body = Node.getBody();
+  return Body != nullptr && isa<CoroutineBodyStmt>(Body);
+}
+
+AST_MATCHER(LambdaExpr, capturesWithoutDeducingThis) {
+  if (Node.capture_size() == 0U)
+    return false;
+  const auto *Call = Node.getCallOperator();
+  return !Call->isExplicitObjectMemberFunction();
+}
+
+} // namespace
+
+void LambdaCoroutineCaptureCheck::registerMatchers(MatchFinder *Finder) {
+  Finder->addMatcher(
+      lambdaExpr(hasCoroutineBody(), capturesWithoutDeducingThis())
+          .bind("lambda"),
+      this);
+}
+
+void LambdaCoroutineCaptureCheck::check(
+    const MatchFinder::MatchResult &Result) {
+  const auto *MatchedLambda = Result.Nodes.getNodeAs<LambdaExpr>("lambda");
+  const auto *Call = MatchedLambda->getCallOperator();
+  const bool HasExplicitParams = MatchedLambda->hasExplicitParameters();
+
+  auto DiagBuilder =
+      diag(MatchedLambda->getExprLoc(),
+           "lambda coroutine with captures may cause use-after-free; use "
+           "'this auto' as the first parameter to move captures into the "
+           "coroutine frame");
+
+  if (HasExplicitParams) {
+    const bool HasParams = !Call->param_empty();
+    if (HasParams) {
+      const ParmVarDecl *FirstParam = Call->parameters().front();
+      DiagBuilder << FixItHint::CreateInsertion(FirstParam->getBeginLoc(),
+                                                "this auto, ");
+    } else {
+      // Empty parens `()` — insert `this auto` before the closing paren.
+      DiagBuilder << FixItHint::CreateInsertion(
+          Call->getFunctionTypeLoc().getRParenLoc(), "this auto");
+    }
+  } else {
+    // No explicit parameter list — insert `(this auto)` after the
+    // capture list closing `]`.
+    auto IntroRange = MatchedLambda->getIntroducerRange();
+    DiagBuilder << FixItHint::CreateInsertion(
+        IntroRange.getEnd().getLocWithOffset(1), "(this auto)");
+  }
+}
+
+} // namespace clang::tidy::concurrency
diff --git a/clang-tools-extra/clang-tidy/concurrency/LambdaCoroutineCaptureCheck.h b/clang-tools-extra/clang-tidy/concurrency/LambdaCoroutineCaptureCheck.h
new file mode 100644
index 0000000000000..aaf51b5b4fefc
--- /dev/null
+++ b/clang-tools-extra/clang-tidy/concurrency/LambdaCoroutineCaptureCheck.h
@@ -0,0 +1,41 @@
+//===----------------------------------------------------------------------===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+
+#ifndef LLVM_CLANG_TOOLS_EXTRA_CLANG_TIDY_CONCURRENCY_LAMBDACOROUTINECAPTURECHECK_H
+#define LLVM_CLANG_TOOLS_EXTRA_CLANG_TIDY_CONCURRENCY_LAMBDACOROUTINECAPTURECHECK_H
+
+#include "../ClangTidyCheck.h"
+
+namespace clang::tidy::concurrency {
+
+/// Finds lambda coroutines that capture variables without using the C++23
+/// "deducing this" syntax, which can lead to use-after-free bugs.
+///
+/// When a lambda coroutine is passed to APIs that store the lambda temporarily
+/// (e.g. ``seastar::future::then()``), the lambda object may be destroyed while
+/// the coroutine is still suspended. Captures reference the lambda's storage,
+/// so accessing them after the lambda is destroyed causes undefined behavior.
+/// The C++23 explicit object parameter (``this auto``) moves captures into the
+/// coroutine frame, decoupling their lifetime from the lambda object.
+///
+/// For the user-facing documentation see:
+/// https://clang.llvm.org/extra/clang-tidy/checks/concurrency/lambda-coroutine-capture.html
+class LambdaCoroutineCaptureCheck : public ClangTidyCheck {
+public:
+  LambdaCoroutineCaptureCheck(StringRef Name, ClangTidyContext *Context)
+      : ClangTidyCheck(Name, Context) {}
+  void registerMatchers(ast_matchers::MatchFinder *Finder) override;
+  void check(const ast_matchers::MatchFinder::MatchResult &Result) override;
+  bool isLanguageVersionSupported(const LangOptions &LangOpts) const override {
+    return LangOpts.CPlusPlus23;
+  }
+};
+
+} // namespace clang::tidy::concurrency
+
+#endif // LLVM_CLANG_TOOLS_EXTRA_CLANG_TIDY_CONCURRENCY_LAMBDACOROUTINECAPTURECHECK_H
diff --git a/clang-tools-extra/docs/ReleaseNotes.rst b/clang-tools-extra/docs/ReleaseNotes.rst
index 64c8fbbe2f07a..8b806027e8add 100644
--- a/clang-tools-extra/docs/ReleaseNotes.rst
+++ b/clang-tools-extra/docs/ReleaseNotes.rst
@@ -111,6 +111,14 @@ New checks
   Finds functions where throwing exceptions is unsafe but the function is still
   marked as potentially throwing.
 
+- New :doc:`concurrency-lambda-coroutine-capture
+  <clang-tidy/checks/concurrency/lambda-coroutine-capture>` check.
+
+  Finds lambda coroutines that capture variables without using the C++23
+  "deducing this" (explicit object parameter) syntax, which can lead to
+  use-after-free bugs when the lambda object is destroyed while the coroutine
+  is still suspended.
+
 - New :doc:`llvm-type-switch-case-types
   <clang-tidy/checks/llvm/type-switch-case-types>` check.
 
diff --git a/clang-tools-extra/docs/clang-tidy/checks/concurrency/lambda-coroutine-capture.rst b/clang-tools-extra/docs/clang-tidy/checks/concurrency/lambda-coroutine-capture.rst
new file mode 100644
index 0000000000000..5149bf2d72ae5
--- /dev/null
+++ b/clang-tools-extra/docs/clang-tidy/checks/concurrency/lambda-coroutine-capture.rst
@@ -0,0 +1,46 @@
+.. title:: clang-tidy - concurrency-lambda-coroutine-capture
+
+concurrency-lambda-coroutine-capture
+====================================
+
+Finds lambda coroutines that capture variables without using the C++23
+"deducing this" (explicit object parameter) syntax, which can lead to
+use-after-free bugs.
+
+The C++ Core Guidelines have described the problem with lambda coroutines
+with non-empty capture lists in `CP.51
+<https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#rcoro-capture>`_.
+However, the C++23 explicit object parameter (``this auto``) solves the
+issue of the closure object being destructed before the lambda coroutine resumes
+by moving captures directly into the coroutine frame, decoupling their lifetime from
+the lambda object.
+
+Example
+-------
+
+Before:
+
+.. code-block:: c++
+
+  auto handler = [data](int x) -> task {
+    co_await process(data, x);  // 'data' may be dangling
+  };
+
+After:
+
+.. code-block:: c++
+
+  auto handler = [data](this auto, int x) -> task {
+    co_await process(data, x);  // captures live in the coroutine frame
+  };
+
+Options
+-------
+
+This check has no configurable options.
+
+This check requires C++23 or later (``-std=c++23``). For pre-C++23 code, see
+:doc:`cppcoreguidelines-avoid-capturing-lambda-coroutines
+<../cppcoreguidelines/avoid-capturing-lambda-coroutines>` which flags the same
+problem but recommends avoiding captures entirely rather than using deducing
+this.
diff --git a/clang-tools-extra/docs/clang-tidy/checks/list.rst b/clang-tools-extra/docs/clang-tidy/checks/list.rst
index c475870ed7b31..815093f2f55fa 100644
--- a/clang-tools-extra/docs/clang-tidy/checks/list.rst
+++ b/clang-tools-extra/docs/clang-tidy/checks/list.rst
@@ -183,6 +183,7 @@ Clang-Tidy Checks
    :doc:`bugprone-use-after-move <bugprone/use-after-move>`,
    :doc:`bugprone-virtual-near-miss <bugprone/virtual-near-miss>`, "Yes"
    :doc:`cert-err33-c <cert/err33-c>`,
+   :doc:`concurrency-lambda-coroutine-capture <concurrency/lambda-coroutine-capture>`, "Yes"
    :doc:`concurrency-mt-unsafe <concurrency/mt-unsafe>`,
    :doc:`concurrency-thread-canceltype-asynchronous <concurrency/thread-canceltype-asynchronous>`,
    :doc:`cppcoreguidelines-avoid-capturing-lambda-coroutines <cppcoreguidelines/avoid-capturing-lambda-coroutines>`,
diff --git a/clang-tools-extra/test/clang-tidy/checkers/concurrency/lambda-coroutine-capture.cpp b/clang-tools-extra/test/clang-tidy/checkers/concurrency/lambda-coroutine-capture.cpp
new file mode 100644
index 0000000000000..0d2e1b6c4ec28
--- /dev/null
+++ b/clang-tools-extra/test/clang-tidy/checkers/concurrency/lambda-coroutine-capture.cpp
@@ -0,0 +1,132 @@
+// RUN: %check_clang_tidy -std=c++23 %s concurrency-lambda-coroutine-capture %t
+
+// Minimal coroutine support types.
+namespace std {
+
+template <typename R, typename...> struct coroutine_traits {
+  using promise_type = typename R::promise_type;
+};
+
+template <typename Promise = void> struct coroutine_handle;
+
+template <> struct coroutine_handle<void> {
+  static coroutine_handle from_address(void *);
+  coroutine_handle() = default;
+  coroutine_handle(decltype(nullptr)) {}
+};
+
+template <typename Promise> struct coroutine_handle : coroutine_handle<> {
+  static coroutine_handle from_address(void *);
+};
+
+struct suspend_never {
+  bool await_ready() noexcept { return true; }
+  void await_suspend(coroutine_handle<>) noexcept {}
+  void await_resume() noexcept {}
+};
+
+} // namespace std
+
+struct task {
+  struct promise_type {
+    task get_return_object() { return {}; }
+    std::suspend_never initial_suspend() { return {}; }
+    std::suspend_never final_suspend() noexcept { return {}; }
+    void return_void() {}
+    void unhandled_exception() {}
+  };
+};
+
+template <typename F> void invoke(F &&f) {}
+
+// --- Cases that SHOULD trigger the warning ---
+
+void test_capture_no_parens() {
+  int x = 42;
+  invoke([&x] -> task {
+    // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: lambda coroutine with captures may cause use-after-free; use 'this auto' as the first parameter to move captures into the coroutine frame [concurrency-lambda-coroutine-capture]
+    // CHECK-FIXES: {{^}}  invoke([&x](this auto) -> task {{{$}}
+    co_return;
+  });
+}
+
+void test_capture_empty_parens() {
+  int x = 42;
+  invoke([&x]() -> task {
+    // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: lambda coroutine with captures may cause use-after-free
+    // CHECK-FIXES: {{^}}  invoke([&x](this auto) -> task {{{$}}
+    co_return;
+  });
+}
+
+void test_capture_with_params() {
+  int x = 42;
+  invoke([&x](int a) -> task {
+    // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: lambda coroutine with captures may cause use-after-free
+    // CHECK-FIXES: {{^}}  invoke([&x](this auto, int a) -> task {{{$}}
+    co_return;
+  });
+}
+
+void test_capture_with_multiple_params() {
+  int x = 42;
+  invoke([&x](int a, int b) -> task {
+    // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: lambda coroutine with captures may cause use-after-free
+    // CHECK-FIXES: {{^}}  invoke([&x](this auto, int a, int b) -> task {{{$}}
+    co_return;
+  });
+}
+
+void test_capture_by_value() {
+  int x = 42;
+  invoke([x]() -> task {
+    // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: lambda coroutine with captures may cause use-after-free
+    // CHECK-FIXES: {{^}}  invoke([x](this auto) -> task {{{$}}
+    co_return;
+  });
+}
+
+void test_default_capture_ref() {
+  int x = 42;
+  invoke([&]() -> task {
+    // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: lambda coroutine with captures may cause use-after-free
+    // CHECK-FIXES: {{^}}  invoke([&](this auto) -> task {{{$}}
+    (void)x;
+    co_return;
+  });
+}
+
+void test_default_capture_copy() {
+  int x = 42;
+  invoke([=]() -> task {
+    // CHECK-MESSAGES: :[[@LINE-1]]:10: warning: lambda coroutine with captures may cause use-after-free
+    // CHECK-FIXES: {{^}}  invoke([=](this auto) -> task {{{$}}
+    (void)x;
+    co_return;
+  });
+}
+
+// --- Cases that should NOT trigger the warning ---
+
+void test_no_captures_no_coroutine() {
+  invoke([]() { return; });
+}
+
+void test_no_captures_coroutine() {
+  invoke([]() -> task { co_return; });
+}
+
+void test_deducing_this_coroutine() {
+  int x = 42;
+  invoke([&x](this auto) -> task { co_return; });
+}
+
+void test_deducing_this_with_params() {
+  int x = 42;
+  invoke([&x](this auto, int a) -> task { co_return; });
+}
+
+void test_captures_not_coroutine() {
+  int x = 42;
+  invoke([&x]() { (void)x; });
+}

>From b7ab6a48367f159079a358af2941762d79f775fe Mon Sep 17 00:00:00 2001
From: Willem Kaufmann <willem.kaufmann at gmail.com>
Date: Mon, 23 Feb 2026 13:53:22 -0500
Subject: [PATCH 2/2] [clang-tidy] Suppress `avoid-capturing-lambda-coroutines`
 for `deducing this`

The `cppcoreguidelines-avoid-capturing-lambda-coroutines` check warns
on all lambda coroutines with captures, including C++23 lambdas
that use explicit object parameters (deducing this) which are safe
from the lifetime issue. Suppress the warning when the lambda uses
`this auto`, and cross-reference the two related checks in their
documentation.
---
 .../AvoidCapturingLambdaCoroutinesCheck.cpp               | 8 +++++++-
 .../avoid-capturing-lambda-coroutines.rst                 | 7 +++++++
 .../avoid-capturing-lambda-coroutines.cpp                 | 6 ++++++
 3 files changed, 20 insertions(+), 1 deletion(-)

diff --git a/clang-tools-extra/clang-tidy/cppcoreguidelines/AvoidCapturingLambdaCoroutinesCheck.cpp b/clang-tools-extra/clang-tidy/cppcoreguidelines/AvoidCapturingLambdaCoroutinesCheck.cpp
index 618554663ab91..3dc008a0bc399 100644
--- a/clang-tools-extra/clang-tidy/cppcoreguidelines/AvoidCapturingLambdaCoroutinesCheck.cpp
+++ b/clang-tools-extra/clang-tidy/cppcoreguidelines/AvoidCapturingLambdaCoroutinesCheck.cpp
@@ -21,12 +21,18 @@ AST_MATCHER(LambdaExpr, hasCoroutineBody) {
 }
 
 AST_MATCHER(LambdaExpr, hasCaptures) { return Node.capture_size() != 0U; }
+
+AST_MATCHER(LambdaExpr, hasDeducingThis) {
+  return Node.getCallOperator()->isExplicitObjectMemberFunction();
+}
 } // namespace
 
 void AvoidCapturingLambdaCoroutinesCheck::registerMatchers(
     MatchFinder *Finder) {
   Finder->addMatcher(
-      lambdaExpr(hasCaptures(), hasCoroutineBody()).bind("lambda"), this);
+      lambdaExpr(hasCaptures(), hasCoroutineBody(), unless(hasDeducingThis()))
+          .bind("lambda"),
+      this);
 }
 
 bool AvoidCapturingLambdaCoroutinesCheck::isLanguageVersionSupported(
diff --git a/clang-tools-extra/docs/clang-tidy/checks/cppcoreguidelines/avoid-capturing-lambda-coroutines.rst b/clang-tools-extra/docs/clang-tidy/checks/cppcoreguidelines/avoid-capturing-lambda-coroutines.rst
index 58bfc35c557dc..c617a7715f6d1 100644
--- a/clang-tools-extra/docs/clang-tidy/checks/cppcoreguidelines/avoid-capturing-lambda-coroutines.rst
+++ b/clang-tools-extra/docs/clang-tidy/checks/cppcoreguidelines/avoid-capturing-lambda-coroutines.rst
@@ -52,3 +52,10 @@ captures or ensuring the lambda closure object has a guaranteed lifetime.
 
 Following these guidelines can help ensure the safe and reliable use of
 coroutine lambdas in C++ code.
+
+In C++23, the "deducing this" (explicit object parameter) syntax provides a
+solution to this problem by moving captures into the coroutine frame. Lambda
+coroutines using ``this auto`` as their first parameter are not flagged by
+this check. See :doc:`concurrency-lambda-coroutine-capture
+<../concurrency/lambda-coroutine-capture>` for a check that can automatically
+apply this fix.
diff --git a/clang-tools-extra/test/clang-tidy/checkers/cppcoreguidelines/avoid-capturing-lambda-coroutines.cpp b/clang-tools-extra/test/clang-tidy/checkers/cppcoreguidelines/avoid-capturing-lambda-coroutines.cpp
index 6670f4a5420be..3e83fbefba7cf 100644
--- a/clang-tools-extra/test/clang-tidy/checkers/cppcoreguidelines/avoid-capturing-lambda-coroutines.cpp
+++ b/clang-tools-extra/test/clang-tidy/checkers/cppcoreguidelines/avoid-capturing-lambda-coroutines.cpp
@@ -33,4 +33,10 @@ void Safe() {
     [=] () -> task { co_return; };
 
     [&v]{++v;}();
+
+#if __cplusplus >= 202302L
+    // Lambda coroutines using C++23 deducing this are safe.
+    [&v] (this auto) -> task { co_return; };
+    [v] (this auto, int x) -> task { co_return; };
+#endif
 }



More information about the cfe-commits mailing list