Error Handling

Corosio reports I/O errors through the results 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 results<T> when a payload rides along. That covers seek(), size(), available(), release(), get_option(), and the make_* factories, and asynchronous operations complete with results<…​>. Every error-returning function is [[nodiscard]].

  • Contract violations throw — for example an io_context options constructor given a thread_pool_size of zero, or to_v4() on an IPv6 address that is not v4-mapped. Using a closed object is not one of them: it returns like any other runtime condition.

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

Using a closed object is deterministic on every I/O backend and every channel: the operation returns 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 constructed the same way

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

signal_set constructed with a context and one or more signals

signal_set(ctx), then add() per signal

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

ensure opts.thread_pool_size >= 1, opts.max_events_per_poll is in [ 1, INT_MAX ] (lower where epoll is available), and opts.inline_budget_max does not exceed INT_MAX

What cannot be spelled exception-free: root construction and the run loop. io_context itself throws if I/O backend setup fails, because there is no code-returning way to construct it. Any constructor can throw std::bad_alloc. run(), poll(), and their siblings throw std::system_error if waiting on the OS demultiplexer fails other than by interruption or another transient condition. No per-operation channel can carry that, and the context cannot dispatch work after it. On Windows stop() throws if it cannot queue the wakeup. run() and poll() also throw on Windows when a wakeup they need to stop the context cannot be queued. set_option(), get_option(), release(), size(), and available() never throw. set_option() and get_option() report a platform that rejects the option, such as an unsupported option on that protocol or OS, through the returned code. A closed object reports errc::bad_file_descriptor through the returned code. On win_stream_handle, win_random_access_handle, stream_file, and random_access_file, release() can also fail while open. It reports errc::device_or_resource_busy if an operation is still in flight on Windows. Where file I/O runs on a thread pool, it reports the same while a worker thread is performing one. Call it again once the canceled operations complete. It reports errc::operation_not_supported if Windows cannot detach the handle from the completion port. A failed release() leaves the object owning the handle and returns the invalid sentinel, never descriptor 0. 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 results Type

results<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)
results<> r1; // Contains: ec

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

// Typed result (resolve)
results<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

results 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 I/O backend:

Error Meaning

bad_file_descriptor

Operation on a closed object, or adopting an invalid descriptor. On Windows, adopting an invalid socket reports not_a_socket instead.

invalid_argument

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

corosio::error::already_open

assign() or connect_pair() on an already-open object

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

operation_not_supported

Adopting a native descriptor or handle of the wrong kind, such as a directory or a synchronous-mode Windows handle

value_too_large

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

filename_too_long

A path over max_path_length passed to make_local_endpoint()

device_or_resource_busy

release() with an operation in flight on a Windows file or handle, or while a thread-pool worker is using the file

Cancellation

Every corosio cancellation completes with capy::error::canceled, whatever triggered it.

Always test cancellation portably with the capy::cond::canceled condition:

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