2

The connection attempt below creates (on my network configuration) a delay of 2 minutes because the target does not exist and the address is on a different subnet to my machine. So I added timeout logic to limit the attempt to 5 seconds:

#define BOOST_ASIO_HAS_CO_AWAIT
#define BOOST_ASIO_HAS_STD_COROUTINE

#include <iostream>
#include <chrono>
#include <thread>

#include <boost/asio/awaitable.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/connect.hpp>
#include <boost/asio/detached.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/address_v4.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/executor_work_guard.hpp>
#include <boost/asio/redirect_error.hpp>

namespace this_coro = boost::asio::this_coro;
using namespace std::chrono_literals;

boost::asio::awaitable<void> fail_to_connect()
{
    auto executor = co_await this_coro::executor;
    auto socket = boost::asio::ip::tcp::socket{executor};
    auto ep = boost::asio::ip::tcp::endpoint{
                    boost::asio::ip::make_address_v4("192.168.1.52"),
                    80};

    auto timer = boost::asio::steady_timer{executor};
    timer.expires_after(5s);
    boost::asio::co_spawn(
        executor,
        [&]() -> boost::asio::awaitable<void> {
            auto ec = boost::system::error_code{};
            co_await timer.async_wait(boost::asio::redirect_error(boost::asio::use_awaitable, ec));
            std::cout << "Thread ID: " << std::this_thread::get_id()
                      << " Timer: " << ec.message() << std::endl;
            if (!ec) {
                socket.close();
            }
        },
        boost::asio::detached
    );

    std::cout << "Thread ID: " << std::this_thread::get_id()
              << " Starting connection" << std::endl;
    co_await boost::asio::async_connect(socket,
                                        std::array{std::move(ep)},
                                        boost::asio::use_awaitable);
    timer.cancel();
}

int main()
{
    auto ctx = boost::asio::io_context{};
    auto guard = boost::asio::make_work_guard(ctx.get_executor());

    auto exception_handler = [&](auto e_ptr) {
        if (e_ptr) {
            std::rethrow_exception(e_ptr);
        }
    };

    boost::asio::co_spawn(ctx, fail_to_connect, std::move(exception_handler));
    ctx.run();
}

This works as expected. As you can see from the thread ID's, using the same execution context between the two coroutines means that I'm not accessing the socket concurrently too.

14:58:41: Starting /home/cmannett85/workspace/build-scratch-Desktop-Debug/scratch ...
Thread ID: 140171855615808 Starting connection
Thread ID: 140171855615808 Timer: Success
terminate called after throwing an instance of 'boost::system::system_error'
  what():  Operation canceled
14:58:46: The program has unexpectedly finished.

However this feels clunky, especially compared to the non-coroutine callback-style. Is there a better approach for timeouts using coroutines? I'm struggling to find examples.

cmannett85
  • 21,725
  • 8
  • 76
  • 119
  • You could maybe use the callback style of async_await to do the cancellation, that would remove at least a few lines of boilerplate. What seems to be needed in Asio is something like [`folly::coro::collectAll()`](https://github.com/facebook/folly/blob/main/folly/experimental/coro/README.md#concurrently-awaiting-multiple-tasks) – MHebes Feb 22 '22 at 23:41
  • Found this fantastic write up on how to implement a completion token which adds a timeout to any operation: https://cppalliance.org/richard/2021/10/10/RichardsOctoberUpdate.html I haven’t tested it myself but seems to be exactly what you’re looking for – MHebes Apr 30 '22 at 21:48

1 Answers1

1

One suggestion is to lift the boilerplate into a class.

Basically just a wrapper around a timer, that can set and cancel a callback with an easier interface:

#include "asio.hpp"

class timeout {
  asio::high_resolution_timer timer_;
  using duration = asio::high_resolution_timer::duration;

 public:
  template <typename Executor>
  timeout(Executor&& executor) : timer_(executor){};

  template <typename Executor, typename Func>
  timeout(Executor&& executor, const duration& timeout, Func&& on_timeout)
      : timer_(executor) {
    set(timeout, on_timeout);
  }

  template <typename Func>
  void set(const duration& timeout, Func&& on_timeout) {
    timer_.expires_after(timeout);  // cancels outstanding timeouts
    timer_.async_wait([&](std::error_code ec) {
      if (ec) return;
      std::cout << "cancelling\n";
      on_timeout();
    });
  }

  void cancel() { timer_.cancel(); }

  ~timeout() { timer_.cancel(); }
};

This can be used like this:

tmo.set(1s, [&]() { socket.close(); });
std::size_t n = co_await asio::async_read_until(socket, incoming, '\n',
                                                asio::use_awaitable);

asio::const_buffer response(asio::buffers_begin(incoming.data()),
                            asio::buffers_begin(incoming.data()) + n);

tmo.set(1s, [&]() { socket.close(); });
co_await asio::async_write(socket, response, asio::use_awaitable);

incoming.consume(n);

It can be generally used with any object that supports true cancellation, which is somewhat sparsely supported. For example, the only true way to cancel an ongoing read or write on a socket is to call close().

Cancelling an async_resolve for example, is not possible, because cancel() only cancels a pending operation, not an ongoing one. In other words, this doesn't work as expected:

// does not actually cancel anything :(
tmo.set(1s, [&]() { resolver.cancel(); });
resolver.async_resolve(...);

Here's a full example of an echo client that stops if any of these takes more than 1 second:

  • async_connect
  • async_read_until
  • async_write

As noted, async_resolve cannot be cancelled, and so writing a timeout for it doesn't make a lot of sense. Perhaps posting an exception to the coroutine's execution context would work instead?

#include <chrono>
#include <iostream>
#include <string>
#include <string_view>

// clang-format off
#ifdef _WIN32
#include "sdkddkver.h"
#endif
#include "asio.hpp"
// clang-format on

using asio::ip::tcp;

class timeout {
  asio::high_resolution_timer timer_;
  using duration = asio::high_resolution_timer::duration;

 public:
  template <typename Executor>
  timeout(Executor&& executor) : timer_(executor){};

  template <typename Executor, typename Func>
  timeout(Executor&& executor, const duration& timeout, Func&& on_timeout)
      : timer_(executor) {
    set(timeout, on_timeout);
  }

  template <typename Func>
  void set(const duration& timeout, Func&& on_timeout) {
    timer_.expires_after(timeout);  // cancels outstanding timeouts
    timer_.async_wait([&](std::error_code ec) {
      if (ec) return;
      std::cout << "cancelling\n";
      on_timeout();
    });
  }

  void cancel() { timer_.cancel(); }

  ~timeout() { timer_.cancel(); }
};

asio::awaitable<void> echo_client(tcp::socket& socket, std::string_view host,
                                  std::string_view service) {
  try {
    using namespace std::chrono_literals;
    auto exec = socket.get_executor();

    tcp::resolver resolver(exec);
    timeout tmo(exec);

    std::cout << "resolving...\n";
    tmo.set(1s, [&]() { resolver.cancel(); });
    auto endpoints =
        co_await resolver.async_resolve(host, service, asio::use_awaitable);

    std::cout << "connecting...\n";
    tmo.set(1s, [&]() { socket.close(); });
    co_await asio::async_connect(socket, endpoints, asio::use_awaitable);

    asio::streambuf incoming;

    for (;;) {
      std::cout << "reading... ";

      tmo.set(1s, [&]() { socket.close(); });
      std::size_t n = co_await asio::async_read_until(socket, incoming, '\n',
                                                      asio::use_awaitable);

      std::string response(asio::buffers_begin(incoming.data()),
                           asio::buffers_begin(incoming.data()) + n);

      std::cout << response;

      tmo.set(1s, [&]() { socket.close(); });
      co_await asio::async_write(socket, asio::buffer(response),
                                 asio::use_awaitable);

      incoming.consume(n);
    }
  } catch (std::exception& e) {
    std::cerr << e.what() << "\n";
  }
}

int main(int argc, char** argv) {
  if (argc < 3) {
    std::cout << "usage: " << argv[0] << " [host] [port]\n";
    return 1;
  }

  asio::io_context io_context;
  tcp::socket socket(io_context);

  asio::co_spawn(io_context, echo_client(socket, argv[1], argv[2]),
                 asio::detached);

  io_context.run();

  return 0;
}
MHebes
  • 2,290
  • 1
  • 16
  • 29
  • It might make sense to change the constructor of `timeout` to take a reference to a template Timer type and use that instead of constructing one. – MHebes Mar 09 '22 at 17:51