|
| 1 | +# ADR-0025: `TcpSocket` / `TcpServer` non-blocking sockets, poll-based readiness, compiled tier |
| 2 | + |
| 3 | +- **Status:** Accepted |
| 4 | +- **Date:** 2026-07-05 |
| 5 | +- **Deciders:** Daniel Polo (maintainer), agent session |
| 6 | +- **Related:** spec §2 (component #17), §3 (RAII / compiled tier / zero-dependency), §5 (error model), |
| 7 | + roadmap 9.3 (closes Milestone 9), |
| 8 | + [ADR-0004](0004-adopt-hybrid-header-only-plus-static-build-model.md) (the compiled tier), |
| 9 | + [ADR-0023](0023-file-stream-buffered-descriptor-single-direction.md) (the handle-hiding idiom |
| 10 | + and the value-or-error boundary for OS I/O), |
| 11 | + [ADR-0020](0020-logger-async-pump-with-strategy-sinks.md) (the UDP sink's Winsock precedent) |
| 12 | + |
| 13 | +## Context |
| 14 | + |
| 15 | +The spec requires `TcpSocket` / `TcpServer` (component #17): "non-blocking asynchronous classes |
| 16 | +based on select/poll or epoll for network communication". It closes Milestone 9, the I/O tier. |
| 17 | +Several decisions shape it. **What is wrapped** — the raw OS socket (`int` fd / Winsock |
| 18 | +`SOCKET`), as for `FileStream`. **Where it lives** — the compiled STATIC tier (the socket headers |
| 19 | +must stay out of consumers). **What readiness primitive** — the spec names "select/poll or |
| 20 | +epoll"; epoll/kqueue/IOCP are per-OS. **How errors and the non-blocking third outcome surface** — |
| 21 | +a refused connection or a socket that "would block" are ordinary runtime outcomes, not bugs. |
| 22 | + |
| 23 | +Forces: spec §3's RAII pillar and the compiled-tier contract (ADR-0004) that keeps |
| 24 | +`<winsock2.h>` / `<sys/socket.h>` out of consumers; the library convention that user-driven |
| 25 | +failure is *reported* and only programmer misuse throws (ADR-0023); the zero-dependency rule |
| 26 | +(no libuv/asio); and portability across Linux, macOS, and Windows on the CI matrix. |
| 27 | + |
| 28 | +## Decision |
| 29 | + |
| 30 | +**`TcpSocket` and `TcpServer` are move-only RAII wrappers over a raw OS socket, living in the |
| 31 | +compiled STATIC tier, non-blocking by default, with a portable one-descriptor `poll` readiness |
| 32 | +model and a value-or-error boundary.** |
| 33 | + |
| 34 | +- **Raw socket, type-erased in the header.** The descriptor is a `std::intptr_t` (sentinel `-1`, |
| 35 | + matching POSIX `-1` and Windows `INVALID_SOCKET`); the `.cpp` casts it back to an `int` fd or a |
| 36 | + `SOCKET`. The header names no OS type — the same handle-hiding as `FileStream`/`StackTrace` |
| 37 | + (ADR-0023/0019) — so the socket headers never reach consumers. Placement in the STATIC tier |
| 38 | + needs no new link library on POSIX; Windows links `ws2_32`, already pulled in for the logger's |
| 39 | + UDP sink (ADR-0020). |
| 40 | +- **Portable poll readiness (`::poll` / `::WSAPoll`).** A single-descriptor poll backs |
| 41 | + `wait_readable` / `wait_writable` (and `TcpServer::wait_readable` for a pending connection), |
| 42 | + returning `WaitResult` (`ready` / `timed_out` / `error`). This is the portable "select/poll" |
| 43 | + core the spec allows; epoll/kqueue/IOCP are per-OS scaling optimizations deferred to a future |
| 44 | + item (the API — readiness + explicit waits — does not change if they are added later). |
| 45 | +- **`select` for connect completion.** Non-blocking `connect` returns immediately (`is_open()` |
| 46 | + true, possibly still completing); `wait_connected` resolves it. It uses `select` rather than |
| 47 | + `poll` here on purpose: Windows' `WSAPoll` famously does **not** report a failed connection, |
| 48 | + whereas `select`'s `exceptfds` does (and a resolved connect shows up in `writefds` on POSIX |
| 49 | + regardless). The caller-visible `SO_ERROR` then separates success from failure. |
| 50 | +- **Value-or-error boundary with an explicit non-blocking outcome.** `connect`/`listen` failure |
| 51 | + yields a closed object with `error()` set (native `errno`/`WSAGetLastError()`); `send`/`recv` |
| 52 | + return an `IoResult` whose `IoStatus` separates `ok` / `closed` (orderly peer shutdown) / |
| 53 | + `would_block` / `error`; `accept` returns `std::nullopt` for both "nothing pending" |
| 54 | + (`error() == 0`) and a real error (`error() != 0`). Nothing throws — mirroring |
| 55 | + `FileStream`/`CliParser`/`JsonParser`. Using a closed socket fails gracefully. |
| 56 | +- **RAII, move-only.** The destructor closes the descriptor; move transfers it and leaves the |
| 57 | + source closed; copying is deleted. `TcpServer::accept` explicitly sets the accepted socket's |
| 58 | + blocking mode (POSIX does not inherit it) so it matches the listener. |
| 59 | +- **Windows Winsock is process-lifetime.** `WSAStartup` is called once via `std::call_once` and |
| 60 | + never paired with `WSACleanup`: its state is released at process exit, and this avoids a |
| 61 | + refcount race across socket moves. The whole init is Windows-only; the sanitizer jobs run the |
| 62 | + POSIX no-op path. |
| 63 | +- **Server is IPv4, `SO_REUSEADDR`, ephemeral-aware.** `TcpServer::listen` binds `INADDR_ANY` on |
| 64 | + the given port (0 ⇒ an OS-assigned port, read back via `local_port()`), with `SO_REUSEADDR` so a |
| 65 | + restart need not wait out `TIME_WAIT`. The client `connect` resolves names with `getaddrinfo` |
| 66 | + (IPv4/IPv6). Dual-stack server binding is a straightforward future extension. |
| 67 | + |
| 68 | +No design pattern is adopted: this is the RAII idiom over an OS handle plus a thin readiness |
| 69 | +helper. **Reactor is explicitly rejected** (see the catalogue): there is no event demultiplexer |
| 70 | +dispatching to registered handlers — `wait_*` are synchronous one-shot readiness checks a caller |
| 71 | +drives, not an event loop. |
| 72 | + |
| 73 | +## Alternatives Considered |
| 74 | + |
| 75 | +- **epoll / kqueue / IOCP now** — rejected (deferred): epoll is Linux-only, kqueue BSD/macOS, |
| 76 | + IOCP a different (completion, not readiness) model on Windows. A portable `poll` core covers the |
| 77 | + spec's "select/poll or epoll" and the common case; the per-OS backends are a scaling |
| 78 | + optimization that can be added behind the same readiness API without a break. |
| 79 | +- **`poll`/`WSAPoll` for connect completion too** — rejected: `WSAPoll` does not surface a failed |
| 80 | + connect on Windows (a documented limitation), so a refused connection would hang until timeout. |
| 81 | + `select` with `exceptfds` reports it on both platforms. |
| 82 | +- **Blocking, thread-per-connection** — rejected as the default: the spec asks for non-blocking |
| 83 | + async classes. Blocking mode is still available (`non_blocking = false` / `set_non_blocking`). |
| 84 | +- **Throw on network error** — rejected: a refused connection, a reset, or a would-block are |
| 85 | + expected runtime conditions to branch on, not to unwind. Consistent with the library boundary. |
| 86 | +- **`std::optional<std::size_t>` for `recv` (as `FileStream::read`)** — rejected: TCP |
| 87 | + non-blocking I/O has a genuine four-way outcome (bytes / orderly close / would-block / error) |
| 88 | + that an optional cannot express without overloading `error()`; `IoResult` states it plainly. |
| 89 | +- **A third-party async library (asio/libuv)** — rejected: zero-dependency contract (spec §3). |
| 90 | + |
| 91 | +## Consequences |
| 92 | + |
| 93 | +- Consumers link the STATIC tier (Windows also gets `ws2_32` transitively); the socket headers |
| 94 | + stay out of their translation units. Header-only consumers see declarations only — calls are a |
| 95 | + link error (same contract as `library_version()`). |
| 96 | +- The readiness model is explicit: a caller polls with `wait_*` then does one non-blocking |
| 97 | + `send`/`recv`/`accept`. Building an event loop on top is the caller's choice; the component does |
| 98 | + not impose one (and is not yet a Reactor). |
| 99 | +- `WSAPoll`'s connect blindness is contained in `wait_connected` via `select`; the rest of the |
| 100 | + surface uses `poll`. |
| 101 | +- IPv6 server binding, dual-stack, and an epoll/kqueue/IOCP fast path are natural future roadmap |
| 102 | + items; none change the public API. |
| 103 | +- Windows never calls `WSACleanup`; acceptable because the state is process-scoped and released |
| 104 | + at exit, and the sanitizer/leak jobs exercise the POSIX path. |
| 105 | +- Patterns catalogue: a *Rejected* row (Reactor) is added; no adoption. |
| 106 | + |
| 107 | +## References |
| 108 | + |
| 109 | +- Spec §2 component #17, §3 (RAII, compiled tier, zero-dependency), §5 error model. |
| 110 | +- ADR-0004 (hybrid tier), ADR-0023 (handle-hiding + value-or-error for OS I/O), ADR-0020 (Winsock |
| 111 | + precedent). |
| 112 | +- POSIX `socket`/`connect`/`bind`/`listen`/`accept`/`poll`/`select`/`getsockopt` (POSIX.1-2008); |
| 113 | + Win32 Winsock `WSAPoll` connect-notification limitation, `select` `exceptfds` semantics. |
0 commit comments