feat(tcp): socket errors as std::system_error, with the phase that failed
SocketErrorException is now a std::system_error: code() carries the errno
under std::generic_category(), or getaddrinfo's result under the new
ResolverCategory(), whose message() is gai_strerror's text; `phase` says
which step failed (resolve, open, connect, send, receive). It is still a
std::runtime_error with the message ClientTCP always threw, the code's
text after it, so existing handlers keep working.
Before, the exception carried a `kind` and an int, and a caller that had
to print the cause its own way still had to match the message prefix to
learn where the failure happened. The standard type ends both: the
category knows how to print the number, and the phase is a field. A
caller writes "[Errno {}] {}" from code().value() and code().message(),
or compares code() with std::errc::connection_refused.
Breaking: SocketErrorException's `kind` and `code` members are gone;
read code().category() and code().value().
This commit is contained in:
parent
16da0a1979
commit
e86c3b70a0
9 changed files with 107 additions and 63 deletions
|
|
@ -41,7 +41,7 @@ A route map is `std::unordered_map<std::string, std::function<HTTPResponse(const
|
|||
|
||||
### HTTP/1.1
|
||||
|
||||
**`ClientHTTP1(host, port)`** is plaintext, **`ClientHTTP1(host, port, TLSClientCredentials)`** is `https://`. `Send(request)` returns the `HTTPResponse` on the calling thread, no pool; `SendAsync(request, onSuccess, onError)` runs it as a pool task with the error as a string. The connection is persistent: the first `Send` dials, later ones reuse the socket until the peer asks to close; a pooled connection the peer closed meanwhile is redialled once and the request replayed, never once a response byte has arrived and never on a fresh connection, so a broken server is an exception, not a loop. An empty `authority` means `host:port`, with the scheme's default port left off (443 under TLS, 80 without); `scheme` is ignored (origin-form targets; the constructor decided the transport). Set `limits` (`HTTP1::MessageLimits`: start line 8 KiB, header section 64 KiB, body 512 MiB), `timeout` (30 s for the next piece of a response) and `handshakeTimeout` (15 s) before the first `Send`. `Connected()`, `Secure()`, `Protocol()` (the ALPN the last connection agreed, `"http/1.1"` under TLS), `Disconnect()`. One instance serves one caller at a time; instances are independent. Throws `HTTP1::HTTP1ProtocolError` for a response it refuses to parse, `TLSException` for a certificate it rejects, `std::runtime_error` for a socket that cannot connect, times out or closes early. There is no connect timeout: the dial is a `ClientTCP` with the default zero, so a host that never answers blocks `Send` for the kernel's two minutes before `handshakeTimeout` and `timeout` even start.
|
||||
**`ClientHTTP1(host, port)`** is plaintext, **`ClientHTTP1(host, port, TLSClientCredentials)`** is `https://`. `Send(request)` returns the `HTTPResponse` on the calling thread, no pool; `SendAsync(request, onSuccess, onError)` runs it as a pool task with the error as a string. The connection is persistent: the first `Send` dials, later ones reuse the socket until the peer asks to close; a pooled connection the peer closed meanwhile is redialled once and the request replayed, never once a response byte has arrived and never on a fresh connection, so a broken server is an exception, not a loop. An empty `authority` means `host:port`, with the scheme's default port left off (443 under TLS, 80 without); `scheme` is ignored (origin-form targets; the constructor decided the transport). Set `limits` (`HTTP1::MessageLimits`: start line 8 KiB, header section 64 KiB, body 512 MiB), `connectTimeout` (15 s, per address the host resolves to), `handshakeTimeout` (15 s) and `timeout` (30 s for the next piece of a response) before the first `Send`. `Connected()`, `Secure()`, `Protocol()` (the ALPN the last connection agreed, `"http/1.1"` under TLS), `Disconnect()`. One instance serves one caller at a time; instances are independent. Throws `HTTP1::HTTP1ProtocolError` for a response it refuses to parse, `TLSException` for a certificate it rejects, `SocketErrorException` for a socket that cannot resolve or connect or breaks under the request, `SocketTimeoutException` when the connect runs out, and `std::runtime_error` when a response stops arriving or the connection closes before one is complete.
|
||||
|
||||
**`ListenerHTTP1(port, routes[, fallback][, TLSServerCredentials])`** is a `ListenerTCP` underneath (every IPv4 address, `SO_REUSEADDR`) and throws `std::runtime_error` when the port cannot be bound, or `TLSException` when the credentials yield no usable certificate. `Listen()` runs the accept loop on the calling thread until `Stop()`, which stops accepting, closes every live connection and joins their threads. **`ListenerAsyncHTTP1`**, same constructors, runs that loop on its `thread` and exposes the `listener`; `Stop()` joins and the destructor stops. Each accepted connection gets its own thread, served one request at a time until the peer closes, sends `connection: close`, or a timeout passes: `keepAliveTimeout` 15 s between requests, `requestTimeout` 30 s between pieces of one request (each read, not the whole request), `handshakeTimeout` 15 s for TLS; `limits` as on the client. `ConnectionCount()`, `AcceptedCount()`, `HandshakeFailureCount()` (connections dropped in the TLS handshake: a scanner, a client with no protocol in common, an untrusted client certificate; ordinary on a public port), `Secure()`.
|
||||
|
||||
|
|
@ -63,12 +63,12 @@ A route map is `std::unordered_map<std::string, std::function<HTTPResponse(const
|
|||
|
||||
### TCP
|
||||
|
||||
**`ClientTCP(host, port, connectTimeout = 0ms)`** resolves with `getaddrinfo`, IPv4 only, and connects; zero waits out the kernel's retries (about two minutes to a host that never answers), a timeout throws `SocketTimeoutException` after that long, and a refused connect throws `std::runtime_error` at once. `ClientTCP(int socket)` adopts a descriptor. Move-only; the destructor shuts down and closes `socketid`, a public member. `SetNoDelay(true)` for small frames that must leave at once; `SetReceiveTimeout(ms)` makes a receive that waits longer throw `SocketTimeoutException`, losing what a whole-buffer receive had collected; `Stop()` closes; `Connect(timeout)` after it opens a new socket to the host and port the client was built from (not on one that adopted a descriptor, which recorded no address).
|
||||
**`ClientTCP(host, port, connectTimeout = 0ms)`** resolves with `getaddrinfo` for IPv4 and IPv6 and tries every address in the order it returned them, so `localhost` reaches a server on `::1` alone; zero waits out the kernel's retries per address (about two minutes to a host that never answers), a timeout throws `SocketTimeoutException` after that long per address, and when no address connects what the last one failed with is thrown. A failed resolve, `socket()`, connect, send or receive throws `SocketErrorException`, a `std::system_error`: `code()` is the errno under `std::generic_category()` or the `getaddrinfo` result under `Crafter::ResolverCategory()` (whose `message()` is `gai_strerror`'s), and `phase` is the step (`SocketPhase::Resolve`, `Open`, `Connect`, `Send`, `Receive`); `what()` is the message ClientTCP always threw with the code's text after it, so a handler for `std::runtime_error` still catches it, and `[Errno {}] {}` is `code().value()` and `code().message()`. `ClientTCP(int socket)` adopts a descriptor. Move-only; the destructor shuts down and closes `socketid`, a public member. `SetNoDelay(true)` for small frames that must leave at once; `SetReceiveTimeout(ms)` makes a receive that waits longer throw `SocketTimeoutException`, losing what a whole-buffer receive had collected; `Stop()` closes; `Connect(timeout)` after it opens a new socket to the host and port the client was built from (not on one that adopted a descriptor, which recorded no address).
|
||||
|
||||
- `Send(buffer, size)` writes it all. The receives return what arrived, at least one byte: `RecieveSync()` one chunk, `RecieveSync(n)` up to `n`, `RecieveUntilFullSync(n)` exactly `n`, `RecieveUntilCloseSync()` everything to the peer's close, `RecieveSync(n, buffer)` into your memory, returning the count. Asked for zero bytes they return at once. They throw `SocketClosedException` once the peer has closed, `SocketTimeoutException` on a receive timeout, `std::runtime_error` otherwise, and wait on through a signal.
|
||||
- `Send(buffer, size)` writes it all. The receives return what arrived, at least one byte: `RecieveSync()` one chunk, `RecieveSync(n)` up to `n`, `RecieveUntilFullSync(n)` exactly `n`, `RecieveUntilCloseSync()` everything to the peer's close, `RecieveSync(n, buffer)` into your memory, returning the count. Asked for zero bytes they return at once. They throw `SocketClosedException` once the peer has closed, `SocketTimeoutException` on a receive timeout, `SocketErrorException` otherwise, and wait on through a signal.
|
||||
- The `*Async` receives (`RecieveAsync`, `RecieveUntilCloseAsync`, `RecieveAsync(n, ...)`, `RecieveUntilFullAsync(n, ...)`, and `RecieveAsync(n, callback(count), buffer, ...)` into memory that must outlive the call) run the same receive as a pool task and take an `onError(std::exception_ptr)`: what the receive throws goes there, and an empty `onError` drops it, since an exception leaving a pool task ends the program. The task uses the `ClientTCP`, which must outlive it; a parked receive ends through `Stop()` from another thread or a receive timeout, both of which arrive at `onError`.
|
||||
|
||||
**`ListenerTCP(port, connectCallback, concurrentClientLimit, totalClientLimit)`** binds every IPv4 address with `SO_REUSEADDR`, backlog 128, and throws `std::runtime_error` when it cannot bind or listen. The callback gets a heap-allocated `ClientTCP*` and owns it: delete it when the connection is done. `ListenSyncSync()` accepts on the calling thread and calls the callback there; `ListenSyncAsync()` calls it as a pool task; `ListenAsyncSync()` and `ListenAsyncAsync()` run the accept loop itself as a pool task, the second with the callback as another. `Stop()` closes the socket, which ends the loop. `totalClientLimit` ends it after that many accepts; `concurrentClientLimit` is stored and not enforced.
|
||||
**`ListenerTCP(port, connectCallback, concurrentClientLimit, totalClientLimit)`** binds every IPv4 address with `SO_REUSEADDR`, backlog 128, and throws `std::runtime_error` when it cannot bind or listen; **`ListenerTCP(address, port, ...)`** binds that one address, IPv4 or IPv6, and port 0 lets the kernel pick, reported by `Port()`. The callback gets a heap-allocated `ClientTCP*` and owns it: delete it when the connection is done. `ListenSyncSync()` accepts on the calling thread and calls the callback there; `ListenSyncAsync()` calls it as a pool task; `ListenAsyncSync()` and `ListenAsyncAsync()` run the accept loop itself as a pool task, the second with the callback as another. `Stop()` closes the socket, which ends the loop. `totalClientLimit` ends it after that many accepts; `concurrentClientLimit` is stored and not enforced.
|
||||
|
||||
### QUIC
|
||||
|
||||
|
|
@ -107,14 +107,14 @@ On a `wasm32-*` target the same `ClientHTTP` and `ClientQUIC` compile against th
|
|||
|
||||
| Call | On failure |
|
||||
|---|---|
|
||||
| `ClientTCP` constructor, `Connect`, `Send`, `Recieve*Sync` | `std::runtime_error` (resolve, refused, socket errors); `SocketTimeoutException` (connect or receive timeout); `SocketClosedException` (the peer closed) |
|
||||
| `ClientTCP` constructor, `Connect`, `Send`, `Recieve*Sync` | `SocketErrorException` (a `std::system_error`: a failed resolve, `socket()`, connect, send or receive, with `phase` and `code()`); `SocketTimeoutException` (connect or receive timeout); `SocketClosedException` (the peer closed) |
|
||||
| `ClientTCP::Recieve*Async` | the same, as an `std::exception_ptr` to `onError`; dropped without one |
|
||||
| `ListenerTCP`, `ListenerHTTP1`, `ListenerAsyncHTTP1` constructors | `std::runtime_error` when the port cannot be bound; `TLSException` for unusable credentials |
|
||||
| `ListenerHTTP`, `ListenerAsyncHTTP` constructors | `QUICException` (they build a `ListenerQUIC`) |
|
||||
| `ClientHTTP1(host, port, TLSClientCredentials)` constructor | `TLSException` for an unreadable certificate or trust anchor, at construction rather than at the first `Send` |
|
||||
| `TLSContext::Server`/`Client`, `TLSStream::Connect`/`Accept` | `TLSException`, naming the certificate error |
|
||||
| `ByteStream::ReadSome` / `Write` | `std::runtime_error`; a read that times out is `StreamStatus::TimedOut`, a write that does throws |
|
||||
| `ClientHTTP1::Send` | `HTTP1::HTTP1ProtocolError`, `TLSException`, `std::runtime_error` (`timed out waiting for the response`, `connection closed before a complete response arrived`, the socket's) |
|
||||
| `ClientHTTP1::Send` | `HTTP1::HTTP1ProtocolError`, `TLSException`, `SocketErrorException` (the socket's), `SocketTimeoutException` (the connect), `std::runtime_error` (`timed out waiting for the response`, `connection closed before a complete response arrived`) |
|
||||
| `ClientHTTP1::SendAsync`, `ClientHTTP::SendAsync` | `what()` as a string to `onError` |
|
||||
| `HTTP1::MessageParser`, `SerializeRequest`/`SerializeResponse` | `HTTP1::HTTP1ProtocolError` |
|
||||
| `ClientQUIC` constructor, `OpenStream`, `SendDatagram`, `GetStreamId`, `ListenerQUIC` constructor | `QUICException` (native; the browser build's `ClientQUIC` never throws, see "Browser build") |
|
||||
|
|
@ -123,7 +123,7 @@ On a `wasm32-*` target the same `ClientHTTP` and `ClientQUIC` compile against th
|
|||
| `ClientHTTP::Send` | `QUICException`, `QUICClosedException`, `HTTP3::HTTP3ProtocolError` |
|
||||
| Route handlers and fallbacks | an exception becomes a 500; a WT handler's exception is swallowed |
|
||||
|
||||
`SocketClosedException`, `SocketTimeoutException` and `QUICClosedException` derive from `std::exception`, not `std::runtime_error`; `QUICException`, `TLSException`, `HTTP1ProtocolError` and `HTTP3ProtocolError` do, so a `catch (const std::runtime_error&)` misses the first three.
|
||||
`SocketClosedException`, `SocketTimeoutException` and `QUICClosedException` derive from `std::exception`, not `std::runtime_error`; `SocketErrorException` (through `std::system_error`), `QUICException`, `TLSException`, `HTTP1ProtocolError` and `HTTP3ProtocolError` do, so a `catch (const std::runtime_error&)` misses the first three.
|
||||
|
||||
### Spellings
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Crafter.Network is the Crafter engine's networking library: one C++ module, `Cra
|
|||
|
||||
Partitions and what they own:
|
||||
|
||||
- `:ClientTCP`, `:ListenerTCP`: POSIX sockets, IPv4. `SocketClosedException`, `SocketTimeoutException`, the receive semantics (what arrived, never a padded buffer) and the `onError` of the async receives. The listener hands its callback a `new ClientTCP` the callback owns.
|
||||
- `:ClientTCP`, `:ListenerTCP`: POSIX sockets, IPv4 and IPv6. `SocketClosedException`, `SocketTimeoutException`, `SocketErrorException` (a `std::system_error` with the `SocketPhase` that failed; `ResolverCategory()` for `getaddrinfo`'s codes), the receive semantics (what arrived, never a padded buffer) and the `onError` of the async receives. The listener hands its callback a `new ClientTCP` the callback owns.
|
||||
- `:Stream`: `ByteStream` (`ReadSome` and `Write` with a timeout per call, `Shutdown`, `Descriptor`, `Protocol`, `Secure`) and `PlainStream`, so one HTTP/1.1 implementation serves `http://` and `https://`. `SetNonBlocking`, `PollDescriptor`.
|
||||
- `:TLS`: `TLSContext`, `TLSStream`, both credential structs, `GetSelfSignedCertificatePem`. OpenSSL stays behind the `Impl` pointers; no OpenSSL header reaches an importer.
|
||||
- `:HTTP`: `HTTPRequest`, `HTTPResponse`, the `Create*` helpers, `PathWithoutQueryHTTP`. Shared by both HTTP versions and by every build.
|
||||
|
|
@ -130,8 +130,7 @@ Decided deliberately; change them only with the maintainer's say-so.
|
|||
- **More comments the code outgrew**: `-ListenerQUIC.cpp` says the certificate pair lives in a `mkdtemp`'d directory for the life of the process (it is the fixed `/tmp/crafter-network-quic-cert/`, reused) and is valid 13 days (`-days 10`); `ClientQUIC.cppm` calls `selfSigned` an in-memory certificate (it is on disk, made by the CLI); `ListenerHTTP.cppm` says `Listen()` dispatches on the calling thread (it is `ListenSyncAsync`); the link-flag comment in `project.cpp` still has msquic bundling quictls (it links the system `libssl`). Fix a comment when you touch its code.
|
||||
- **`WebTransportSession::OpenStream(true)` does not throw** as its comment says; it writes the unidirectional prefix. Peer-initiated unidirectional WebTransport streams are drained by the listener and never reach a session.
|
||||
- **`ListenerTCP::concurrentClientLimit` is stored and never read**; only `totalClientLimit` ends the loop.
|
||||
- **TCP and HTTP/1.1 are IPv4 only** (`AF_INET` in `ClientTCP` and `ListenerTCP`); QUIC listens and connects on either family (`QUIC_ADDRESS_FAMILY_UNSPEC`).
|
||||
- **`ListenerTCP(port, ...)` listens on every IPv4 address**; given an address it binds that one, IPv4 or IPv6, and port 0 asks the kernel for one. `ClientTCP` resolves for both families and tries every address; QUIC listens and connects on either family (`QUIC_ADDRESS_FAMILY_UNSPEC`).
|
||||
- **The Flatpak's host triple is `x86_64-unknown-linux-gnu`**, and the tests register on any Linux triple since the TCP-only commit; before it they never ran inside the Flatpak.
|
||||
- **A port in use from a crashed run** is `Could not bind port <n>: Address already in use` on the HTTP/1.1 side and a `ListenerStart failed` `QUICException` on the QUIC side; find the stray process rather than changing the port.
|
||||
- **A warning is printed only when its unit actually recompiles**; a build with nothing to do is silent and says nothing about warnings.
|
||||
- **`claude/tcp-only` carries four unmerged TCP and HTTP/1.1 commits** (IPv4 and IPv6 connects with the reason a connect failed, which retires the IPv4-only gotcha for the client; a bind address and port 0 for `ListenerTCP`; a bounded connect and `FetchHTTP1` following redirects; errno in the TCP errors), and `bin/` and `build/` keep its six test directories until `clean`.
|
||||
|
|
|
|||
|
|
@ -53,13 +53,14 @@ client.Send("Hello World", 11);
|
|||
std::vector<char> data = client.RecieveSync();
|
||||
```
|
||||
|
||||
The host is resolved for IPv4 and IPv6 alike and every address it resolves to is tried in turn, as Python's `socket.create_connection` does, so `localhost` reaches a server that listens on `::1` only. The optional third argument is a connect timeout, applied to each address on its own. A failed resolve, connect, send or receive throws `SocketErrorException`: still a `std::runtime_error` with the same message, but carrying a `kind` (`SocketErrorKind::System` for an errno such as `ECONNREFUSED`, `SocketErrorKind::Resolver` for a getaddrinfo result such as `EAI_NONAME`) and the `code`, so a caller can report the cause without parsing the text.
|
||||
The host is resolved for IPv4 and IPv6 alike and every address it resolves to is tried in turn, as Python's `socket.create_connection` does, so `localhost` reaches a server that listens on `::1` only. The optional third argument is a connect timeout, applied to each address on its own. A failed resolve, connect, send or receive throws `SocketErrorException`: a `std::system_error`, so still a `std::runtime_error` with the same message, whose `code()` is the errno under `std::generic_category()` or the `getaddrinfo` result under `Crafter::ResolverCategory()`, and whose `phase` names the step (`SocketPhase::Resolve`, `Open`, `Connect`, `Send` or `Receive`), so a caller can report the cause without parsing the text.
|
||||
|
||||
```cpp
|
||||
try {
|
||||
Crafter::ClientTCP client("localhost", 5432, std::chrono::seconds(5));
|
||||
} catch (const Crafter::SocketErrorException& e) {
|
||||
std::println("[Errno {}] {}", e.code, std::strerror(e.code)); // [Errno 111] Connection refused
|
||||
if (e.code() == std::errc::connection_refused) std::println("nothing listens there");
|
||||
std::println("[Errno {}] {}", e.code().value(), e.code().message()); // [Errno 111] Connection refused
|
||||
} catch (const Crafter::SocketTimeoutException&) {
|
||||
std::println("timed out");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,9 +44,21 @@ namespace {
|
|||
[[noreturn]] void ThrowRecieveError() {
|
||||
const int failure = errno;
|
||||
if (failure == EAGAIN || failure == EWOULDBLOCK) throw SocketTimeoutException();
|
||||
throw SocketErrorException(SocketErrorKind::System, failure, std::strerror(failure));
|
||||
throw SocketErrorException(SocketPhase::Receive, std::error_code(failure, std::generic_category()));
|
||||
}
|
||||
|
||||
// getaddrinfo's results as an error category: the number is one of
|
||||
// netdb.h's EAI_* codes and the text is what gai_strerror makes of it.
|
||||
class ResolverErrorCategory : public std::error_category {
|
||||
public:
|
||||
const char* name() const noexcept override {
|
||||
return "resolver";
|
||||
}
|
||||
std::string message(int code) const override {
|
||||
return gai_strerror(code);
|
||||
}
|
||||
};
|
||||
|
||||
// One connect attempt to one address: a connected, blocking socket, or a
|
||||
// throw that leaves nothing open.
|
||||
int ConnectTo(const sockaddr* address, socklen_t length, std::chrono::milliseconds timeout) {
|
||||
|
|
@ -55,7 +67,7 @@ namespace {
|
|||
// An address family the host cannot open — IPv6 switched off,
|
||||
// say — fails this address alone; the caller moves on.
|
||||
const int failure = errno;
|
||||
throw SocketErrorException(SocketErrorKind::System, failure, std::string("Could not open socket: ") + std::strerror(failure));
|
||||
throw SocketErrorException(SocketPhase::Open, std::error_code(failure, std::generic_category()), "Could not open socket");
|
||||
}
|
||||
|
||||
// With a timeout the connect runs non-blocking, so poll can bound the
|
||||
|
|
@ -93,7 +105,7 @@ namespace {
|
|||
// connected to anything — every later send/recv on it would fail
|
||||
// with a far less obvious error.
|
||||
close(fd);
|
||||
throw SocketErrorException(SocketErrorKind::System, failure, std::string("Could not connect to server: ") + std::strerror(failure));
|
||||
throw SocketErrorException(SocketPhase::Connect, std::error_code(failure, std::generic_category()), "Could not connect to server");
|
||||
}
|
||||
if (bounded) {
|
||||
fcntl(fd, F_SETFL, flags);
|
||||
|
|
@ -102,6 +114,11 @@ namespace {
|
|||
}
|
||||
}
|
||||
|
||||
const std::error_category& Crafter::ResolverCategory() noexcept {
|
||||
static const ResolverErrorCategory category;
|
||||
return category;
|
||||
}
|
||||
|
||||
ClientTCP::ClientTCP(int socketid) : socketid(socketid)
|
||||
{
|
||||
|
||||
|
|
@ -125,8 +142,8 @@ ClientTCP::ClientTCP(const char* hostName, std::uint16_t port, std::chrono::mill
|
|||
const std::string service = std::to_string(port);
|
||||
const int status = getaddrinfo(hostName, service.c_str(), &hints, &resolved);
|
||||
if (status != 0 || resolved == nullptr) {
|
||||
throw SocketErrorException(SocketErrorKind::Resolver, status,
|
||||
std::string("Could not resolve host '") + hostName + "': " + gai_strerror(status));
|
||||
throw SocketErrorException(SocketPhase::Resolve, std::error_code(status, ResolverCategory()),
|
||||
std::string("Could not resolve host '") + hostName + "'");
|
||||
}
|
||||
for (const addrinfo* entry = resolved; entry != nullptr; entry = entry->ai_next) {
|
||||
Address address {};
|
||||
|
|
@ -215,7 +232,7 @@ void ClientTCP::Send(const void* buffer, std::uint32_t size) const {
|
|||
} else if (status < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
const int failure = errno;
|
||||
throw SocketErrorException(SocketErrorKind::System, failure, std::strerror(failure));
|
||||
throw SocketErrorException(SocketPhase::Send, std::error_code(failure, std::generic_category()));
|
||||
}
|
||||
sent += static_cast<std::uint32_t>(status);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,26 +37,42 @@ namespace Crafter {
|
|||
}
|
||||
};
|
||||
|
||||
// Where the code of a SocketErrorException comes from.
|
||||
export enum class SocketErrorKind {
|
||||
// An errno: ECONNREFUSED, ENETUNREACH, EMFILE, ...
|
||||
System,
|
||||
// A getaddrinfo result: EAI_NONAME, EAI_AGAIN, ...
|
||||
Resolver,
|
||||
// The step of a connection a SocketErrorException failed at.
|
||||
export enum class SocketPhase {
|
||||
// getaddrinfo on the host name.
|
||||
Resolve,
|
||||
// socket() for an address the host resolved to.
|
||||
Open,
|
||||
// connect() to that address.
|
||||
Connect,
|
||||
Send,
|
||||
Receive,
|
||||
};
|
||||
|
||||
// A resolve, a socket(), a connect, a send or a receive that failed, with
|
||||
// the code it failed with. what() is the text ClientTCP has always thrown, and catching
|
||||
// std::runtime_error still catches it; the code is for callers that
|
||||
// report the cause in their own words — a Python-style
|
||||
// "[Errno 111] Connection refused", say — and would otherwise have to
|
||||
// parse it back out of the message.
|
||||
export class SocketErrorException : public std::runtime_error {
|
||||
// The error category of getaddrinfo's results (EAI_NONAME, EAI_AGAIN,
|
||||
// ...): a number space of its own that overlaps the errno values, so a
|
||||
// resolver code is never confused with an errno, and whose message() is
|
||||
// gai_strerror's text. One instance for the process, like
|
||||
// std::generic_category().
|
||||
export const std::error_category& ResolverCategory() noexcept;
|
||||
|
||||
// A resolve, a socket(), a connect, a send or a receive that failed. A
|
||||
// std::system_error, so still a std::runtime_error for the handlers that
|
||||
// catch one: code() is the errno under std::generic_category(), or the
|
||||
// getaddrinfo result under ResolverCategory(), and what() is the text
|
||||
// ClientTCP has always thrown, the code's own text after it. `phase`
|
||||
// says which step failed. A caller that reports the cause in its own
|
||||
// words — "[Errno 111] Connection refused", say — takes code().value()
|
||||
// and code().message() instead of parsing what(), and compares code()
|
||||
// with std::errc::connection_refused instead of the number.
|
||||
export class SocketErrorException : public std::system_error {
|
||||
public:
|
||||
SocketErrorException(SocketErrorKind kind, std::int32_t code, const std::string& message)
|
||||
: std::runtime_error(message), kind(kind), code(code) {}
|
||||
SocketErrorKind kind;
|
||||
std::int32_t code;
|
||||
SocketErrorException(SocketPhase phase, std::error_code code, const std::string& what)
|
||||
: std::system_error(code, what), phase(phase) {}
|
||||
// Without a message of its own: what() is the code's text alone.
|
||||
SocketErrorException(SocketPhase phase, std::error_code code)
|
||||
: std::system_error(code), phase(phase) {}
|
||||
SocketPhase phase;
|
||||
};
|
||||
|
||||
export class ClientTCP {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ int main() {
|
|||
try {
|
||||
ClientTCP client(*outside, port, 2s);
|
||||
} catch (const SocketErrorException& e) {
|
||||
refused = e.code == ECONNREFUSED;
|
||||
refused = e.phase == SocketPhase::Connect && e.code() == std::errc::connection_refused;
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
Check(refused, std::format("through {} it is refused: it listens on 127.0.0.1 alone", *outside));
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ int main() {
|
|||
try {
|
||||
ClientTCP client("localhost", closedPort, 2s);
|
||||
} catch (const SocketErrorException& e) {
|
||||
refused = e.kind == SocketErrorKind::System && e.code == ECONNREFUSED;
|
||||
refused = e.phase == SocketPhase::Connect && e.code() == std::errc::connection_refused;
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
Check(refused, "when no address answers, the last failure is thrown, with its errno");
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// A failed resolve or connect throws SocketErrorException, which carries the
|
||||
// code it failed with: the errno for a connect, the getaddrinfo result for a
|
||||
// name. Before, both were a bare std::runtime_error, so a caller that reports
|
||||
// the cause in its own words — a Python-style "[Errno 111] Connection
|
||||
// refused" in front of a language model reading the output — had to parse the
|
||||
// message back apart. The message itself stays what it always was, and the
|
||||
// exception is still a std::runtime_error, so existing handlers keep working.
|
||||
// A failed resolve or connect throws SocketErrorException, a std::system_error
|
||||
// whose code() carries what it failed with: the errno for a connect, under
|
||||
// std::generic_category(); the getaddrinfo result for a name, under
|
||||
// ResolverCategory(), whose text is gai_strerror's. `phase` says which step
|
||||
// failed. Before, both were a bare std::runtime_error, so a caller that
|
||||
// reports the cause in its own words — "[Errno 111] Connection refused" in
|
||||
// front of a language model reading the output — had to parse the message
|
||||
// back apart. The message itself stays what it always was, and the exception
|
||||
// is still a std::runtime_error, so existing handlers keep working.
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netdb.h>
|
||||
|
|
@ -54,8 +56,11 @@ int main() {
|
|||
ClientTCP client("127.0.0.1", ClosedPort(), 2s);
|
||||
} catch (const SocketErrorException& e) {
|
||||
thrown = true;
|
||||
Check(e.kind == SocketErrorKind::System, "a refused connect is a system error");
|
||||
Check(e.code == ECONNREFUSED, std::format("it carries ECONNREFUSED (got {})", e.code));
|
||||
Check(e.phase == SocketPhase::Connect, "a refused connect failed in the connect phase");
|
||||
Check(e.code().category() == std::generic_category(), "its code is an errno");
|
||||
Check(e.code().value() == ECONNREFUSED, std::format("it carries ECONNREFUSED (got {})", e.code().value()));
|
||||
Check(e.code() == std::errc::connection_refused, "and compares with std::errc::connection_refused");
|
||||
Check(e.code().message() == std::strerror(ECONNREFUSED), std::format("its code's text is strerror's (got '{}')", e.code().message()));
|
||||
Check(std::string_view(e.what()) == std::string("Could not connect to server: ") + std::strerror(ECONNREFUSED),
|
||||
std::format("its message is the one ClientTCP always threw (got '{}')", e.what()));
|
||||
}
|
||||
|
|
@ -64,12 +69,14 @@ int main() {
|
|||
|
||||
{
|
||||
bool caught = false;
|
||||
bool system = false;
|
||||
try {
|
||||
ClientTCP client("127.0.0.1", ClosedPort(), 2s);
|
||||
} catch (const std::runtime_error&) {
|
||||
caught = true;
|
||||
} catch (const std::system_error& e) {
|
||||
system = e.code() == std::errc::connection_refused;
|
||||
caught = dynamic_cast<const std::runtime_error*>(&e) != nullptr;
|
||||
}
|
||||
Check(caught, "it is still caught as std::runtime_error");
|
||||
Check(caught && system, "it is caught as std::system_error, and is still a std::runtime_error");
|
||||
}
|
||||
|
||||
{
|
||||
|
|
@ -80,10 +87,12 @@ int main() {
|
|||
ClientTCP client("no-such-host.invalid", 80, 2s);
|
||||
} catch (const SocketErrorException& e) {
|
||||
thrown = true;
|
||||
Check(e.kind == SocketErrorKind::Resolver, "a name that does not resolve is a resolver error");
|
||||
Check(e.code != 0, std::format("it carries the getaddrinfo code (got {})", e.code));
|
||||
Check(std::string_view(e.what()).starts_with("Could not resolve host 'no-such-host.invalid': "),
|
||||
std::format("its message names the host (got '{}')", e.what()));
|
||||
Check(e.phase == SocketPhase::Resolve, "a name that does not resolve failed in the resolve phase");
|
||||
Check(e.code().category() == ResolverCategory(), "its code is in the resolver category");
|
||||
Check(e.code().value() != 0, std::format("it carries the getaddrinfo code (got {})", e.code().value()));
|
||||
Check(e.code().message() == gai_strerror(e.code().value()), std::format("the category's text is gai_strerror's (got '{}')", e.code().message()));
|
||||
Check(std::string_view(e.what()) == std::string("Could not resolve host 'no-such-host.invalid': ") + gai_strerror(e.code().value()),
|
||||
std::format("its message names the host, the code's text after it (got '{}')", e.what()));
|
||||
}
|
||||
Check(thrown, "an unresolvable host throws SocketErrorException");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// A send or a receive that fails throws SocketErrorException with its errno,
|
||||
// as a failed connect does: a caller that reports the cause in its own words
|
||||
// — "the connection broke ([Errno 104] Connection reset by peer)" — needs the
|
||||
// code, and the message alone is the strerror text a send and a receive
|
||||
// always threw. A receive timeout is still SocketTimeoutException and a
|
||||
// clean close SocketClosedException.
|
||||
// A send or a receive that fails throws SocketErrorException with its errno
|
||||
// in code() and the phase that failed, as a failed connect does: a caller
|
||||
// that reports the cause in its own words — "the connection broke ([Errno
|
||||
// 104] Connection reset by peer)" — needs the code, and the message alone is
|
||||
// the strerror text a send and a receive always threw. A receive timeout is
|
||||
// still SocketTimeoutException and a clean close SocketClosedException.
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
|
|
@ -73,8 +73,8 @@ int main() {
|
|||
client.RecieveSync(16);
|
||||
} catch (const SocketErrorException& e) {
|
||||
thrown = true;
|
||||
Check(e.kind == SocketErrorKind::System, "a reset connection fails the receive as a system error");
|
||||
Check(e.code == ECONNRESET, std::format("it carries ECONNRESET (got {})", e.code));
|
||||
Check(e.phase == SocketPhase::Receive, "a reset connection fails in the receive phase");
|
||||
Check(e.code() == std::errc::connection_reset && e.code().value() == ECONNRESET, std::format("it carries ECONNRESET (got {})", e.code().value()));
|
||||
Check(std::string_view(e.what()) == std::strerror(ECONNRESET), std::format("its message is the strerror text (got '{}')", e.what()));
|
||||
} catch (const std::exception& e) {
|
||||
Check(false, std::format("the receive threw something else: {}", e.what()));
|
||||
|
|
@ -90,12 +90,14 @@ int main() {
|
|||
// kernel answers the next one with EPIPE or ECONNRESET.
|
||||
std::int32_t code = 0;
|
||||
bool runtime = false;
|
||||
bool sending = false;
|
||||
const std::string data(4096, 'x');
|
||||
for (int i = 0; i < 50 && code == 0; ++i) {
|
||||
try {
|
||||
client.Send(data.data(), static_cast<std::uint32_t>(data.size()));
|
||||
} catch (const SocketErrorException& e) {
|
||||
code = e.code;
|
||||
code = e.code().value();
|
||||
sending = e.phase == SocketPhase::Send && e.code().category() == std::generic_category();
|
||||
runtime = dynamic_cast<const std::runtime_error*>(&e) != nullptr;
|
||||
} catch (const std::exception&) {
|
||||
break;
|
||||
|
|
@ -103,7 +105,7 @@ int main() {
|
|||
std::this_thread::sleep_for(10ms);
|
||||
}
|
||||
Check(code == EPIPE || code == ECONNRESET, std::format("a send into a reset connection throws SocketErrorException with EPIPE or ECONNRESET (got {})", code));
|
||||
Check(runtime, "and it is still a std::runtime_error");
|
||||
Check(sending && runtime, "in the send phase, an errno, and still a std::runtime_error");
|
||||
}
|
||||
|
||||
std::println("{}", failures == 0 ? "all passed" : std::format("{} failed", failures));
|
||||
|
|
|
|||
Loading…
Reference in a new issue