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 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 07:06:24 +01:00
commit 16da0a1979
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
5 changed files with 124 additions and 7 deletions

View file

@ -53,7 +53,7 @@ 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 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

View file

@ -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<std::uint32_t>(status);
}

View file

@ -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

View file

@ -176,6 +176,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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 });

View file

@ -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 <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
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<sockaddr*>(&address), sizeof(address));
listen(listener, 1);
socklen_t length = sizeof(address);
getsockname(listener, reinterpret_cast<sockaddr*>(&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<std::uint32_t>(data.size()));
} catch (const SocketErrorException& e) {
code = e.code;
runtime = dynamic_cast<const std::runtime_error*>(&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;
}