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>
2026-10-31 05:06:41 +01:00
|
|
|
//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) {
|
feat(tcp): socket errors as std::system_error, with the phase that failed
SocketErrorException is now a std::system_error: code() carries the errno
under std::generic_category(), or getaddrinfo's result under the new
ResolverCategory(), whose message() is gai_strerror's text; `phase` says
which step failed (resolve, open, connect, send, receive). It is still a
std::runtime_error with the message ClientTCP always threw, the code's
text after it, so existing handlers keep working.
Before, the exception carried a `kind` and an int, and a caller that had
to print the cause its own way still had to match the message prefix to
learn where the failure happened. The standard type ends both: the
category knows how to print the number, and the phase is a field. A
caller writes "[Errno {}] {}" from code().value() and code().message(),
or compares code() with std::errc::connection_refused.
Breaking: SocketErrorException's `kind` and `code` members are gone;
read code().category() and code().value().
2026-10-31 20:33:37 +01:00
|
|
|
refused = e.phase == SocketPhase::Connect && e.code() == std::errc::connection_refused;
|
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>
2026-10-31 05:06:41 +01:00
|
|
|
} 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;
|
|
|
|
|
}
|