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

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