The IoAwaitable Protocol
This section explains the IoAwaitable protocol—Capy’s mechanism for propagating execution context through coroutine chains.
Prerequisites
-
Completed Executors and Execution Contexts
-
Understanding of standard awaiter protocol (
await_ready,await_suspend,await_resume)
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?
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 theio_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>:
-
The parent task’s
await_transformintercepts the awaitable -
It wraps the child in a transform awaiter
-
The transform awaiter’s
await_suspendpasses 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.
A Vocabulary for Coroutine Interop
Because the protocol is just a two-argument await_suspend, IoAwaitable is more than an internal mechanism—it is a vocabulary type for interoperation. Any coroutine library that speaks the protocol can propagate execution environment across a co_await boundary without knowing the other side’s concrete task type.
This is the interop problem framed on the home page: a shared protocol replaces the set of pairwise adapters that separate coroutine libraries would otherwise need to talk to one another. Capy is the reference implementation of that protocol.
Implementing Custom IoAwaitables
To create a custom IoAwaitable:
struct my_awaitable
{
io_env const* env_ = nullptr;
continuation cont_;
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;
// Wrap the caller's handle in a continuation we own, so it stays
// at a stable address until the executor resumes it.
cont_.h = 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 the caller on its executor. post() takes the
// continuation by reference and queues it; never resume inline
// from a completion callback (it may run on the wrong thread).
env_->executor.post(cont_);
}
};
The key points:
-
Store the
io_envas a pointer (io_env const*), never a copy. Launch functions guarantee theio_envoutlives the awaitable’s operation. -
To resume the caller, wrap its handle in a
continuationand pass that to the executor’spost(ordispatch) — these take acontinuation&, not a rawcoroutine_handle. Store thecontinuationin the awaitable so it keeps a stable address until the executor dequeues and resumes it; the executor links continuations intrusively, so a temporary would dangle. -
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.
Bridging a Foreign Awaitable
What happens when you co_await an awaitable that does not implement the protocol—a "plain" awaitable from another library, with the standard one-argument await_suspend?
Capy rejects it at compile time:
// In task.hpp, when the awaited type is not an IoAwaitable:
static_assert(sizeof(A) == 0, "requires IoAwaitable");
This is intentional. A plain awaitable receives only the coroutine handle; it can resume the coroutine on any thread by calling handle.resume() directly. That silently breaks the same-executor invariant—the coroutine could wake on a foreign completion thread, leaving shared state you believed was strand-protected exposed to races. Rejecting such an awaitable at compile time prevents that. The constraint does not lock you in; it requires environment propagation to be explicit rather than silently dropped.
The escape hatch is to wrap the foreign awaitable (or callback, or future) in a small IoAwaitable that captures the executor and re-posts the resumption through it:
// Bridge a foreign async operation into a Capy coroutine.
struct foreign_bridge
{
io_env const* env_ = nullptr;
continuation cont_;
result_type result_;
bool await_ready() const noexcept { return false; }
std::coroutine_handle<> await_suspend(
std::coroutine_handle<> h, io_env const* env)
{
env_ = env;
cont_.h = h; // stable address; the executor links continuations intrusively
// Start the foreign operation. Its completion callback may run on
// ANY thread, so it must not resume h directly. Instead it posts
// the continuation back through the caller's executor, restoring
// the same-executor invariant.
start_foreign_op([this]() noexcept {
store_result();
env_->executor.post(cont_);
});
return std::noop_coroutine();
}
result_type await_resume() { return std::move(result_); }
};
The single rule that makes any bridge correct: on completion, post through env→executor instead of resuming the handle inline.
There Is No Universal Bridge
Capy does not provide a generic co_await foreign_awaitable(x) that adapts arbitrary awaitables automatically. Such an adapter cannot work in the general case: it has no way to know how a foreign runtime schedules its completions, so it cannot guarantee the invariant. A shared protocol addresses this where a hidden adapter cannot. A small, explicit bridge per foreign runtime keeps the guarantee intact and the cost visible.
Worked Bridges
Two complete, buildable bridges live in the examples:
-
Bridging a P2300 Sender —
await_senderadapts astd::executionsender, mapping its completion channels ontoio_resultand posting the resumption through the executor. -
Calling Asio from a Capy Coroutine — the
use_capycompletion token turns any Asio async operation into anIoAwaitable.
Reference
| Header | Description |
|---|---|
|
The IoAwaitable concept definition |
|
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.