c++boostlanguage-lawyerc++20boost-asio

Is the lifetime of a local lambda as a completion handler for co_spawn i.e. a function with functor&& sufficent


Question

I am getting a little confused or paranoid, given the pattern:

void setup(boost::asio::io_context &context) {
    const auto completion_handler = [](std::exception_ptr ptr) {
        if (ptr) {
            std::cout << "Rethrowing in completion handler" << std::endl;
            std::rethrow_exception(ptr);
        } else {
            std::cout << "Completed without error" << std::endl;
        }
    };
    boost::asio::co_spawn(context, coroutine_with_rethrow_completion_handler(), completion_handler);
}

Is the lifetime of completion_handler, i.e. being local, ok?


Reasoning

AFAIK of course any local capture would be bad, as they would be out of scope when eventually boost::asio::io_context will run that handler. But whats about the lifetime of that very handler i.e. functor i.e. lambda itself?

boost::asio::co_spawn takes a &&, which AFAIK should be a forwarding reference (there is a lot of macro stuff in the template list of this boost function), and is perfect forwarding that completion function into the guts of boost::asio, and the documentation of co_spawn is not stating any lifetime regards about the completion token.

So my fear is that in the end, only a reference to that lambda is stored in boost::asio::io_context i.e. context and when we actually execute the lambda in io_context::run in main after, the lambda has gone out of scope in setup, we have UB.


Complete MRE

#include <iostream>
#include <boost/asio.hpp>

boost::asio::awaitable<void> coroutine_with_rethrow_completion_handler() {
    std::cout << "Coroutine executes with rethrow completion handler\n";
    throw std::runtime_error("Test throw from coroutine!");
    co_return;
}

void setup(boost::asio::io_context &context) {
    const auto completion_handler = [](std::exception_ptr ptr) {
        if (ptr) {
            std::cout << "Rethrowing in completion handler" << std::endl;
            std::rethrow_exception(ptr);
        } else {
            std::cout << "Completed without error" << std::endl;
        }
    };
    boost::asio::co_spawn(context, coroutine_with_rethrow_completion_handler(), completion_handler);
}

int main() {
    boost::asio::io_context context;
    setup(context);
    std::thread t([&context]() {
        try {
            while (true) {
                context.run();
                return;
            }
        } catch (std::exception &e) {
            std::cerr << "Exception in context::run(): " << e.what() << "\n";
        }
    });
    t.join();
}


Solution

  • The CompletionToken protocol decouples the completion mechanism. What is stored depends on the type of the completion token.

    The mechanism is specialized via the asio::async_result trait: https://www.boost.org/doc/libs/1_85_0/doc/html/boost_asio/reference/async_result.html#boost_asio.reference.async_result.requirements

    One of the types provided by the trait is completion_handler_type: "The concrete completion handler type for the specific signature. ".

    A priori, it is clear that a new instance of may need to be constructed. This implies that the instance lifetime is governed by the async operation, instead of the calling code.

    For regular callable as the completion token, the completion_handler_type is a synonym of the user's handler argument type decayed:

    using H = decltype(completion_handler);
    using Protocol = boost::asio::async_result<boost::asio::decay_t<H>, void(std::exception_ptr)>;
    Protocol::completion_handler_type stored{std::move(completion_handler)};
    

    Which is implemented // HERE:

    template <typename CompletionToken,
        BOOST_ASIO_COMPLETION_SIGNATURE... Signatures>
    class completion_handler_async_result
    {
    public:
      typedef CompletionToken completion_handler_type; // HERE
      typedef void return_type;
    
      explicit completion_handler_async_result(completion_handler_type&)
      {
      }
    

    The decay guarantees that any reference/const/volatile qualifications are dropped. Ergo: the handler is copied or moved.

    Conclusion

    As you expected the handler is not stored by reference. The reason it is taken by universal reference is to accommodate

    _In fact, token adaptors can already bind references into the token/handler. bind_executor/bind_cancellation_slot store copies, but asio::redirect_error stores a reference to an error_code object. Even so, the handler itself will be copied/moved.

    Out Of The Box

    If you share a link to the code that exhibits UB I might take a look for potential issues.