The IoAwaitable Protocol

This section explains the IoAwaitable protocol—Capy’s mechanism for propagating execution context through coroutine chains.

Prerequisites

The Problem: Context Propagation

Standard C++20 coroutines define awaiters with this await_suspend signature:

void await_suspend(std::coroutine_handle<> h);
// or
bool await_suspend(std::coroutine_handle<> h);
// or
std::coroutine_handle<> await_suspend(std::coroutine_handle<> h);

The awaiter receives only a handle to the suspended coroutine. But real I/O code needs more:

  • Executor — Where should completions be dispatched?

  • Stop token — Should this operation support cancellation?

  • Allocator — Where should memory be allocated?

How does an awaitable get this information?

Backward Query Approach

One approach: the awaitable queries the calling coroutine’s promise for context. This requires the awaitable to know the promise type, creating tight coupling.

Forward Propagation Approach

Capy uses forward propagation: the caller passes context to the awaitable through an extended await_suspend signature.

The Two-Argument await_suspend

The IoAwaitable protocol extends await_suspend to receive context:

std::coroutine_handle<> await_suspend(std::coroutine_handle<> h, io_env const* env);

This signature receives:

  • h — The coroutine handle (as in standard awaiters)

  • env — The execution environment containing:

    • env→executor — The caller’s executor for dispatching completions

    • env→stop_token — A stop token for cooperative cancellation

    • env→frame_allocator — An optional frame allocator

Many IoAwaitables return std::coroutine_handle<> to enable symmetric transfer, but the concept does not require any particular return type.

IoAwaitable Concept

An awaitable satisfies IoAwaitable if a.await_suspend(h, env) is a valid expression:

template<typename A>
concept IoAwaitable =
    requires(A a, std::coroutine_handle<> h, io_env const* env) {
        a.await_suspend(h, env);
    };

The concept constrains only the two-argument await_suspend that receives the io_env. It does not require await_ready or await_resume, nor does it constrain the return type of await_suspend. A complete awaitable still provides await_ready and await_resume so it can be co_await-ed; the concept simply does not test for them.

IoRunnable Concept

For tasks that can be launched from non-coroutine contexts, the IoRunnable concept refines IoAwaitable and requires a promise_type plus the following:

  • handle(): Access the typed coroutine handle

  • release(): Transfer ownership of the frame

  • exception(): Check for captured exceptions (on the promise)

  • result(): Access the result value (on the promise, for non-void tasks)

  • set_continuation(): Set the continuation handle (on the promise)

  • set_environment(): Inject the io_env (on the promise)

These methods exist because launch functions like run_async cannot co_await the task directly. The trampoline must be allocated before the task type is known, so it type-erases the task through function pointers and needs a common API to manage lifetime and extract results.

The context injection methods set_continuation and set_environment are part of the IoRunnable concept: it requires them on the promise_type. Launch functions access them through the typed handle provided by handle().

Capy’s task<T> satisfies this concept.

How Context Flows

When you write co_await child_task() inside a task<T>:

  1. The parent task’s await_transform intercepts the awaitable

  2. It wraps the child in a transform awaiter

  3. The transform awaiter’s await_suspend passes context:

template<class Awaitable>
auto await_suspend(std::coroutine_handle<Promise> h)
{
    // Forward caller's context to child
    return awaitable_.await_suspend(h, promise_.environment());
}

The child receives the parent’s executor and stop token automatically.

Why Forward Propagation?

Forward propagation offers several advantages:

  • Decoupling — Awaitables don’t need to know caller’s promise type

  • Composability — Any IoAwaitable works with any IoRunnable task

  • Explicit flow — Context flows downward through the call chain, not queried upward

This design enables Capy’s type-erased wrappers (any_stream, etc.) to work without knowing the concrete executor type.

Implementing Custom IoAwaitables

To create a custom IoAwaitable:

struct my_awaitable
{
    io_env const* env_ = nullptr;
    std::coroutine_handle<> continuation_;
    result_type result_;

    bool await_ready() const noexcept
    {
        return false;  // Or true if result is immediately available
    }

    std::coroutine_handle<> await_suspend(std::coroutine_handle<> h, io_env const* env)
    {
        // Store pointer to environment, never copy
        env_ = env;
        continuation_ = h;

        // Start async operation...
        start_operation();

        // Return noop to suspend
        return std::noop_coroutine();
    }

    result_type await_resume()
    {
        return result_;
    }

private:
    void on_completion()
    {
        // Resume on caller's executor
        env_->executor.dispatch(continuation_);
    }
};

The key points:

  1. Store the io_env as a pointer (io_env const*), never a copy. Launch functions guarantee the io_env outlives the awaitable’s operation.

  2. Use the executor to dispatch completion

  3. Respect the stop token for cancellation

Stop Callbacks Must Post, Not Resume

When implementing a stoppable awaitable, you may register a std::stop_callback to wake the coroutine when cancellation is requested. The callback fires synchronously on whatever thread calls request_stop(), which is typically not the executor’s thread.

Never resume a coroutine handle directly from a stop_callback. Doing so executes the coroutine on the wrong thread, corrupting the thread-local frame allocator. This causes use-after-free on the next coroutine allocation—potentially in completely unrelated code.

Post the resume through the executor instead of resuming inline:

struct stoppable_awaitable
{
    mutable continuation cont_;

    bool await_ready() { return false; }

    std::coroutine_handle<> await_suspend(
        std::coroutine_handle<> h, io_env const* env)
    {
        if (env->stop_token.stop_requested())
            return h;  // Already cancelled, resume immediately

        // Post through executor when stop is requested
        cont_.h = h;
        auto ex = env->executor;
        stop_cb_.emplace(env->stop_token,
            [this, ex]() mutable noexcept { ex.post(cont_); });

        start_async_operation();
        return std::noop_coroutine();
    }

    void await_resume() { /* ... */ }
};

The incorrect pattern—which compiles and appears to work but causes memory corruption—looks like this:

// WRONG: resumes coroutine on the calling thread
stop_cb_.emplace(env->stop_token, h);  // h is a raw coroutine_handle

See Implementing Stoppable Awaitables for a complete example.

For a production implementation of this exact pattern, read the source of delay_awaitable (delay_awaitable): it schedules a timer, registers a stop callback that posts the resume through the executor, and arbitrates between the timer and cancellation with a single atomic claim.

Reference

Header Description

<boost/capy/concept/io_awaitable.hpp>

The IoAwaitable concept definition

<boost/capy/concept/io_runnable.hpp>

The IoRunnable concept for launchable tasks

You have now learned how the IoAwaitable protocol enables context propagation through coroutine chains. In the next section, you will learn about stop tokens and cooperative cancellation.