top of page

When Coroutines Betray You: A Lambda Lifetime Bug You Don’t See Coming

  • Writer: alex d
    alex d
  • 5 days ago
  • 3 min read

Coroutines are powerful.They make asynchronous code readable, composable, and expressive.But they are also merciless when it comes to lifetime mistakes.

In this post, I want to walk through a real bug that looks completely harmless at first glance — modern C++, clean abstractions, universal references — and still ends up as a data-integrity time bomb.

This is not a coroutine bug.This is a fundamentals bug, made visible by coroutines.

The Setup: Everything Looks Fine

Imagine the following scenario:

  • We have a coroutine that performs an operation with retries

  • Up to 10 instances of this coroutine may run concurrently

  • A helper function with_retries takes a callable and retries it on failure

  • The callable is passed as an inline lambda

Conceptually, the flow looks like this:

  • Coroutine A starts

  • It calls with_retries

  • with_retries repeatedly invokes the lambda

  • Other coroutines may be running at the same time

Nothing unusual here.This is a very common and very reasonable abstraction.

The with_retries function is implemented using a universal reference:

 feature<> with_retries(auto&& f);

When its called We’re passing a temporary lambda an rvalue exactly what universal references are meant for.

So… what’s the problem?

The Subtle Bug: Lifetime, Not Value Category

The key issue is not how the lambda is used.It’s where it lives.

Let’s break it down carefully.

  • The lambda is created at the call site

  • It is passed as an rvalue reference

  • Inside with_retries, the parameter is never materialized

  • That means no copy, no move — just a reference

So what does F&& f actually refer to?

👉 It refers to a lambda object that still lives on the caller’s stack.

Now introduce coroutines.


Coroutines Change the Rules

When a coroutine suspends:

  • Its execution is paused

  • Control returns to the caller

  • The caller’s stack frame may be destroyed


That’s exactly what happens here:

  1. Coroutine starts and calls with_retries

  2. with_retries schedules work and suspends

  3. The calling coroutine completes

  4. The lambda is destroyed

  5. with_retries resumes

  6. 💥 Dangling reference


No warnings. No crashes (at least not immediately).

Just undefined behavior waiting to happen and in the end if all starts will align it will explode.


“But I Didn’t std::forward It!”

That’s part of the trap.

Even if you don’t forward the callable, the parameter f is still:

  • A reference

  • Bound to a temporary

  • Referring to an object not owned by with_retries

Yes, f is an lvalue expression inside the function —but it does not represent an object with extended lifetime.

This distinction matters a lot once suspension enters the picture.


The Assembly Tells the Truth

lea rdi, [rbp - 208] 
mov qword ptr [rbp - 240], rdi 
lea rsi, [rbp - 56] 
call std::vector<int, std::allocator<int>>::vector(std::vector<int, std::allocator<int>>&&) 
mov rdi, qword ptr [rbp - 240] 
call void with_retries<main::$_0>(main::$_0&&) 
jmp .LBB0_3 lea rdi, [rbp - 208] 
call main::$_0::~$_0() [base object destructor]

If you look at the generated assembly, the story becomes obvious:

  • The lambda is constructed on the stack

  • Its address is passed into with_retries

  • When the caller exits, the lambda destructor runs

  • Later, with_retries calls operator() on freed memory

The code looks high-level and safe.The assembly is brutally honest.


The Fix: Own What You Use

The correct fix is simple and fundamental:

Take the callable by value.

future<> with_retries(auto f);

Now:

  • The lambda is moved into with_retries

  • Its lifetime is tied to the coroutine frame

  • Suspension is safe

  • Retries are safe

  • Data integrity is preserved


Why not co_await with_retries?

That would technically fix the lifetime issue but it defeats the design goal.

The whole point is to:

  • Start work

  • Allow other coroutines to run

  • Let with_retries finish independently

So awaiting it is the wrong semantic choice here (maybe its good in your case).


The Bigger Lesson

This bug has nothing to do with retries.

Nothing to do with lambdas.Nothing to do with templates.

It’s about ownership and lifetime.

Coroutines don’t create lifetime bugs they expose them.

Much faster than threads ever did.

If you don’t have a rock-solid understanding of:

  • Value categories

  • References vs ownership

  • Object lifetime across suspension

Coroutines will find the cracks in your mental model.


Final Thought

Modern C++ gives us incredible tools.Coroutines are one of the best additions we’ve ever had.

But they demand respect.

Because in asynchronous code,the basics are never optional and never “basic.” 🚀

 
 

Recent Posts

See All
bottom of page