drogonframework / drogonframework/drogon
SIGSEGV when exception thrown during coroutine promise aggregate initialization (Clang 21 / nlohmann::json)
- Dominant language
- C++
- Stars
- 14.3k
- Forks
- 1.4k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 14
Description
**Describe the bug**
A `drogon::Task` 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()`, 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` taking `const json&`). Nothing was logged — the process simply vanished.
**Root cause**
1. `drogon::Task::promise_type` (`lib/inc/drogon/utils/coroutine.h`) is an **aggregate** — no user-declared constructors — and its **first data member is `std::optional 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:
```text
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.
```cpp
// repro.cpp
#include
#include
#include
#include
// 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 byRef(const FailConvert v)
{
co_return "ok"; // never reached on Clang 21
}
drogon::Task 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;
}
```
```bash
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):
```diff
--- 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 get_return_object()
{
return Task{handle_type::from_promise(*this)};
}
```
Verified locally: with these lines added to `Task::promise_type` and `Task::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.
Contributor guide
Research direction
Start in lib/inc/drogon/utils/coroutine.h by reading Task::promise_type and Task::promise_type, then reproduce the failure with the supplied FailConvert example on Clang 21. Add a regression test around invoking byRef(v) and verify that the process no longer segfaults and the coroutine behaves normally.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100