The Most Vexing Capture, in the Wild

TL;DR: A coroutine function can return a value (like Detached{}) before its body has finished executing. If that function is an unnamed lambda, its captures — whether by reference or by value — live in the closure object, not the coroutine frame. If the closure is a temporary, it’s destroyed while the coroutine is still suspended, and all captures dangle. [&](this auto) -> Coro { ... }() fixes it. David Mazières’ The Most Vexing Capture explains the mechanics. This is a field report of getting bitten by it.

I spent a couple of days chasing a coroutine frame data race in a C++23 async RPC library. The fix ended up being a fairly small change to how we manage coroutine handle ownership. Finding it, however, was not small — it was one of those investigations where you end up reading compiler IR and staring at memory orderings until things start making sense. That kind of deep dive puts you in a particular headspace, and from that headspace we wrote a bunch of new stress tests to exercise the paths we’d been worried about. One of the tests that I wrote was a multi-threaded RPC write-buffer stress scenario.

Looked good locally to me, but on the very first CI run ASAN lit up on it:

ERROR: AddressSanitizer: stack-use-after-scope on address 0x7a6600201388
READ of size 8 thread T5
    #0 operator() rpcstress.cc:473

Stack-use-after-scope. The coroutine was reading from a stack frame that ASAN considered out of scope. Confusing, because the enclosing function was still running — blocked in a while loop right below the launch site. The stack frame was alive. So what was out of scope?

[&]() -> Detached {
  UniqueFd fd{socket(addr.ss_family, SOCK_STREAM, 0)};
  if (!fd)
    co_return;
  if (auto res = co_await cp.connect(fd, &addr); !res)
    co_return;
  auto c = rpc_stream(cp, fd.release())->client(rpc_v1{});
  // ... launch work on c ...
}();

That [&]() -> Detached { ... }() is an immediately-invoked lambda coroutine capturing by reference. And it’s a temporary. The closure is created, operator() is called, the coroutine suspends, and a Detached is returned to the caller — before the coroutine body has finished executing. Then the temporary is destroyed. All in one expression. The coroutine body, meanwhile, is suspended at co_await cp.connect(...) and will resume on a worker thread — at which point it reaches through a dangling this to access its captures.

This is exactly what the Most Vexing Capture describes. Lambda captures — whether by reference or by value — live in the closure object, not in the coroutine frame. If the closure is a temporary, it’s destroyed before the coroutine finishes, and all captures dangle. It compiles without a whisper. The lambda looks fine. The captures look fine. Everything about it looks fine, right up until the coroutine resumes on a different thread and the closure is gone.

The humbling part is I wrote this bug myself, in brand new test code, while actively investigating a different coroutine lifetime issue. I was already thinking hard about frame lifetimes and destruction ordering. Didn’t matter. The MVC is that invisible.

(this auto)

C++23 deducing this makes the fix a one-token change:

[&](this auto) -> Detached {
  // captures now survive suspension
  // ...
}();

this auto makes the closure an explicit object parameter — a coroutine parameter — so it gets copied into the coroutine frame. The temporary still dies at the semicolon, but the coroutine has its own copy of the closure and its captures.

One subtlety: this auto copies the entire closure into the coroutine frame, regardless of capture mode. Without it, both [&] and [=] captures dangle — value captures are just as dead as reference captures when the temporary is destroyed. With this auto, [=] captures are fully owned by the frame copy and safe. But [&] captures survive as copied references that still point at the original objects, so the enclosing scope must outlive the coroutine. If it might not, use [=](this auto) or explicit parameters. I think?

What sticks with me is how latent this class of bug is. Without ASAN in CI I’d never have caught it — the memory was still there, it would’ve kept working by accident. Undefined behavior the entire time. And any change to suspension behavior — tweaking initial_suspend, adding a co_await, refactoring a coroutine type — can reshuffle destruction ordering and expose MVCs that were previously hidden by luck. We’ve had this in the production code, mine just happened to only be in the test code designed to find other races!

Coroutines were only introduced in C++20 to make asynchronous code easier and safer to write. It’s my first time ever working with them, the only comparable I have really are Go’s goroutines which I’ve come to appreciate. Better than callback hell? Almost certainly. But they’re still brutally hard to reason about, in my experience so far. The abstraction is thinner than it looks. Maybe I’ll get better at it, we’ll see.

David’s post covers the language-level analysis and standardization path far better than I can. This is just a +1 from the trenches for (this auto) as the right default on any lambda coroutine with captures.

← Back to posts

© 2026 Mark Mayo · RSS