[C++20 Coroutines] SIGSEGV in `_Unwind_Resume` when the promise object's initialization throws and `final_suspend()` uses symmetric transfer
- Dominant language
- LLVM
- Stars
- 40.5k
- Forks
- 18.7k
- PR merge metrics
- PR metrics pending
Description
**Describe the bug**
When the initialization of a C++20 coroutine's promise object throws — e.g. a user-defined conversion invoked by a promise constructor taking the coroutine parameter — Clang 21 mis-unwinds out of the partially constructed coroutine frame and the process dies with SIGSEGV inside `_Unwind_Resume`. This happens only when the coroutine's `final_suspend()` returns an awaiter whose `await_suspend` returns a `std::coroutine_handle` (the symmetric-transfer form). The exception cannot be caught by any handler in the program; the process terminates silently.
The identical program catches the exception normally when either:
- the final awaiter's `await_suspend` returns `void` instead of a `std::coroutine_handle`, or
- `final_suspend()` returns `std::suspend_always`.
The crash reproduces with `-std=c++20` and `-std=c++23`, and — in this environment — only at `-O0` (`-O1`, `-O2`, `-O3`, `-Os` are clean), which suggests mis-generated exception cleanup around the partially constructed coroutine frame.
**Steps to reproduce the bug**
No third-party library is needed. `arg` stands in for `nlohmann::json`, whose implicit `operator ValueType()` throws `type_error.302` on a type mismatch — the real-world trigger.
```cpp
// repro.cpp
#include
#include
#include
#include
#include
struct arg {
operator std::string() const {
std::puts(" converting argument -> std::string");
std::fflush(stdout);
throw std::runtime_error("conversion failed");
}
};
// Symmetric-transfer flavor of a final_suspend awaiter: await_suspend returns
// a std::coroutine_handle. This shape is required to trigger the crash; the
// same awaiter with a void-returning await_suspend does not crash.
struct final_awaiter {
bool await_ready() noexcept { return false; }
auto await_suspend(std::coroutine_handle<>) noexcept { return std::noop_coroutine(); }
void await_resume() noexcept {}
};
struct task {
struct promise_type {
std::optional value;
// The promise is initialized from the coroutine parameter through this
// user-declared constructor (fully conforming); the conversion throws.
promise_type() = default;
promise_type(const arg &a) : value(a) {}
task get_return_object() { return {}; }
std::suspend_never initial_suspend() noexcept { return {}; }
final_awaiter final_suspend() noexcept { return {}; }
void return_value(std::string v) { value = std::move(v); }
void unhandled_exception() {}
};
};
task f(const arg &a) {
co_return "ok"; // never reached when the crash happens
}
int main() {
try {
task t = f(arg{});
std::puts("call returned normally");
} catch (const std::exception &e) {
std::printf("caught: %s\n", e.what());
}
std::puts("main exited normally");
}
```
Build and run:
```bash
$ clang++ -std=c++20 -O0 repro.cpp -o repro && ./repro; echo "exit: $?"
converting argument -> std::string
exit: 139
```
The conversion message proves the conversion runs while initializing the promise object, at the call site, before any coroutine body code. The catch block in `main` is never reached.
Crash report (macOS):
```text
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Subtype: KERN_INVALID_ADDRESS at 0x0000000000000010
0 libunwind.dylib _Unwind_Resume
1 repro f(arg const&) <- coroutine ramp; body never entered
2 repro main
```
For reference, the same program with `await_suspend` returning `void` (or with `final_suspend()` returning `std::suspend_always`) prints:
```text
converting argument -> std::string
caught: conversion failed
main exited normally
```
**Expected behavior**
An exception thrown while initializing the promise object must propagate cleanly to the caller of the coroutine (with the coroutine state properly deallocated) and be catchable, independent of the shape of the `final_suspend()` awaiter. The promise constructor used here is fully standard-conforming: [dcl.fct.def.coroutine] initializes the promise via overload resolution on a promise constructor call assembled from the coroutine parameters, and `promise_type(const arg&)` is viable here.
**Additional context**
- The crash does not depend on the promise being an aggregate; the reproducer's promise is a non-aggregate with a user-declared constructor from the coroutine parameter.
- Optimization sweep for the reproducer: `-O0` → SIGSEGV; `-O1`/`-O2`/`-O3`/`-Os` → exception caught cleanly.
- Real-world trigger: `drogon::Task` (1.9.13) — its `promise_type` is an aggregate whose first data member is `std::optional value;` and whose `final_suspend()` returns a symmetric-transfer awaiter — combined with `nlohmann::json` (3.12.0) parameters. A production bot process died silently this way: calling a `drogon::Task` coroutine taking `const json&` with a mismatched JSON value throws during promise initialization, and the process vanishes with no log output.
- Separate non-conformance observed along the way (may deserve its own issue): Clang 21 initializes an aggregate `promise_type` from the coroutine parameters even when overload resolution on the promise constructor finds no viable constructor, although [dcl.fct.def.coroutine] requires `promise-constructor-arguments` to be empty in that case ("If a viable constructor is found ([over.match.viable]), then promise-constructor-arguments is (q1, …, qn), otherwise promise-constructor-arguments is empty") and the promise to be default-initialized. Demonstrated with a non-throwing conversion and a promise suspended at `initial_suspend` and never resumed (the body never runs): the converted parameter is found in the promise's first data member — the return-value slot — where `promise.value.has_value()` is `true` and `*promise.value == "PARAM"`, while the wording above requires `has_value() == false`. For aggregate promise types such as drogon's, this is what places a throwing conversion at the call site in the first place.
**Version**
```text
$ clang++ --version
Apple clang version 21.0.0 (clang-2100.1.1.101)
Target: arm64-apple-darwin25.5.0
Thread model: posix
InstalledDir: /Library/Developer/CommandLineTools/usr/bin
```
macOS 26.5.2 (Build 25F84), arm64. Reproduced only with this toolchain so far; not yet checked against an LLVM.org build.
Contributor guide
Research direction
Start with the repro.cpp program and run the provided clang++ -std=c++20 -O0 command, comparing it with the void-returning await_suspend, std::suspend_always, and optimized builds. Investigate the coroutine ramp's exception cleanup during promise initialization and symmetric transfer; done means the conversion exception reaches main's catch block and the process exits normally.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100