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 .

  • 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(ctx, ep, 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(ctx, sig, sigs…​)

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(opts, …​) throwing std::invalid_argument

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 (there is no code-returning way to construct it), any constructor can throw std::bad_alloc, and 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 on the last table row: set_option()/get_option() also throw when the platform rejects the option itself (an unsupported option on that protocol or OS), so an open check 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<resolver_results> 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, while cancel(), an in-flight stop-token cancel, and a syscall reporting ECANCELED tend to produce capy::error::canceled. 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