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:
parent
f9a45c143e
commit
16da0a1979
5 changed files with 124 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
111
tests/ShouldReportIoErrors/main.cpp
Normal file
111
tests/ShouldReportIoErrors/main.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue