Skip to content

lambda coroutine captures are a foot-gun #119

@vinniefalco

Description

@vinniefalco
= Lambda Coroutine Captures: A Critical Pitfall

This guide explains a subtle but dangerous issue with C++20 lambda coroutines that every user of async libraries must understand.

== The Problem

Consider this innocent-looking code:

[source,cpp]
----
void process(socket& sock)
{
    auto task = [&sock]() -> capy::task<>
    {
        char buf[1024];
        auto [ec, n] = co_await sock.read_some(buffer(buf, sizeof(buf)));
        // ... use data ...
    }();
    
    run_async(executor)(std::move(task));
}
----

**This code has undefined behavior.** It may crash, corrupt memory, or appear to work until it doesn't.

== Why It Fails

In C++20, **lambda coroutine captures are NOT stored in the coroutine frame**. They are stored in the lambda closure object. Here's what happens:

1. The lambda closure is created, capturing `sock` by reference
2. The lambda's `operator()()` is called
3. A coroutine frame is allocated on the heap
4. The coroutine suspends at `initial_suspend`
5. `operator()()` returns the task
6. **The lambda closure is destroyed** (it was a temporary!)
7. Later, the coroutine resumes
8. The coroutine tries to access `sock` through the destroyed closure
9. **Undefined behavior** — typically a crash

The coroutine frame does not contain a copy of the captured `sock`. It contains a reference to the lambda's capture storage, which no longer exists.

== The Safe Pattern: IIFE With Parameters

The solution is to pass values as **function parameters** instead of **lambda captures**. Function parameters ARE copied to the coroutine frame.

[source,cpp]
----
void process(socket& sock)
{
    auto task = [](socket* s) -> capy::task<>  // <1>
    {
        char buf[1024];
        auto [ec, n] = co_await s->read_some(buffer(buf, sizeof(buf)));
        // ... use data ...
    }(&sock);  // <2>
    
    run_async(executor)(std::move(task));
}
----
<1> The lambda takes a parameter instead of capturing
<2> The value is passed as an argument to the immediately-invoked lambda

This is called an **Immediately Invoked Lambda Expression (IIFE)**. The parameter `s` is copied to the coroutine frame before the first suspension, so it remains valid for the coroutine's lifetime.

== Complete Example

Here's a before/after comparison:

=== BROKEN: Using Captures

[source,cpp]
----
class connection_handler
{
    socket sock_;
    std::string name_;
    
public:
    capy::task<> run()
    {
        // BROKEN: 'this' captured in lambda, lambda destroyed after invoke
        return [this]() -> capy::task<>
        {
            log("Connection from", name_);  // UB: 'this' is dangling
            co_await handle_request();
        }();
    }
};
----

=== CORRECT: Using Parameters

[source,cpp]
----
class connection_handler
{
    socket sock_;
    std::string name_;
    
public:
    capy::task<> run()
    {
        // CORRECT: 'self' is a parameter, copied to coroutine frame
        return [](connection_handler* self) -> capy::task<>
        {
            log("Connection from", self->name_);  // OK
            co_await self->handle_request();
        }(this);
    }
};
----

== When Are Captures Safe?

Captures are only safe when the lambda object **outlives the coroutine**:

[source,cpp]
----
// SAFE: lambda stored in 'handler', outlives coroutine
auto handler = [&sock]() -> capy::task<>
{
    co_await sock.read_some(...);
};

// Lambda 'handler' still exists here
run_and_wait(handler());  // Blocks until coroutine completes
// Lambda destroyed after coroutine finishes
----

However, this pattern is rare. Most async code immediately invokes the lambda and discards it, making captures unsafe.

== Rules of Thumb

1. **Default to IIFE with parameters** for lambda coroutines
2. **Never capture by reference** (`[&]`) in a lambda coroutine unless you're certain the lambda outlives the coroutine
3. **Capturing by value** (`[=]`, `[x]`) is equally broken — the copy lives in the lambda, not the coroutine frame
4. **Capturing `this`** is particularly dangerous and common
5. **When in doubt, use parameters**

== Alternative: Named Coroutine Functions

If the IIFE syntax feels awkward, use a named function instead:

[source,cpp]
----
class connection_handler
{
    socket sock_;
    
    // Named coroutine member function
    capy::task<> do_handle()
    {
        // 'this' is an implicit parameter, handled correctly
        co_await sock_.read_some(...);
    }
    
public:
    capy::task<> run()
    {
        return do_handle();
    }
};
----

NOTE: Member function coroutines work correctly because `this` is an implicit **parameter**, not a capture. The compiler handles it properly.

== Why Does C++ Work This Way?

The C++ standard specifies that coroutine parameters are copied to the coroutine state, but lambda captures are not. This is because:

- Lambda captures are part of the lambda's closure type
- The coroutine is the lambda's `operator()`
- The coroutine frame only stores what's needed for the function body
- The closure is external to the function body

There have been proposals to change this behavior, but as of C++23, the issue remains. Always use the safe patterns described above.

== Summary

|===
| Pattern | Safety | Notes

| `[x]() -> task<> { use(x); }()`
| UNSAFE
| Capture `x` destroyed with lambda

| `[](auto x) -> task<> { use(x); }(val)`
| SAFE
| Parameter `x` in coroutine frame

| `[&x]() -> task<> { use(x); }()`
| UNSAFE
| Reference to lambda storage, not `x`

| `[](auto& x) -> task<> { use(x); }(val)`
| SAFE*
| Reference parameter, `val` must outlive coroutine

| Member function coroutine
| SAFE
| `this` is implicit parameter
|===

When writing lambda coroutines, **pass values as parameters, not captures**.

This is in AsciiDoc format to match your existing docs. If you want me to switch to Agent mode and create the file, let me know which path you'd like it at.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions