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(orio_result<T>when a payload rides along —seek(), themake_*factories), and asynchronous operations complete withio_result<…>. Every error-returning function is. -
Misuse of a documented precondition throws
std::system_error— for examplerelease(),set_option(), orget_option()on a closed object. -
Operations whose outcome nobody can act on report nothing.
close()andcancel()arevoid noexcept; durability reporting belongs tosync_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 |
|---|---|
|
default-construct, then |
|
|
|
|
|
|
|
check |
|
ensure |
|
check |
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:
|
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 |
|---|---|
|
End of stream reached |
|
No server at endpoint |
|
Peer reset connection |
|
Write to closed connection |
|
Operation exceeded its deadline |
|
No route to host |
Deterministic Corosio Codes
Codes corosio generates itself are contracts, portable across every platform and backend:
| Error | Meaning |
|---|---|
|
Operation on a closed object, or adopting an invalid descriptor |
|
Unparseable input to the |
|
|
|
|
|
Adopting a foreign descriptor of the wrong type or family |
|
A file offset beyond what the platform can represent, or a
truncated hostname from |
|
A |
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
-
Sockets — Socket operations
-
Composed Operations — read() and write()