A Tiny Coroutine, a Big Crash💥
- alex d
- 12 minutes ago
- 3 min read

How One Line of C++ Caused a Segmentation Fault
Coroutines are one of the most exciting additions to modern C++. They promise cleaner async code, better readability, and fewer callback pyramids.
But they also come with something very C++-like:
👉 lifetime rules you must understand
In this post, we’ll look at a tiny coroutine that looks perfectly safe and still crashes with a segmentation fault.
The Innocent Code
Let’s start with a minimal example:
#include <coroutine>
#include <utility>
struct BadTask {
struct promise_type {
BadTask get_return_object() {
return BadTask{
std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; } // 🚨
void return_void() noexcept {}
void unhandled_exception() { std::terminate(); }
};
using H = std::coroutine_handle<promise_type>;
H h{};
explicit BadTask(H hh) : h(hh) {}
BadTask(BadTask&& o) noexcept { std::swap(h, o.h); }
~BadTask() {
if (h) h.destroy(); // looks safe… right?
}
};
BadTask boom() {
co_return;
}
int main() {
auto t = boom();
}You look at it and think:
“This is fine. What could possibly go wrong?”
And then…
💥 Segmentation fault
From this code.
The Short Explanation
The short answer is:
Undefined Behavior
But let’s understand why What Actually Happens When You Call a Coroutine because this is exactly the kind of bug that bites experienced C++ developers.
Calling a coroutine is very different from calling a normal function.
When boom() is called, the compiler transforms it into a state machine.
Roughly speaking, this happens:
A coroutine frame is allocated (usually on the heap)
The frame contains:
the promise_type
local variables
bookkeeping state
A std::coroutine_handle<promise_type> is created pointing to that frame
promise.get_return_object() is called → you get BadTask{h}
The coroutine body starts executing
At this point, your BadTask object holds a handle to a valid coroutine frame.
Everything still looks correct.
boom()
│
▼
┌─────────────────────────────┐
│ Allocate coroutine frame │
│ (usually on the heap) │
│ │
│ - promise_type │
│ - local variables │
│ - bookkeeping state │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ std::coroutine_handle<h> │
│ points to that frame │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ get_return_object() │
│ → BadTask{ h } │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ Coroutine body runs │
└─────────────────────────────┘
The Hidden Trap: final_suspend
At the end of the coroutine (after co_return), execution reaches the final suspend point.
In the bad code, we wrote:
std::suspend_never final_suspend() noexcept;
This tells the coroutine runtime:
Do not suspend at the end
Finish immediately
You are allowed to destroy the coroutine frame right away
In other words:
The coroutine self-destructs as soon as it finishes.
The Fatal Mismatch
Now look at the destructor:
~BadTask() {
if (h) h.destroy();
}
Here’s the problem:
The coroutine already destroyed its own frame at final_suspend
BadTask still holds a handle to that frame
When BadTask goes out of scope, we call destroy() again
🎯 Double destruction → undefined behavior
The handle was never set to nullptr, so we confidently destroy… memory that no longer exists.
That’s where the segmentation fault comes from.
Why This Bug Is So Dangerous
This is a classic C++ foot-gun because:
The code looks reasonable
The destructor looks “safe”
It may work sometimes
It may crash only under optimization or a different allocator
Undefined behavior doesn’t always fail immediately and that’s what makes it scary.
The Fix (One Line)
The real issue is ownership.
You must decide who owns the coroutine frame.
If your wrapper owns it, the coroutine must not destroy itself.
The fix is literally one line:
std::suspend_always final_suspend() noexcept;
This changes everything:
The coroutine always suspends at the end
The frame stays alive
The wrapper destroys it exactly once
No self-destruction. No double free. No crash.
The Real Lesson
Coroutines are powerful but they are not magic.
They introduce:
explicit ownership
explicit lifetime
explicit destruction rules
If you mix ownership models, C++ won’t protect you.
It will just crash.
Takeaway
If your wrapper destroys the coroutine handle, final_suspend must suspend. If the coroutine self-destroys, the wrapper must not.
Once you internalize this rule, coroutines become safe, predictable, and incredibly elegant.
Until then…even the smallest coroutine can go 💥


