From 16da0a197973f61f005da2deab1a43056130cfe5 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 07:06:24 +0100 Subject: [PATCH] feat(tcp): a failed send or receive says which errno it failed with Send() and the receives threw a bare std::runtime_error with the strerror text, so a caller that reports the cause the way Python does - "the connection broke ([Errno 104] Connection reset by peer)", which a language model reads in Crafter.Review's SQL helper output - could not tell the code. They throw SocketErrorException now, as a failed connect already does: kind System, the errno, and the same message, still a std::runtime_error. A receive timeout stays SocketTimeoutException and a closed peer SocketClosedException. ShouldReportIoErrors: a peer that resets the connection (SO_LINGER 0) fails the next receive with ECONNRESET and a send into it with EPIPE or ECONNRESET. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- implementations/Crafter.Network-ClientTCP.cpp | 12 +- interfaces/Crafter.Network-ClientTCP.cppm | 4 +- project.cpp | 1 + tests/ShouldReportIoErrors/main.cpp | 111 ++++++++++++++++++ 5 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 tests/ShouldReportIoErrors/main.cpp diff --git a/README.md b/README.md index fb69171..67f747b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ 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 or connect 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`: 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. ```cpp try { @@ -327,6 +327,7 @@ The library includes tests covering: - Mutual TLS and the raw stream (`ShouldRequireClientCertificateHTTPS1`) — a client certificate the listener's CA vouches for is served and one absent is refused; `TLSStream` is also driven directly with hand-written HTTP/1.1 to keep `:TLS` usable without `:ClientHTTP1` on top - Dual-stack connect (`ShouldConnectDualStack`) — `localhost` reaches a server on `::1` only and one on `127.0.0.1` only, an IPv6 literal connects over IPv6, and the last failure is what is thrown when no address answers; skips on a host without IPv6 loopback - Connect errors (`ShouldReportConnectErrors`) — a refused connect and an unresolvable name throw `SocketErrorException` with their errno or getaddrinfo code and the message ClientTCP always threw, and still reach a `std::runtime_error` handler +- Send and receive errors (`ShouldReportIoErrors`) — a connection the peer resets fails the next receive, and a send into it, with `SocketErrorException` carrying ECONNRESET or EPIPE under the plain strerror text a receive and a send always threw - Loopback listeners (`ShouldBindLoopbackEphemeral`) — a listener on `127.0.0.1` at port 0 reports its port, takes loopback clients and refuses them through the host's other address; `::1` works too, a name is refused, and the port-only constructor still listens on every interface - HTTP/1.1 timeouts (`ShouldTimeoutSilentHTTP1`) — a server that accepts and never answers fails after `timeout`, and a connect nobody answers after `connectTimeout` - Redirects (`ShouldFollowRedirectsHTTP1`) — relative and `..` Locations, an http-to-https hop, the 4-repeat and 10-URL limits, a redirect without Location, POST after 303 and 307, percent-encoding a Location, and the URLs `FetchHTTP1` refuses diff --git a/implementations/Crafter.Network-ClientTCP.cpp b/implementations/Crafter.Network-ClientTCP.cpp index defeae6..ddab4b3 100755 --- a/implementations/Crafter.Network-ClientTCP.cpp +++ b/implementations/Crafter.Network-ClientTCP.cpp @@ -38,10 +38,13 @@ namespace { return read; } - // A failed recv. EAGAIN is the receive timeout (SO_RCVTIMEO) running out. + // A failed recv. EAGAIN is the receive timeout (SO_RCVTIMEO) running out; + // anything else carries its errno, under the message a receive always + // threw. [[noreturn]] void ThrowRecieveError() { - if (errno == EAGAIN || errno == EWOULDBLOCK) throw SocketTimeoutException(); - throw std::runtime_error(std::strerror(errno)); + const int failure = errno; + if (failure == EAGAIN || failure == EWOULDBLOCK) throw SocketTimeoutException(); + throw SocketErrorException(SocketErrorKind::System, failure, std::strerror(failure)); } // One connect attempt to one address: a connected, blocking socket, or a @@ -211,7 +214,8 @@ void ClientTCP::Send(const void* buffer, std::uint32_t size) const { throw SocketClosedException(); } else if (status < 0) { if (errno == EINTR) continue; - throw std::runtime_error(std::strerror(errno)); + const int failure = errno; + throw SocketErrorException(SocketErrorKind::System, failure, std::strerror(failure)); } sent += static_cast(status); } diff --git a/interfaces/Crafter.Network-ClientTCP.cppm b/interfaces/Crafter.Network-ClientTCP.cppm index 2481c4a..389d7c8 100755 --- a/interfaces/Crafter.Network-ClientTCP.cppm +++ b/interfaces/Crafter.Network-ClientTCP.cppm @@ -45,8 +45,8 @@ namespace Crafter { Resolver, }; - // A resolve, a socket() or a connect that failed, with the code it failed - // with. what() is the text ClientTCP has always thrown, and catching + // 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 diff --git a/project.cpp b/project.cpp index b4b2d29..1030b81 100644 --- a/project.cpp +++ b/project.cpp @@ -176,6 +176,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("ShouldRecieveTCP").Dependencies({ &cfg }); cfg.AddTest("ShouldConnectDualStack").Dependencies({ &cfg }); cfg.AddTest("ShouldReportConnectErrors").Dependencies({ &cfg }); + cfg.AddTest("ShouldReportIoErrors").Dependencies({ &cfg }); cfg.AddTest("ShouldBindLoopbackEphemeral").Dependencies({ &cfg }); cfg.AddTest("ShouldTimeoutSilentHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldFollowRedirectsHTTP1").Dependencies({ &cfg }); diff --git a/tests/ShouldReportIoErrors/main.cpp b/tests/ShouldReportIoErrors/main.cpp new file mode 100644 index 0000000..52d5004 --- /dev/null +++ b/tests/ShouldReportIoErrors/main.cpp @@ -0,0 +1,111 @@ +//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. + +#include +#include +#include +#include +#include +#include + +import Crafter.Network; +import std; +using namespace Crafter; +using namespace std::chrono_literals; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// A listener on 127.0.0.1 that accepts one connection and resets it: SO_LINGER +// with a zero timeout makes close() send an RST instead of a FIN. +struct Resetter { + int listener = -1; + std::uint16_t port = 0; + std::thread thread; + + Resetter() { + listener = socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bind(listener, reinterpret_cast(&address), sizeof(address)); + listen(listener, 1); + socklen_t length = sizeof(address); + getsockname(listener, reinterpret_cast(&address), &length); + port = ntohs(address.sin_port); + thread = std::thread([this] { + const int client = accept(listener, nullptr, nullptr); + linger reset { .l_onoff = 1, .l_linger = 0 }; + setsockopt(client, SOL_SOCKET, SO_LINGER, &reset, sizeof(reset)); + close(client); + }); + } + + ~Resetter() { + if (thread.joinable()) thread.join(); + close(listener); + } +}; + +} // namespace + +int main() { + { + Resetter peer; + ClientTCP client("127.0.0.1", peer.port, 2s); + client.SetReceiveTimeout(2s); + peer.thread.join(); + bool thrown = false; + try { + 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(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())); + } + Check(thrown, "a receive on a reset connection throws SocketErrorException"); + } + + { + Resetter peer; + ClientTCP client("127.0.0.1", peer.port, 2s); + peer.thread.join(); + // The first send after the reset may still be accepted locally; the + // kernel answers the next one with EPIPE or ECONNRESET. + std::int32_t code = 0; + bool runtime = 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; + runtime = dynamic_cast(&e) != nullptr; + } catch (const std::exception&) { + break; + } + 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"); + } + + std::println("{}", failures == 0 ? "all passed" : std::format("{} failed", failures)); + return failures == 0 ? 0 : 1; +}