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(orresults<T>when a payload rides along —seek(), themake_*factories), and asynchronous operations complete withresults<…>. Every error-returning function is[[nodiscard]]. -
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(). Cancellation reports through each canceled completion.
Using a closed object is deterministic on every I/O 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 I/O 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.
The same row has a Windows caveat for release(). On
win_stream_handle, win_random_access_handle,
stream_file, and random_access_file, it can throw while
open. It throws errc::device_or_resource_busy if an operation is
still in flight. Call it again once the canceled operations complete.
It throws errc::operation_not_supported if Windows cannot detach the
handle from the completion port.
Startup construction failing by exception is the intended shape.
|
Code snippets assume:
|
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 |
|---|---|
|
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 I/O backend:
| Error | Meaning |
|---|---|
|
Operation on a closed object, or adopting an invalid descriptor.
On Windows, adopting an invalid socket reports |
|
Unparseable input to the |
|
|
|
|
|
Adopting a foreign descriptor of the wrong type or family |
|
Adopting a native descriptor or handle of the wrong kind, such as a directory or a synchronous-mode Windows handle |
|
A file offset beyond what the platform can represent, or a
truncated hostname from |
|
A |
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
-
Sockets — Socket operations
-
Composed Operations — read() and write()