Bridging a P2300 Sender

Awaiting a std::execution (P2300) sender from inside a Capy coroutine.

What You Will Learn

  • How to co_await a foreign awaitable that does not implement the IoAwaitable protocol

  • How a bridge restores the same-executor invariant by posting the resumption through the caller’s executor

  • How a sender’s value and error completion channels map onto io_result

Prerequisites

This example is built only when BOOST_CAPY_BUILD_P2300_EXAMPLES=ON (it requires C++23 and fetches beman.execution). The full source is in example/sender-bridge/.

The Bridge

A P2300 sender is a foreign awaitable: it has no two-argument await_suspend, so a Capy task cannot co_await it directly. await_sender wraps it in an IoAwaitable. The key move is in the bridge’s receiver: when the sender completes—on whatever thread its scheduler chose—it does not resume the coroutine inline. It stores the result and posts the continuation back through the caller’s executor. From example/sender-bridge/sender_awaitable.hpp:

template<class... Args>
void set_value(Args&&... args) && noexcept
{
    result_->template emplace<1>(
        std::forward<Args>(args)...);
    // resume on the caller's executor
    env_->executor.post(cont_);
}

await_sender inspects the sender’s completion signatures. If the sender can complete with set_error(std::error_code), the bridge yields io_result<T> so the error stays a value rather than an exception; otherwise it yields the value directly.

Source Code

#include "sender_awaitable.hpp"

#include <boost/capy.hpp>

#include <beman/execution/execution.hpp>

#include <iostream>
#include <latch>
#include <thread>

namespace capy = boost::capy;
namespace ex = beman::execution;

capy::task<int> compute(auto sched)
{
    int result = co_await capy::await_sender(
        ex::schedule(sched)
            | ex::then([] {
                std::cout
                    << "  sender running on thread "
                    << std::this_thread::get_id() << "\n";
                return 42 * 42;
            }));

    std::cout
        << "  coroutine resumed on thread "
        << std::this_thread::get_id() << "\n";

    co_return result;
}

int main()
{
    std::cout
        << "main thread: "
        << std::this_thread::get_id() << "\n";

    // Capy execution context
    capy::thread_pool pool;

    // Beman execution context (run_loop on a dedicated thread)
    ex::run_loop loop;
    std::jthread loop_thread([&loop] {
        loop.run();
    });
    auto sched = loop.get_scheduler();

    std::latch done(1);
    int answer = 0;

    auto on_complete = [&](int v) {
        answer = v;
        done.count_down();
    };

    auto on_error = [&](std::exception_ptr ep) {
        try { std::rethrow_exception(ep); }
        catch (std::exception const& e) {
            std::cerr << "error: " << e.what() << "\n";
        }
        done.count_down();
    };

    capy::run_async(
        pool.get_executor(),
        on_complete,
        on_error)(compute(sched));

    done.wait();
    loop.finish();

    std::cout << "result: " << answer << "\n";
}

Output

The sender runs on the run_loop thread, but the coroutine resumes on the thread_pool executor it was launched with—the invariant holds across the bridge:

main thread: 139667952822976
  sender running on thread 139667946014400
  coroutine resumed on thread 139667920406208
result: 1764

See Also