Skip to content

SIGSEGV when exception thrown during coroutine promise aggregate initialization (Clang 21 / nlohmann::json) #2579

Description

@DreamDonghao

Describe the bug

A drogon::Task<T> coroutine that takes a parameter implicitly convertible to T crashes with SIGSEGV at the call site — before a single line of the coroutine body executes — when that conversion throws. The process dies inside _Unwind_Resume while unwinding from the partially constructed coroutine frame;
the exception is never catchable.

The most common real-world trigger is nlohmann::json: its implicit operator ValueType() calls get<ValueType>(), which throws nlohmann::detail::type_error.302 for mismatched types. We first hit this in production: a bot process terminated silently right after invoking a coroutine tool handler whose
argument is a JSON object (Task<std::string> taking const json&). Nothing was logged — the process simply vanished.

Root cause

  1. drogon::Task<T>::promise_type (lib/inc/drogon/utils/coroutine.h) is an aggregate — no user-declared constructors — and its first data member is std::optional<T> value.
  2. Clang 21 aggregate-initializes the promise from the coroutine arguments, positionally: the first argument initializes value, so any implicit conversion to T runs during promise construction — before the body and before initial_suspend.
  3. If that conversion throws, unwinding from the partially constructed coroutine frame faults in _Unwind_Resume.

Crash report excerpt from the minimal repro below:

Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
0  libunwind.dylib  _Unwind_Resume +228
1  repro_min        byRef(FailConvert const&) +1060   <- coroutine ramp (promise init); body never entered
2  repro_min        main +136

To Reproduce

Environment: macOS 26.5.2 (arm64), Apple clang 21.0.0 (clang-2100.1.1.101), drogon 1.9.13 (Homebrew). C++20 suffices; no third-party library needed — the repro uses a 5-line stand-in type.

// repro.cpp
#include <drogon/utils/coroutine.h>
#include <cstdio>
#include <stdexcept>
#include <string>

// Minimal stand-in for nlohmann::json: implicitly convertible to std::string,
// and the conversion throws at runtime.
struct FailConvert
{
    operator std::string() const
    {
        printf("  converting argument -> std::string (during promise init!)\n");
        std::fflush(stdout);
        throw std::runtime_error("conversion failed");
    }
};

drogon::Task<std::string> byRef(const FailConvert v)
{
    co_return "ok";  // never reached on Clang 21
}

drogon::Task<std::string> byPtr(const FailConvert *v)
{
    co_return "ok";
}

int main(int argc, char **argv)
{
    FailConvert v;
    try
    {
        if (argc > 1)
        {
            auto t = byPtr(&v);      // pointer: no conversion, promise default-constructed
            printf("pointer param   : ok\n");
        }
        else
        {
            auto t = byRef(v);       // reference: Clang 21 aggregate-inits promise.value from it
            printf("reference param : ok\n");
        }
    }
    catch (const std::exception &e)
    {
        printf("caught: %s\n", e.what());
    }
    return 0;
}
clang++ -std=c++20 repro.cpp -I/opt/homebrew/include -o repro   # adjust include path to your drogon

./repro
#   converting argument -> std::string (during promise init!)
# Segmentation fault: 11   (exit code 139; the catch in main is never reached)

./repro ptr
# pointer param   : ok

Per the standard C++20 rules, when no promise constructor takes the coroutine parameters the promise is default-constructed — that is what the reference variant should do (earlier compilers behave this way; only Clang 21 is available in this environment, so we did not re-verify older compilers ourselves).

Expected behavior

A conversion that throws during promise initialization must not crash the process with SIGSEGV. Either the exception should propagate cleanly to the caller after the coroutine frame is properly destroyed, or — as prescribed when no promise constructor takes the parameters — the promise should be
default-constructed and the coroutine body should run.

Desktop (please complete the following information):

  • OS: macOS 26.5.2 (arm64, Build 25F84)
  • Compiler: Apple clang 21.0.0 (clang-2100.1.1.101), /usr/bin/c++
  • Drogon Version: 1.9.13 (Homebrew); still present on master (lib/inc/drogon/utils/coroutine.h has no user-declared promise constructor)
  • nlohmann/json: 3.12.0 (real-world trigger, used in the production project where this was found)

Additional context

Minimal library-side fix: add a user-declared default constructor to promise_type so it is no longer an aggregate, and Clang 21+ falls back to default construction (same semantics as pre-Clang-21 compilers):

--- a/lib/inc/drogon/utils/coroutine.h
+++ b/lib/inc/drogon/utils/coroutine.h
@@
     struct promise_type
     {
+        // A user-declared constructor makes promise_type a non-aggregate, so
+        // Clang 21+ will not aggregate-initialize `value` from the first
+        // coroutine argument (see #2579).
+        promise_type() = default;
+
         Task<T> get_return_object()
         {
             return Task<T>{handle_type::from_promise(*this)};
         }

Verified locally: with these lines added to Task<T>::promise_type and Task<void>::promise_type (the latter's first member is std::exception_ptr — same latent hazard), the repro prints reference param : ok on Clang 21. AsyncTask::promise_type has no data members and is not affected. The same helper type
makes for a simple regression test: invoking byRef(v) from a test body is enough — with the bug the process segfaults before the next line; with the fix the coroutine behaves normally.

In our project we worked around it by taking const json* instead of const json& in all such coroutine signatures; we would rather revert that once a fixed version ships.

There may also be a Clang-side issue in unwinding from a partially constructed coroutine frame (the SIGSEGV is inside _Unwind_Resume), but the library-side fix above sidesteps the situation entirely. I'm happy to submit a PR with this change.

Activity

  1. DreamDonghao commented on Sep 3, 2026

    @DreamDonghao
    ContributorAuthor

    A note on why Clang 21 triggers this

    I'd like to add some analysis on why Clang 21 behaves this way, from a semantic perspective.

    Conceptually, a coroutine frame should contain two distinct logical areas:

    1. Parameter storage — copies of the arguments passed from the caller, for use inside the coroutine body.
    2. Promise result slot — the storage where the co_return value ends up (i.e., std::optional value in Task::promise_type), for the caller to retrieve via the Task object.

    These two are logically decoupled. Parameters are inputs; the result is an output. Before the coroutine body executes — specifically, before co_return — the result slot should be either uninitialized or default-constructed. It should not be touched by input arguments.

    What Clang 21 does, however, is to treat promise_type as an aggregate and aggregate-initialize the entire promise from the coroutine arguments positionally — meaning the first argument goes straight into the first member, which happens to be value. This effectively forces:

    promise.value = arg;  // at coroutine startup, before the body runs

    This conflates two independent lifecycle events — parameter copy and result initialization — into a single atomic operation (aggregate initialization). As a result, an implicit conversion (FailConvert → std::string) that should logically happen at co_return (when the result is actually produced) gets triggered at frame allocation time, before the exception handling machinery for the coroutine body is fully ready, leading to the unwind crash.

    In short: Clang 21 is treating an input parameter as if it were an initializer for the output storage. This is not a logical error in Drogon's Task design — it's an implementation strategy in Clang that violates the intuitive separation between "arguments" and "return value storage."

    The library-side fix (adding a user-declared default constructor to promise_type so it's no longer an aggregate) is a clean workaround.

  2. marty1885 commented on Sep 3, 2026

    @marty1885
    Member

    @DreamDonghao Thanks for debugging can finding this. As drogon's maintainer I am keen to merge in the patch (since it has no harm to perf on GCC). My major complain is this is a clang bug and clang needs to fix it before other projects run into it. Have you reported the issue to LLVM?

  3. DreamDonghao commented on Sep 3, 2026

    @DreamDonghao
    ContributorAuthor

    @marty1885

    Thank you for the review and feedback.

    Regarding your question about reporting this to LLVM — I'm currently preparing a detailed issue and plan to submit it to the LLVM GitHub repository within the next couple of days. While researching existing discussions, I came across a related issue from 2024 (#84519), which deals with lambda coroutines and promise_type constructor argument passing. Although the exact trigger is different from our case (aggregate initialization + exception during conversion causing SIGSEGV), both issues revolve around the construction behavior of promise_type in coroutines, so they likely stem from the same underlying area.

    I'll include the minimal standalone reproducer (without any dependency on Drogon or nlohmann/json) in the new issue to help LLVM developers pinpoint the problem quickly.

    I'll post the link here once the issue is filed.

  4. DreamDonghao commented on Sep 3, 2026

    @DreamDonghao
    ContributorAuthor

    @marty1885
    Regarding the LLVM report, I have filed an issue; the link is below:

    llvm/llvm-project#220915

    While preparing the standalone reproducer, I also clarified the actual trigger chain in Drogon:

    Drogon's Task::promise_type is an aggregate type, and its first data member is std::optional value.
    → Under Clang 21's current non‑conforming behavior, coroutine parameters are used to aggregate‑initialize value.
    → Taking nlohmann::json as an example: its implicit conversion to T throws type_error::302 when the type mismatches, and this conversion happens during promise construction.
    Drogon's final_awaiter::await_suspend() returns a std::coroutine_handle (symmetric‑transfer form).
    → When the exception thrown during promise construction is unwound, Clang's generated cleanup code accesses an invalid coroutine‑frame state, causing a SIGSEGV inside _Unwind_Resume.
    → The process dies silently, and the exception cannot be caught by any try‑catch.
    This analysis is fully described in the LLVM issue. The library‑side fix (adding a user‑declared default constructor to make promise_type non‑aggregate) completely avoids this trigger chain and is consistent with standard behavior.

  5. added a commit that references this issue on Oct 4, 2026
    bfb3ae2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions