Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions doc/error-handling-rulebook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# Corosio Error-Handling Rulebook

How corosio reports, classifies, tests, and documents errors.

## 1. One Channel

Every fallible operation reports through exactly one channel, chosen
by classifying its failures:

- **Expected runtime conditions** — anything the environment or a
peer can cause at runtime: missing file, address in use, descriptor
exhaustion, disk full, peer disconnect race, unparseable input,
foreign descriptors unfit for adoption — report through the
**return value**.
- **Misuse** — violations of a documented precondition the caller
could have checked: calling on a closed object, invoking twice,
breaking a stated invariant — **throw**.
- **Not actionable** — nothing a caller can act on remains — report
nothing: **`void noexcept`**. The effect is guaranteed: `close()`
releases the descriptor even when the OS objects, `cancel()`'s
request always lands. Everything actionable was available through
an earlier return-channel operation — durability through
`sync_data`/`sync_all`, orderly teardown through `shutdown` — and
cancellation's outcome is inherently asynchronous, surfacing
through each canceled completion. Reporting a `close()` error
would be an attractive nuisance: the retry it invites is a
double-close hazard, because POSIX leaves descriptor release under
`EINTR` unspecified.
- Never both channels for one operation. Never `std::error_code&`
out-params. Never a throwing/non-throwing overload pair.

## 2. Return Shapes

| Situation | Shape |
|---|---|
| Sync, no payload | `[[nodiscard]] std::error_code f(...) noexcept` |
| Sync, with payload | `[[nodiscard]] capy::io_result<T> f(...) noexcept` |
| Async | the awaitable completes with `io_result<...>` — initiators never throw |

`io_result<T>` is `std::tuple<std::error_code, T...>`, so results
destructure; the sync and async surfaces share one idiom:

```cpp
if (auto ec = sock.open()) // no payload
co_return;
auto [ec, pos] = f.seek(0, file_base::seek_end); // payload
auto [ec2, ep] = make_endpoint("10.0.0.1:80"); // factory
```

The payload always rides in the return — the `make_*` factories
return `io_result<T>` with a default-constructed payload on failure.
Every awaitable-returning initiator is `[[nodiscard]]`: a discarded
awaitable is a silent no-op that also drops any pre-set completion.

Constructors have no return channel to choose, so rule 1 admits only
three constructor failures: misuse, guarded by a public pre-check
(`local_endpoint`'s `max_path_length`, `io_context`'s
`thread_pool_size`); the wrapped codes of a code-returning path the
constructor abbreviates (`tcp_acceptor(ctx, ep)` over
`open`+`bind`+`listen`, `endpoint(str)` over `make_endpoint`, thrown
as `std::system_error` carrying the code the piecewise spelling
returns); and root setup for which no code-returning spelling can
exist (`io_context` backend creation, allocation).

## 3. The Classification Test

Ask: **can the caller reliably prevent the failure by checking state
they own, race-free, before the call?**

- **No** → normal error. File existence (TOCTOU), a port's
availability, a peer's connection state, or whether a string parses
cannot be pre-checked.
- **Yes** → precondition, misuse, throw. The canonical example:
`local_endpoint(path)` throws on an over-long path because the
limit is the public `max_path_length` constant — one integer
comparison, no grammar, no race. The pre-check *is* the
non-throwing API.

Corollary: an operation with no realistic failure on a valid open
object (`size()`, `release()`, `available()`) stays on the throwing
channel — an `io_result` return would tax every correct call site to
carry a bit that is always "you have a bug".

## 4. The Closed-Object Condition

One condition, one code — `errc::bad_file_descriptor` — delivered
through whichever channel the operation already uses:

- Error-returning sync methods (`bind`, `listen`, `shutdown`,
`assign`, file `resize`/`sync_*`/`seek`) **return** it.
- Throwing-channel methods (`size`, `release`, `available`,
`set_option`, `get_option`) **throw**
`std::system_error(errc::bad_file_descriptor)`. Not `logic_error`:
closed-ness is legitimately observable in async teardown (another
coroutine closes to cancel), the platform spells it as a system
condition (`EBADF`), and one exception type keeps the surface
coherent.
- Async initiators **pre-fail the awaitable**: set `aw.ec_` and
return it; `await_ready` treats a pre-set `ec_` as
ready-with-result, so the operation completes immediately without
dispatching. `connect()`'s auto-open reports an open failure the
same way. The backends enforce the same contract at their operation
entries — a closed object reaching a backend read/write/wait
completes with `bad_file_descriptor` before touching the kernel —
so the code is deterministic on every path, including the
devirtualized native facades.

Genuine logic errors unrelated to object state stay `logic_error`:
service not installed, launcher invoked twice, zero-sized thread pool.

## 5. Composite Operations

An operation that performs several fallible steps reports the first
failure through its own single channel — sub-steps never add a
second channel:

- `connect()` opens the socket automatically when needed; an open
failure surfaces through the connect completion, so callers need no
explicit `open()` before `connect`.
- The convenience constructors wrap `open`+`bind`+`listen` and throw
the code the failing step returned.
- The free `corosio::connect` walks a candidate range and reports
through one completion — `no_such_device_or_address` when no
candidate is viable.
- `tls_context` setters record configuration that is applied when a
handshake first configures the engine; application failures surface
through that handshake's completion.

## 6. Attributes and Spelling

- Every error-returning function is `[[nodiscard]]` and, where the
body permits, `noexcept`. A silently dropped
`use_certificate_chain_file` failure is a security bug; the
attribute makes it a warning.
- **`[[nodiscard]]` goes BEFORE `BOOST_COROSIO_DECL`.** The macro
expands to a visibility/dllexport attribute in shared builds;
placed after it, `[[nodiscard]]` binds to the return *type* — a
hard error that static local builds never see. Gate new free
functions with a `-DBOOST_COROSIO_DYN_LINK -DBOOST_COROSIO_SOURCE`
syntax check.
- Deliberate discards use `std::ignore = expr;`, never `(void)`
casts. Reserve them for calls whose outcome is asserted downstream
(hostile-input tests, best-effort bench teardown).
- Unused names — parameters kept for signature clarity, structured
bindings partially consumed, `#if`-gated uses — are declared
`[[maybe_unused]]`, never silenced with a void cast.

## 7. Code Values and Portability

- **The user vocabulary is `std::errc`** — corosio defines no error
namespace of its own.
- Codes corosio generates itself are deterministic contracts:
`invalid_argument` (parsers, signal_set, negative seek),
`bad_file_descriptor` (closed objects, descriptor validation),
`wrong_protocol_type` / `address_family_not_supported` (adoption
rejection), `value_too_large` (off_t guard, truncated hostname),
`filename_too_long`,
`already_connected` (`connect_pair` on an open socket),
`no_such_device_or_address` (`corosio::connect` with no viable
candidate).
- Portable comparison comes from **normalizing at the boundary**: the
Windows `make_err` maps the contracted WSA/Win32 codes to
generic-category `errc` values (`WSAEOPNOTSUPP`, `WSAENOTSOCK`,
`WSAEAFNOSUPPORT`, `WSAEPROTOTYPE`, `WSAEADDRINUSE`,
`WSAEADDRNOTAVAIL`, `ERROR_NEGATIVE_SEEK`; `iocp_make_err` adds the
async condition set and `WSAEBADF`/`ERROR_INVALID_HANDLE`). On
POSIX, raw errno satisfies `errc` comparison with one exception:
`make_err` normalizes `ENOTSUP` so platforms where it differs from
`EOPNOTSUPP` still compare equal to
`errc::operation_not_supported`.
- Kernel codes outside the contracted set go out as raw
`system_category` values — normalization is for contracts, not a
laundering of every error. Some rejections stay platform- or even
runner-dependent (WinSock's handling of IPv6-level options on
AF_INET sockets; Darwin's `getsockopt(TCP_NODELAY)` on AF_UNIX);
Windows reports `not_a_socket` where POSIX validation reports
`EBADF` for garbage (non-sentinel) handles.
- Conditions the standard cannot spell come from capy:
`capy::cond::eof`, `capy::cond::canceled` (a stop token, not
`errc::operation_canceled`), `capy::cond::timeout` (our deadline,
not a kernel `ETIMEDOUT`).

## 8. Testing

- **Lock deterministic codes by equality**: everything corosio
generates, plus kernel codes with reliable mappings on the platform
under test. `BOOST_TEST(ec == std::errc::bad_file_descriptor)`.
- **Platform-variant codes assert only that a genuine error arrived**
(option failures, fsync-on-pipe, resize-read-only), with a comment
naming the variance.
- **Platform-variant *premises* get gated, not weakened**: if the
operation legitimately succeeds somewhere (IOCP dual-stack
acceptors accepting `v6_only`, Darwin's permissive `getsockopt`),
gate the whole assertion with `#if` and a comment — don't leave an
assertion that intermittently passes.
- Contracted codes compare by `errc` equality on every platform —
raw-value pinning is reserved for kernel codes outside the contract
list.
- Closed-object behavior is tested on **every channel**: returned
codes, thrown `system_error`s, and awaited completions
(`co_await` + `ioc.run()` + a `done` flag so the test cannot pass
vacuously).
- Error paths count as coverage targets: exercise the reachable ones
(wrong-direction I/O, huge offsets, bind conflicts, pipe sync);
document the rest as fault-injection-only. Run the gcc coverage
build — it doubles as a second-compiler gate.

## 9. Documentation

- Javadocs state the channel: `@return The error code, empty on
success.` for returns; `@throws std::system_error
\`errc::bad_file_descriptor\` if ...` for throws. Never a stale
`@throws` on a `noexcept` function.
- Examples and doc snippets model checked usage — `if (auto ec = ...)`
early-return — and never discard a `[[nodiscard]]` result, even
where a pragma would let it compile. Fragments before `connect`
simply omit `open()`.
- Example `if` bodies are real statements (`co_return;`), never a
comment alone — a comment-only body silently swallows the next
statement.
2 changes: 1 addition & 1 deletion doc/modules/ROOT/pages/3.tutorials/3b.http-client.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Key points:
include::example$client/http_client.cpp[tag=run_client]
----

The socket must be opened before connecting. We pass the socket as an
`connect()` opens the socket automatically. We pass the socket as an
`io_stream&` to `do_request`, so the same function works with any plain
socket. TLS streams have a different type and need their own overload, as
shown below.
Expand Down
7 changes: 7 additions & 0 deletions doc/modules/ROOT/pages/4.guide/4a.tcp-networking.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,13 @@ When a socket closes, its address may remain in TIME_WAIT state for minutes.
This is essential for servers that restart—without it, the restart fails with
"address already in use" until TIME_WAIT expires.

The flag means something different on Windows: there it grants a socket the
right to bind over an address another socket actively holds, silently
splitting incoming connections between the two. Windows servers that want
the POSIX contract set `SO_EXCLUSIVEADDRUSE` instead — the `tcp_acceptor`
convenience constructor does this automatically, so a second listener fails
with `errc::address_in_use` on every platform.

=== SO_REUSEPORT

`SO_REUSEPORT` (available on some operating systems) allows multiple sockets
Expand Down
6 changes: 4 additions & 2 deletions doc/modules/ROOT/pages/4.guide/4d.sockets.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ Creates the underlying TCP socket:
include::example$snippets/4d_sockets.cpp[tag=open,indent=0]
----

This allocates a socket handle and registers it with the I/O backend.
Throws `std::system_error` on failure.
This allocates a socket handle and registers it with the I/O backend,
returning a `std::error_code`; failures such as descriptor exhaustion
are reported through the code. Explicit `open()` is only needed to set
socket options before connecting — `connect()` opens automatically.

=== close()

Expand Down
27 changes: 17 additions & 10 deletions doc/modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,15 @@ which opens, sets `SO_REUSEADDR`, binds, and listens in a single step:
include::example$snippets/4e_tcp_acceptor.cpp[tag=convenience_ctor,indent=0]
----

This throws `std::system_error` if binding or listening fails. Unlike the
standalone `open()`, the convenience constructor enables `SO_REUSEADDR` before
binding, so the listening port can be reused immediately after a restart.
This throws `std::system_error` on open, `set_option()`, bind, or listen
failure — exactly the codes the piecewise path (open, set option, bind,
listen) reports. Unlike the standalone `open()`, the convenience
constructor configures address reuse before binding, so the listening
port can be reused immediately after a restart. The option is
platform-specific: `SO_REUSEADDR` on POSIX, and `SO_EXCLUSIVEADDRUSE` on
Windows, where `SO_REUSEADDR` would instead let other sockets bind over
the port. Either way the observable contract is the same — a second
listener on an occupied endpoint throws `errc::address_in_use`.

=== bind() and listen()

Expand Down Expand Up @@ -142,20 +148,19 @@ Common accept errors:
|===
| Error | Meaning

| `operation_canceled`
| `capy::cond::canceled`
| Cancelled via `cancel()` or stop token

| Resource errors
| System limit reached (file descriptors, memory)
|===

Calling `accept()` on an acceptor that is not listening is a precondition
violation: it throws `std::logic_error` rather than completing with an error
code.
Calling `accept()` on a closed acceptor completes with
`errc::bad_file_descriptor` — the same code every corosio operation
reports for a closed object.

=== Preconditions

* The tcp_acceptor must be listening (`is_open() == true`)
* For `accept(tcp_socket&)`, the peer socket must be associated with the same
execution context as the acceptor (the returning overload guarantees this)

Expand All @@ -170,7 +175,8 @@ Cancel pending accept operations:
include::example$snippets/4e_tcp_acceptor.cpp[tag=cancel,indent=0]
----

All outstanding `accept()` operations complete with `operation_canceled`.
All outstanding `accept()` operations complete with an error matching
`capy::cond::canceled`.

=== Stop Token Cancellation

Expand All @@ -193,7 +199,8 @@ Release tcp_acceptor resources:
include::example$snippets/4e_tcp_acceptor.cpp[tag=close,indent=0]
----

Pending accept operations complete with `operation_canceled`.
Pending accept operations complete with an error matching
`capy::cond::canceled`.

=== is_open()

Expand Down
15 changes: 8 additions & 7 deletions doc/modules/ROOT/pages/4.guide/4f.endpoints.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -118,21 +118,22 @@ include::example$snippets/4f_endpoints.cpp[tag=broadcast,indent=0]

== Parsing Addresses

Parse addresses from strings. Parsing reports failure through an
`std::error_code` out-parameter:
Create addresses from strings. The `make_*` factories destructure into
the error code and the parsed value, the same idiom every corosio
operation uses:

[source,cpp]
----
include::example$snippets/4f_endpoints.cpp[tag=parse_addresses,indent=0]
include::example$snippets/4f_endpoints.cpp[tag=make_addresses,indent=0]
----

You can also parse a full `address:port` string directly into an endpoint
using `parse_endpoint()`, or the `endpoint` constructor that accepts a
`std::string_view`:
You can also create an endpoint from a full `address:port` string
using `make_endpoint()`, or the `endpoint` constructor that accepts a
`std::string_view` and throws on failure:

[source,cpp]
----
include::example$snippets/4f_endpoints.cpp[tag=parse_endpoint,indent=0]
include::example$snippets/4f_endpoints.cpp[tag=make_endpoint,indent=0]
----

== Comparison
Expand Down
6 changes: 3 additions & 3 deletions doc/modules/ROOT/pages/4.guide/4j.resolver.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,9 @@ I/O context.

[IMPORTANT]
====
Because resolution runs on a thread pool, it requires a multi-threaded
`io_context`. When the `io_context` is constructed single-threaded (a
`concurrency_hint` of 1), `resolve()` completes with
Because resolution runs on a thread pool, it relies on scheduler
locking, which is on by default. Under the fully lockless tier
(`locking_mode::unsafe`), `resolve()` completes with
`std::errc::operation_not_supported` and never performs a lookup.
====

Expand Down
Loading
Loading