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?
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.
#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();
}
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.
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, butasio::redirect_error
stores a reference to anerror_code
object. Even so, the handler itself will be copied/moved.
If you share a link to the code that exhibits UB I might take a look for potential issues.