SIGSEGV when exception thrown during coroutine promise aggregate initialization (Clang 21 / nlohmann::json)
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
drogon::Task<T>::promise_type(lib/inc/drogon/utils/coroutine.h) is an aggregate — no user-declared constructors — and its first data member isstd::optional<T> value.- Clang 21 aggregate-initializes the promise from the coroutine arguments, positionally: the first argument initializes
value, so any implicit conversion toTruns during promise construction — before the body and beforeinitial_suspend. - 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 +136To 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 : okPer 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.hhas 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.
Source: drogonframework/drogon