feat(tcp): connect over IPv4 and IPv6, and say why a connect failed
- ClientTCP resolves with AF_UNSPEC and tries every address the name resolves to, in getaddrinfo's order, the way Python's socket.create_connection does. It used to ask for AF_INET only and keep the first answer, so `localhost` could not reach a server listening on ::1 alone, which is what a dev server on a recent Node does. The connect timeout bounds each address on its own; when none connects, what the last one failed with is thrown. - A failed resolve, socket() or connect throws SocketErrorException: the std::runtime_error it always was, with the same message, plus the errno (SocketErrorKind::System) or the getaddrinfo code (SocketErrorKind::Resolver). A caller that reports the cause in its own words, such as a Python-style "[Errno 111] Connection refused", no longer has to parse it back out of the text. - The move constructor carries the resolved addresses along. New: ShouldConnectDualStack, ShouldReportConnectErrors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3a58564422
commit
78853033e8
6 changed files with 383 additions and 51 deletions
14
README.md
14
README.md
|
|
@ -53,6 +53,18 @@ client.Send("Hello World", 11);
|
||||||
std::vector<char> data = client.RecieveSync();
|
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.
|
||||||
|
|
||||||
|
```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
|
||||||
|
} catch (const Crafter::SocketTimeoutException&) {
|
||||||
|
std::println("timed out");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### ListenerTCP
|
#### ListenerTCP
|
||||||
```cpp
|
```cpp
|
||||||
// Create a TCP listener
|
// Create a TCP listener
|
||||||
|
|
@ -287,6 +299,8 @@ The library includes tests covering:
|
||||||
- HTTPS round-trip (`ShouldSendRecieveHTTPS1`) — the plaintext round-trip replayed over TLS, so a transport regression surfaces as an HTTP failure, plus what only exists under TLS: ALPN, `scheme=https` reaching handlers, a body spanning many records, verification failing for an untrusted certificate *and* for a trusted certificate presented under the wrong name, and a plaintext peer on the TLS port being counted and shrugged off
|
- HTTPS round-trip (`ShouldSendRecieveHTTPS1`) — the plaintext round-trip replayed over TLS, so a transport regression surfaces as an HTTP failure, plus what only exists under TLS: ALPN, `scheme=https` reaching handlers, a body spanning many records, verification failing for an untrusted certificate *and* for a trusted certificate presented under the wrong name, and a plaintext peer on the TLS port being counted and shrugged off
|
||||||
- HTTPS interop (`ShouldInteropCurlHTTPS1`) — `curl` verifying our certificate with `--cacert` (not `--insecure`): keep-alive reuse, POST, HEAD, negotiated version, and an h2-only client being refused rather than mis-served; then `ClientHTTP1` against python3's `http.server` behind `ssl.wrap_socket`, whose HTTP/1.0 `Connection: close` frames the body by `close_notify`
|
- HTTPS interop (`ShouldInteropCurlHTTPS1`) — `curl` verifying our certificate with `--cacert` (not `--insecure`): keep-alive reuse, POST, HEAD, negotiated version, and an h2-only client being refused rather than mis-served; then `ClientHTTP1` against python3's `http.server` behind `ssl.wrap_socket`, whose HTTP/1.0 `Connection: close` frames the body by `close_notify`
|
||||||
- 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
|
- 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
|
||||||
|
|
||||||
The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` and `ShouldInteropCurlHTTPS1` skip whichever half is unavailable when `curl` or `python3` is not installed, and the mutual-TLS half of `ShouldRequireClientCertificateHTTPS1` skips without the `openssl` CLI (a *client* certificate needs the `clientAuth` extended key usage, which the built-in development certificate does not carry) — so all three pass on a bare machine. Install `curl`, `python3` and `openssl` to actually exercise them.
|
The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` and `ShouldInteropCurlHTTPS1` skip whichever half is unavailable when `curl` or `python3` is not installed, and the mutual-TLS half of `ShouldRequireClientCertificateHTTPS1` skips without the `openssl` CLI (a *client* certificate needs the `clientAuth` extended key usage, which the built-in development certificate does not carry) — so all three pass on a bare machine. Install `curl`, `python3` and `openssl` to actually exercise them.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,60 @@ namespace {
|
||||||
if (errno == EAGAIN || errno == EWOULDBLOCK) throw SocketTimeoutException();
|
if (errno == EAGAIN || errno == EWOULDBLOCK) throw SocketTimeoutException();
|
||||||
throw std::runtime_error(std::strerror(errno));
|
throw std::runtime_error(std::strerror(errno));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const int fd = socket(address->sa_family, SOCK_STREAM, 0);
|
||||||
|
if (fd == -1) {
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a timeout the connect runs non-blocking, so poll can bound the
|
||||||
|
// wait; the socket is made blocking again once it is connected.
|
||||||
|
const bool bounded = timeout > std::chrono::milliseconds::zero();
|
||||||
|
const int flags = fcntl(fd, F_GETFL, 0);
|
||||||
|
if (bounded) {
|
||||||
|
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||||
|
}
|
||||||
|
int failure = 0;
|
||||||
|
if (connect(fd, address, length) == -1) {
|
||||||
|
failure = errno;
|
||||||
|
if (failure == EINPROGRESS) {
|
||||||
|
pollfd connecting { .fd = fd, .events = POLLOUT, .revents = 0 };
|
||||||
|
const int waitMs = static_cast<int>(std::min<std::chrono::milliseconds::rep>(timeout.count(), std::numeric_limits<int>::max()));
|
||||||
|
int ready;
|
||||||
|
do {
|
||||||
|
ready = poll(&connecting, 1, waitMs);
|
||||||
|
} while (ready == -1 && errno == EINTR);
|
||||||
|
if (ready == 0) {
|
||||||
|
close(fd);
|
||||||
|
throw SocketTimeoutException();
|
||||||
|
}
|
||||||
|
if (ready == -1) {
|
||||||
|
failure = errno;
|
||||||
|
} else {
|
||||||
|
// The connect's own result.
|
||||||
|
socklen_t size = sizeof(failure);
|
||||||
|
getsockopt(fd, SOL_SOCKET, SO_ERROR, &failure, &size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failure != 0) {
|
||||||
|
// Report the failure instead of handing back a socket that is not
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
if (bounded) {
|
||||||
|
fcntl(fd, F_SETFL, flags);
|
||||||
|
}
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ClientTCP::ClientTCP(int socketid) : socketid(socketid)
|
ClientTCP::ClientTCP(int socketid) : socketid(socketid)
|
||||||
|
|
@ -55,17 +109,28 @@ ClientTCP::ClientTCP(const char* hostName, std::uint16_t port, std::chrono::mill
|
||||||
// getaddrinfo rather than gethostbyname: the latter is not thread safe,
|
// getaddrinfo rather than gethostbyname: the latter is not thread safe,
|
||||||
// and it signals failure by returning null — which was then dereferenced
|
// and it signals failure by returning null — which was then dereferenced
|
||||||
// straight into a crash on any unresolvable host.
|
// straight into a crash on any unresolvable host.
|
||||||
|
//
|
||||||
|
// AF_UNSPEC, and every result kept: `localhost` resolves to ::1 and
|
||||||
|
// 127.0.0.1, and a server may listen on either one alone. Asking for
|
||||||
|
// AF_INET only, as this used to, made a server bound to ::1 unreachable
|
||||||
|
// by name. No AI_ADDRCONFIG, as Python's create_connection passes none
|
||||||
|
// either: it hides ::1 on a host whose only IPv6 address is loopback.
|
||||||
addrinfo hints{};
|
addrinfo hints{};
|
||||||
hints.ai_family = AF_INET;
|
hints.ai_family = AF_UNSPEC;
|
||||||
hints.ai_socktype = SOCK_STREAM;
|
hints.ai_socktype = SOCK_STREAM;
|
||||||
addrinfo* resolved = nullptr;
|
addrinfo* resolved = nullptr;
|
||||||
const std::string service = std::to_string(port);
|
const std::string service = std::to_string(port);
|
||||||
const int status = getaddrinfo(hostName, service.c_str(), &hints, &resolved);
|
const int status = getaddrinfo(hostName, service.c_str(), &hints, &resolved);
|
||||||
if (status != 0 || resolved == nullptr) {
|
if (status != 0 || resolved == nullptr) {
|
||||||
throw std::runtime_error(std::string("Could not resolve host '") + hostName + "': "
|
throw SocketErrorException(SocketErrorKind::Resolver, status,
|
||||||
+ gai_strerror(status));
|
std::string("Could not resolve host '") + hostName + "': " + gai_strerror(status));
|
||||||
|
}
|
||||||
|
for (const addrinfo* entry = resolved; entry != nullptr; entry = entry->ai_next) {
|
||||||
|
Address address {};
|
||||||
|
std::memcpy(&address.storage, entry->ai_addr, entry->ai_addrlen);
|
||||||
|
address.length = entry->ai_addrlen;
|
||||||
|
addresses.push_back(address);
|
||||||
}
|
}
|
||||||
serv_addr = *reinterpret_cast<sockaddr_in*>(resolved->ai_addr);
|
|
||||||
freeaddrinfo(resolved);
|
freeaddrinfo(resolved);
|
||||||
|
|
||||||
Connect(connectTimeout);
|
Connect(connectTimeout);
|
||||||
|
|
@ -79,7 +144,7 @@ ClientTCP::ClientTCP(std::string hostName, std::uint16_t port, std::chrono::mill
|
||||||
// The moved-from object gives up ownership; the socket itself stays open —
|
// The moved-from object gives up ownership; the socket itself stays open —
|
||||||
// closing it here (as this used to) meant moving a ClientTCP silently
|
// closing it here (as this used to) meant moving a ClientTCP silently
|
||||||
// dropped the connection it was carrying.
|
// dropped the connection it was carrying.
|
||||||
ClientTCP::ClientTCP(ClientTCP&& other) noexcept : socketid(other.socketid) {
|
ClientTCP::ClientTCP(ClientTCP&& other) noexcept : socketid(other.socketid), addresses(std::move(other.addresses)) {
|
||||||
other.socketid = -1;
|
other.socketid = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,52 +157,23 @@ ClientTCP::~ClientTCP()
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClientTCP::Connect(std::chrono::milliseconds timeout) {
|
void ClientTCP::Connect(std::chrono::milliseconds timeout) {
|
||||||
if((socketid = socket(AF_INET, SOCK_STREAM, 0)) == -1){
|
if (addresses.empty()) {
|
||||||
throw std::runtime_error(std::string("Could not open socket: ") + std::strerror(errno));
|
throw std::logic_error("ClientTCP::Connect: no address to connect to; a socket handed over by accept() cannot connect again");
|
||||||
}
|
|
||||||
|
|
||||||
// With a timeout the connect runs non-blocking, so poll can bound the
|
|
||||||
// wait; the socket is made blocking again once it is connected.
|
|
||||||
const bool bounded = timeout > std::chrono::milliseconds::zero();
|
|
||||||
const int flags = fcntl(socketid, F_GETFL, 0);
|
|
||||||
if (bounded) {
|
|
||||||
fcntl(socketid, F_SETFL, flags | O_NONBLOCK);
|
|
||||||
}
|
}
|
||||||
int failure = 0;
|
// Each address in turn, in the order getaddrinfo ranked them; when none
|
||||||
if(connect(socketid,(sockaddr*)&serv_addr, sizeof(sockaddr)) == -1){
|
// connects, the caller sees what the last one failed with, as with
|
||||||
failure = errno;
|
// Python's create_connection.
|
||||||
if (failure == EINPROGRESS) {
|
std::exception_ptr last;
|
||||||
pollfd connecting { .fd = socketid, .events = POLLOUT, .revents = 0 };
|
for (const Address& address : addresses) {
|
||||||
const int waitMs = static_cast<int>(std::min<std::chrono::milliseconds::rep>(timeout.count(), std::numeric_limits<int>::max()));
|
try {
|
||||||
int ready;
|
socketid = ConnectTo(reinterpret_cast<const sockaddr*>(&address.storage), address.length, timeout);
|
||||||
do {
|
return;
|
||||||
ready = poll(&connecting, 1, waitMs);
|
} catch (...) {
|
||||||
} while (ready == -1 && errno == EINTR);
|
last = std::current_exception();
|
||||||
if (ready == 0) {
|
|
||||||
close(socketid);
|
|
||||||
socketid = -1;
|
|
||||||
throw SocketTimeoutException();
|
|
||||||
}
|
|
||||||
if (ready == -1) {
|
|
||||||
failure = errno;
|
|
||||||
} else {
|
|
||||||
// The connect's own result.
|
|
||||||
socklen_t length = sizeof(failure);
|
|
||||||
getsockopt(socketid, SOL_SOCKET, SO_ERROR, &failure, &length);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (failure != 0) {
|
|
||||||
// Report the failure instead of handing back a socket that is not
|
|
||||||
// connected to anything — every later send/recv on it would fail
|
|
||||||
// with a far less obvious error.
|
|
||||||
close(socketid);
|
|
||||||
socketid = -1;
|
|
||||||
throw std::runtime_error(std::string("Could not connect to server: ") + std::strerror(failure));
|
|
||||||
}
|
|
||||||
if (bounded) {
|
|
||||||
fcntl(socketid, F_SETFL, flags);
|
|
||||||
}
|
}
|
||||||
|
socketid = -1;
|
||||||
|
std::rethrow_exception(last);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClientTCP::Stop() {
|
void ClientTCP::Stop() {
|
||||||
|
|
|
||||||
|
|
@ -37,14 +37,44 @@ 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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 {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
export class ClientTCP {
|
export class ClientTCP {
|
||||||
public:
|
public:
|
||||||
int socketid;
|
int socketid;
|
||||||
ClientTCP(int socket);
|
ClientTCP(int socket);
|
||||||
|
// The host is resolved for IPv4 and IPv6 alike and every address it
|
||||||
|
// resolves to is tried in turn, the way Python's
|
||||||
|
// socket.create_connection does: `localhost` reaches a server that
|
||||||
|
// listens on ::1 only, as a dev server on a recent Node does. The
|
||||||
|
// first address that connects wins; when none does, what the last
|
||||||
|
// one failed with is thrown — SocketErrorException, or
|
||||||
|
// SocketTimeoutException for a timeout.
|
||||||
|
//
|
||||||
// A non-zero connectTimeout makes a connect nobody answers throw
|
// A non-zero connectTimeout makes a connect nobody answers throw
|
||||||
// SocketTimeoutException after that long, where it would otherwise
|
// SocketTimeoutException after that long, where it would otherwise
|
||||||
// wait out the kernel's retries, about two minutes. A refused connect
|
// wait out the kernel's retries, about two minutes. It bounds each
|
||||||
// fails at once either way.
|
// address on its own. A refused connect fails at once either way.
|
||||||
ClientTCP(const char* host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero());
|
ClientTCP(const char* host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero());
|
||||||
ClientTCP(std::string host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero());
|
ClientTCP(std::string host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero());
|
||||||
~ClientTCP();
|
~ClientTCP();
|
||||||
|
|
@ -83,7 +113,13 @@ namespace Crafter {
|
||||||
// Into `buffer`, which must outlive the receive; the callback gets the count.
|
// Into `buffer`, which must outlive the receive; the callback gets the count.
|
||||||
void RecieveAsync(std::uint32_t bufferSize, std::function<void(std::uint32_t)> recieveCallback, char* buffer, std::function<void(std::exception_ptr)> onError) const;
|
void RecieveAsync(std::uint32_t bufferSize, std::function<void(std::uint32_t)> recieveCallback, char* buffer, std::function<void(std::exception_ptr)> onError) const;
|
||||||
private:
|
private:
|
||||||
sockaddr_in serv_addr;
|
struct Address {
|
||||||
|
sockaddr_storage storage;
|
||||||
|
socklen_t length;
|
||||||
|
};
|
||||||
|
// What the host resolved to, in getaddrinfo's order; empty for a
|
||||||
|
// socket handed over by accept().
|
||||||
|
std::vector<Address> addresses;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
@ -174,6 +174,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
if (cfg.target.find("linux") != std::string::npos) {
|
if (cfg.target.find("linux") != std::string::npos) {
|
||||||
cfg.AddTest("ShouldConfigureTCP").Dependencies({ &cfg });
|
cfg.AddTest("ShouldConfigureTCP").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldRecieveTCP").Dependencies({ &cfg });
|
cfg.AddTest("ShouldRecieveTCP").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldConnectDualStack").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldReportConnectErrors").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg });
|
cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldInteropCurlHTTPS1").Dependencies({ &cfg });
|
cfg.AddTest("ShouldInteropCurlHTTPS1").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg });
|
cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg });
|
||||||
|
|
|
||||||
151
tests/ShouldConnectDualStack/main.cpp
Normal file
151
tests/ShouldConnectDualStack/main.cpp
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// ClientTCP resolves a host for IPv4 and IPv6 alike and tries every address
|
||||||
|
// the name resolves to, in order, the way Python's socket.create_connection
|
||||||
|
// does. Before, it asked getaddrinfo for AF_INET only and kept the first
|
||||||
|
// answer, so a server listening on ::1 alone — a dev server on a recent Node
|
||||||
|
// binds `localhost` that way — could not be reached by name, while every
|
||||||
|
// Python client reached it.
|
||||||
|
//
|
||||||
|
// The listeners are raw sockets, so nothing here depends on ListenerTCP: one
|
||||||
|
// IPv6-only on ::1, one on 127.0.0.1. Skips (77) on a host without IPv6
|
||||||
|
// loopback.
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
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 socket listening on the loopback address of `family`, on a port the
|
||||||
|
// kernel picks; fd stays -1 when the host cannot open one.
|
||||||
|
struct LoopbackListener {
|
||||||
|
int fd = -1;
|
||||||
|
std::uint16_t port = 0;
|
||||||
|
|
||||||
|
explicit LoopbackListener(int family) {
|
||||||
|
fd = socket(family, SOCK_STREAM, 0);
|
||||||
|
if (fd == -1) return;
|
||||||
|
sockaddr_storage address {};
|
||||||
|
socklen_t length = 0;
|
||||||
|
if (family == AF_INET6) {
|
||||||
|
// v6 only, or the socket would also take IPv4 connects and the
|
||||||
|
// checks below would not tell the two apart.
|
||||||
|
int only = 1;
|
||||||
|
setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &only, sizeof(only));
|
||||||
|
auto* v6 = reinterpret_cast<sockaddr_in6*>(&address);
|
||||||
|
v6->sin6_family = AF_INET6;
|
||||||
|
v6->sin6_addr = in6addr_loopback;
|
||||||
|
length = sizeof(sockaddr_in6);
|
||||||
|
} else {
|
||||||
|
auto* v4 = reinterpret_cast<sockaddr_in*>(&address);
|
||||||
|
v4->sin_family = AF_INET;
|
||||||
|
v4->sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||||
|
length = sizeof(sockaddr_in);
|
||||||
|
}
|
||||||
|
if (bind(fd, reinterpret_cast<sockaddr*>(&address), length) != 0 || listen(fd, 8) != 0) {
|
||||||
|
close(fd);
|
||||||
|
fd = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getsockname(fd, reinterpret_cast<sockaddr*>(&address), &length);
|
||||||
|
port = family == AF_INET6 ? ntohs(reinterpret_cast<sockaddr_in6*>(&address)->sin6_port)
|
||||||
|
: ntohs(reinterpret_cast<sockaddr_in*>(&address)->sin_port);
|
||||||
|
}
|
||||||
|
~LoopbackListener() {
|
||||||
|
if (fd != -1) close(fd);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The address family of the peer a connected client reached.
|
||||||
|
int PeerFamily(const ClientTCP& client) {
|
||||||
|
sockaddr_storage address {};
|
||||||
|
socklen_t length = sizeof(address);
|
||||||
|
getpeername(client.socketid, reinterpret_cast<sockaddr*>(&address), &length);
|
||||||
|
return address.ss_family;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether `localhost` resolves to ::1 here; /etc/hosts decides.
|
||||||
|
bool LocalhostResolvesToIPv6() {
|
||||||
|
addrinfo hints {};
|
||||||
|
hints.ai_family = AF_INET6;
|
||||||
|
hints.ai_socktype = SOCK_STREAM;
|
||||||
|
addrinfo* resolved = nullptr;
|
||||||
|
if (getaddrinfo("localhost", nullptr, &hints, &resolved) != 0) return false;
|
||||||
|
freeaddrinfo(resolved);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
LoopbackListener v6(AF_INET6);
|
||||||
|
if (v6.fd == -1) {
|
||||||
|
std::println("SKIP no IPv6 loopback on this host");
|
||||||
|
return 77;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
ClientTCP client("::1", v6.port, 2s);
|
||||||
|
Check(PeerFamily(client) == AF_INET6, "an IPv6 literal connects over IPv6");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LocalhostResolvesToIPv6()) {
|
||||||
|
try {
|
||||||
|
ClientTCP client("localhost", v6.port, 2s);
|
||||||
|
Check(PeerFamily(client) == AF_INET6, "localhost reaches a server that listens on ::1 only");
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("localhost reaches a server that listens on ::1 only (threw: {})", e.what()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::println("SKIP localhost does not resolve to ::1 on this host");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Whichever address the resolver ranks first, a refused one is not
|
||||||
|
// the end: the next is tried.
|
||||||
|
LoopbackListener v4(AF_INET);
|
||||||
|
try {
|
||||||
|
ClientTCP client("localhost", v4.port, 2s);
|
||||||
|
Check(PeerFamily(client) == AF_INET, "localhost reaches a server that listens on 127.0.0.1 only");
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("localhost reaches a server that listens on 127.0.0.1 only (threw: {})", e.what()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
std::uint16_t closedPort = 0;
|
||||||
|
{
|
||||||
|
LoopbackListener gone(AF_INET);
|
||||||
|
closedPort = gone.port;
|
||||||
|
}
|
||||||
|
bool refused = false;
|
||||||
|
try {
|
||||||
|
ClientTCP client("localhost", closedPort, 2s);
|
||||||
|
} catch (const SocketErrorException& e) {
|
||||||
|
refused = e.kind == SocketErrorKind::System && e.code == ECONNREFUSED;
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
}
|
||||||
|
Check(refused, "when no address answers, the last failure is thrown, with its errno");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
}
|
||||||
93
tests/ShouldReportConnectErrors/main.cpp
Normal file
93
tests/ShouldReportConnectErrors/main.cpp
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
//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.
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstdlib>
|
||||||
|
#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 port on 127.0.0.1 nothing listens on: bound by the kernel, then released.
|
||||||
|
std::uint16_t ClosedPort() {
|
||||||
|
const int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
sockaddr_in address {};
|
||||||
|
address.sin_family = AF_INET;
|
||||||
|
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||||
|
bind(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address));
|
||||||
|
socklen_t length = sizeof(address);
|
||||||
|
getsockname(fd, reinterpret_cast<sockaddr*>(&address), &length);
|
||||||
|
close(fd);
|
||||||
|
return ntohs(address.sin_port);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
{
|
||||||
|
bool thrown = false;
|
||||||
|
try {
|
||||||
|
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(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()));
|
||||||
|
}
|
||||||
|
Check(thrown, "a refused connect throws SocketErrorException");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
ClientTCP client("127.0.0.1", ClosedPort(), 2s);
|
||||||
|
} catch (const std::runtime_error&) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
Check(caught, "it is still caught as std::runtime_error");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// .invalid never resolves (RFC 6761); without a network the resolver
|
||||||
|
// fails differently (EAI_AGAIN), but always as a resolver error.
|
||||||
|
bool thrown = false;
|
||||||
|
try {
|
||||||
|
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(thrown, "an unresolvable host throws SocketErrorException");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue