Error Handling

Corosio reports I/O errors through the io_result type, which carries an error code alongside any values produced by the operation.

One Channel

Every fallible operation reports through exactly one channel:

  • Expected runtime conditions return the error. Synchronous operations return std::error_code (or io_result<T> when a payload rides along — seek(), the make_* factories), and asynchronous operations complete with io_result<…​>. Every error-returning function is [[nodiscard]].

  • Misuse of a documented precondition throws std::system_error — for example release(), set_option(), or get_option() on a closed object.

  • Operations whose outcome nobody can act on report nothing. close() and cancel() are void noexcept; durability reporting belongs to sync_data()/sync_all(), and cancellation reports through each canceled completion.

Using a closed object is deterministic on every backend and every channel: the operation returns, throws, or completes with errc::bad_file_descriptor.

Avoiding Exceptions

Every throwing convenience is sugar over an exception-free spelling, or is guarded by a public pre-check. Code that performs the check, or uses the piecewise path, never sees the throw:

Throwing convenience Exception-free spelling

tcp_acceptor constructed with a context, endpoint, and backlog
local_stream_acceptor(ctx, ep, backlog)

default-construct, then open() + bind() + listen(). tcp_acceptor also configures address reuse via set_option() between open and bind: SO_REUSEADDR on POSIX, SO_EXCLUSIVEADDRUSE on Windows. The constructor throws exactly the codes this path reports

endpoint("host:port")

make_endpoint(s)

ipv4_address(s) / ipv6_address(s)

make_ipv4_address(s) / make_ipv6_address(s)

signal_set constructed with a context and one or more signals

signal_set(ctx), then add() per signal

local_endpoint(path)

check path.size() ⇐ local_endpoint::max_path_length first — the public constant is the entire precondition

io_context's options constructor throwing std::invalid_argument (POSIX only)

ensure opts.thread_pool_size >= 1

release(), size(), available(), set_option(), get_option() on a closed object

check is_open() first — closed-ness is the documented precondition

What cannot be spelled exception-free: root construction and the run loop. io_context itself throws if backend setup fails, because there is no code-returning way to construct it. Any constructor can throw std::bad_alloc. run()/stop() throw std::system_error if the OS demultiplexer itself fails — a process-fatal condition with no per-operation channel to carry it. One environmental caveat applies to the last table row. set_option()/get_option() also throw when the platform rejects the option itself, such as an unsupported option on that protocol or OS. An open check therefore removes the closed-object throw but not that environmental arm. Probe an option once at startup if it must not throw later. Startup construction failing by exception is the intended shape.

Code snippets assume:

#include <boost/corosio.hpp>
#include <boost/capy/cond.hpp>
#include <boost/capy/error.hpp>
#include <boost/capy/read.hpp>
#include <boost/capy/write.hpp>
#include <iostream>
#include <system_error>

namespace corosio = boost::corosio;
namespace capy    = boost::capy;
using namespace std::chrono_literals;

The io_result Type

io_result<Ts…​> is an alias for std::tuple<std::error_code, Ts…​>: the error code always comes first, followed by any values the operation produced. Because it is a std::tuple, results interoperate with the whole tuple API — structured bindings, std::tie, std::get, std::apply. There is no value() member and no conversion to bool; check for errors by testing the error code.

// Void result (connect, handshake)
io_result<> r1; // Contains: ec

// Single value (read_some, write_some)
io_result<std::size_t> r2; // Contains: ec, n (bytes transferred)

// Typed result (resolve)
io_result<std::vector<corosio::endpoint>> r3; // Contains: ec, results

Structured Bindings Pattern

Use structured bindings to extract results:

// Void result
auto [ec] = co_await sock.connect(endpoint);
if (ec)
    std::cerr << "Connect failed: " << ec.message() << "\n";

// Value result
auto [ec, n] = co_await sock.read_some(buffer);
if (ec)
    std::cerr << "Read failed: " << ec.message() << "\n";
else
    std::cout << "Read " << n << " bytes\n";

This pattern gives you full control over error handling.

Accessing Elements Directly

You can also bind the whole result and read its elements through the tuple API:

auto result = co_await sock.connect(endpoint);
if (!std::get<0>(result))
    std::cout << "Connected successfully\n";
else
    std::cerr << "Failed: " << std::get<0>(result).message() << "\n";

The error code is std::get<0>(result) and payload elements follow. For single-value results, prefer structured bindings, which name the value for you.

Throwing on Error

io_result never throws on its own. To turn an error into an exception, test ec and throw explicitly:

auto [ec, n] = co_await sock.read_some(buffer);
if (ec)
    throw std::system_error(ec);
// 'n' bytes were read

This keeps error handling explicit and avoids hidden control flow.

Structured Bindings vs. Explicit Throwing

Inspect ec When:

  • Errors are expected and need handling (EOF, timeout)

  • You want to log errors without throwing

  • Performance is critical (no exception overhead)

  • You need partial success information (bytes transferred)

auto [ec, n] = co_await sock.read_some(buf);
if (ec == capy::cond::eof)
{
    std::cout << "End of stream after " << n << " bytes\n";
    // Not an exceptional condition
}

Throw When:

  • Errors are truly exceptional

  • You want concise, linear code

  • Errors should propagate to a central handler

  • You don’t need partial success information

auto throw_on_error = [](auto result) {
    if (std::get<0>(result))
        throw std::system_error(std::get<0>(result));
    return result;
};

throw_on_error(co_await sock.connect(endpoint));
throw_on_error(co_await capy::write(sock, request));
auto [ec, n] = throw_on_error(co_await capy::read(sock, buffer));

Common Error Codes

I/O Errors

Error Meaning

capy::cond::eof

End of stream reached

connection_refused

No server at endpoint

connection_reset

Peer reset connection

broken_pipe

Write to closed connection

capy::cond::timeout

Operation exceeded its deadline

network_unreachable

No route to host

Deterministic Corosio Codes

Codes corosio generates itself are contracts, portable across every platform and backend:

Error Meaning

bad_file_descriptor

Operation on a closed object, or adopting an invalid descriptor

invalid_argument

Unparseable input to the make_* factories, invalid signal_set flags, a negative seek

already_connected

connect_pair() on an already-open socket

no_such_device_or_address

corosio::connect() with no viable candidate

wrong_protocol_type / address_family_not_supported

Adopting a foreign descriptor of the wrong type or family

value_too_large

A file offset beyond what the platform can represent, or a truncated hostname from host_name()

filename_too_long

A local_endpoint path over max_path_length

Cancellation

Cancellation does not map deterministically to a single category or value per trigger. Depending on the path, a cancelled operation may surface as capy::error::canceled (capy’s category) or as std::errc::operation_canceled (the generic category). For example, a stop token that is already requested when the operation is awaited tends to produce std::errc::operation_canceled. Three other sources tend to produce capy::error::canceled: cancel(), an in-flight stop-token cancel, and a syscall reporting ECANCELED. Do not rely on the specific category or value.

Always test cancellation portably with the capy::cond::canceled condition, which matches both:

if (ec == capy::cond::canceled)
    std::cout << "Operation was cancelled\n";

Timeout vs. Cancellation

corosio::timeout() races an operation against a deadline and keeps these two outcomes distinct. A deadline that elapses first produces capy::cond::timeout. A stop token that fires first — the coroutine’s own cancellation, independent of the deadline — produces capy::cond::canceled, exactly as an unguarded operation would:

auto [ec] = co_await corosio::timeout(sock.connect(ep), 3s);
if (ec == capy::cond::timeout)
    std::cout << "Deadline elapsed before connecting\n";
else if (ec == capy::cond::canceled)
    std::cout << "Cancelled before the deadline\n";

Never infer a timeout from capy::cond::canceled, and never infer a cancellation from capy::cond::timeout. The two conditions never overlap for a single result.

EOF Handling

End-of-stream is signaled by the capy::cond::eof condition:

auto [ec, n] = co_await capy::read(stream, buffer);
if (ec == capy::cond::eof)
{
    std::cout << "Stream ended, read " << n << " bytes total\n";
    // This is often expected, not an error
}
else if (ec)
{
    std::cerr << "Unexpected error: " << ec.message() << "\n";
}

When you throw on read errors, filter out EOF if it is expected:

auto [ec, n] = co_await capy::read(stream, response);
if (ec && ec != capy::cond::eof)
    throw std::system_error(ec);
// EOF is expected when server closes connection

Partial Success

Some operations may partially succeed before an error:

auto [ec, n] = co_await capy::write(stream, large_buffer);
if (ec)
{
    std::cerr << "Error after writing " << n << " of "
              << buffer_size(large_buffer) << " bytes\n";
    // Can potentially resume from here
}

The composed operations (read(), write()) return the total bytes transferred even when returning an error.

Error Categories

Corosio uses std::error_code, which supports categories:

if (ec.category() == std::system_category())
{
    // Operating system error
}

if (ec.category() == std::generic_category())
{
    // Portable POSIX-style error
}

Capy’s own errors (eof, canceled) don’t expose a public category accessor; match them by condition instead, as shown next.

Comparing Errors

Use error conditions for portable comparison:

// Specific error (platform-dependent)
if (ec == std::errc::connection_refused)
{
    // ...
}

// Error condition (portable)
if (ec == capy::cond::canceled)
{
    // Matches any cancellation error
}

if (ec == capy::cond::eof)
{
    // Matches end-of-stream
}

Exception Safety in Coroutines

When using exceptions in coroutines, caught exceptions don’t leak:

capy::task<void> safe_operation()
{
    try
    {
        auto [ec] = co_await sock.connect(endpoint);
        if (ec)
            throw std::system_error(ec);
    }
    catch (std::system_error const& e)
    {
        std::cerr << "Connect failed: " << e.what() << "\n";
        // Exception handled here, doesn't propagate
    }
}

Uncaught exceptions in a task are stored and rethrown when the task is awaited.

Example: Robust Connection

capy::task<void>
connect_with_retry(
    corosio::io_context& ioc, corosio::endpoint ep, int max_retries)
{
    corosio::tcp_socket sock(ioc);

    for (int attempt = 0; attempt < max_retries; ++attempt)
    {
        // connect() re-opens the socket after the close() below
        auto [ec] = co_await sock.connect(ep);

        if (!ec)
            co_return; // Success

        std::cerr << "Attempt " << (attempt + 1) << " failed: " << ec.message()
                  << "\n";

        sock.close();

        // Wait before retry (exponential backoff)
        auto [dec] =
            co_await corosio::delay(std::chrono::seconds(1 << attempt));
        if (dec == capy::cond::canceled)
            co_return; // Cancellation aborts the retry loop
    }

    throw std::runtime_error("Failed to connect after retries");
}

Next Steps