top of page

A Tiny Coroutine, a Big Crash💥

  • Writer: alex d
    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:

  1. A coroutine frame is allocated (usually on the heap)

  2. The frame contains:

    • the promise_type

    • local variables

    • bookkeeping state

  3. A std::coroutine_handle<promise_type> is created pointing to that frame

  4. promise.get_return_object() is called → you get BadTask{h}

  5. 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 💥





 
 
bottom of page