diff --git a/AGENTS.consumer.md b/AGENTS.consumer.md index 376a689..0e70b41 100644 --- a/AGENTS.consumer.md +++ b/AGENTS.consumer.md @@ -41,7 +41,7 @@ A route map is `std::unordered_map: 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`. diff --git a/README.md b/README.md index 67f747b..274f1c1 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,14 @@ client.Send("Hello World", 11); std::vector 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"); } diff --git a/implementations/Crafter.Network-ClientTCP.cpp b/implementations/Crafter.Network-ClientTCP.cpp index ddab4b3..6f5aee6 100755 --- a/implementations/Crafter.Network-ClientTCP.cpp +++ b/implementations/Crafter.Network-ClientTCP.cpp @@ -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(status); } diff --git a/interfaces/Crafter.Network-ClientTCP.cppm b/interfaces/Crafter.Network-ClientTCP.cppm index 389d7c8..3a527e7 100755 --- a/interfaces/Crafter.Network-ClientTCP.cppm +++ b/interfaces/Crafter.Network-ClientTCP.cppm @@ -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 { diff --git a/tests/ShouldBindLoopbackEphemeral/main.cpp b/tests/ShouldBindLoopbackEphemeral/main.cpp index a897e22..1653c9e 100644 --- a/tests/ShouldBindLoopbackEphemeral/main.cpp +++ b/tests/ShouldBindLoopbackEphemeral/main.cpp @@ -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)); diff --git a/tests/ShouldConnectDualStack/main.cpp b/tests/ShouldConnectDualStack/main.cpp index 17707f8..2629650 100644 --- a/tests/ShouldConnectDualStack/main.cpp +++ b/tests/ShouldConnectDualStack/main.cpp @@ -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"); diff --git a/tests/ShouldReportConnectErrors/main.cpp b/tests/ShouldReportConnectErrors/main.cpp index c919907..6d0ecf5 100644 --- a/tests/ShouldReportConnectErrors/main.cpp +++ b/tests/ShouldReportConnectErrors/main.cpp @@ -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 #include @@ -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(&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"); } diff --git a/tests/ShouldReportIoErrors/main.cpp b/tests/ShouldReportIoErrors/main.cpp index 52d5004..61efa03 100644 --- a/tests/ShouldReportIoErrors/main.cpp +++ b/tests/ShouldReportIoErrors/main.cpp @@ -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 #include @@ -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(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(&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));