Question

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();
}

 3  105  3
1 Jan 1970

Solution

 2

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

  • move-only callables
  • generic completion tokens that might behave differently

_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.

2024-07-05
sehe

Solution

 1

It's an interesting question, if for captureless closure there is no UB related to end of life. All because closure have to behave simlarly to reference code offered by standard document (§ 8.4.5.1 Closure types):

struct Closure {
  template<class T> auto operator()(T t) const { ... }
  template<class T> static auto lambda_call_operator_invoker(T a) {
     // forwards execution to operator()(a) and therefore has
     // the same return type deduced
     ...
  }

  template<class T> using fptr_t =
  decltype(lambda_call_operator_invoker(declval<T>())) (*)(T);
  
  template<class T> operator fptr_t<T>() const { 
     return &lambda_call_operator_invoker; 
  }
};

If we have an instance in can be cast to a function pointer, after which the object itself is not need anymore - the cast returns address of a static member function. And closure would be detected by SFINAE as something convertible to a function pointer in co_spawn, after which only pointer would be stored. If it's how co_spawn implemented, so we have to rely on quality of implementation.

2024-07-05
Swift - Friday Pie