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:
Jorijn van der Graaf 2026-10-31 05:06:41 +01:00
commit 78853033e8
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
6 changed files with 383 additions and 51 deletions

View file

@ -43,6 +43,60 @@ namespace {
if (errno == EAGAIN || errno == EWOULDBLOCK) throw SocketTimeoutException();
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)
@ -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,
// and it signals failure by returning null — which was then dereferenced
// 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{};
hints.ai_family = AF_INET;
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
addrinfo* resolved = nullptr;
const std::string service = std::to_string(port);
const int status = getaddrinfo(hostName, service.c_str(), &hints, &resolved);
if (status != 0 || resolved == nullptr) {
throw std::runtime_error(std::string("Could not resolve host '") + hostName + "': "
+ gai_strerror(status));
throw SocketErrorException(SocketErrorKind::Resolver, 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);
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 —
// closing it here (as this used to) meant moving a ClientTCP silently
// 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;
}
@ -92,52 +157,23 @@ ClientTCP::~ClientTCP()
}
void ClientTCP::Connect(std::chrono::milliseconds timeout) {
if((socketid = socket(AF_INET, SOCK_STREAM, 0)) == -1){
throw std::runtime_error(std::string("Could not open socket: ") + std::strerror(errno));
}
// 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);
if (addresses.empty()) {
throw std::logic_error("ClientTCP::Connect: no address to connect to; a socket handed over by accept() cannot connect again");
}
int failure = 0;
if(connect(socketid,(sockaddr*)&serv_addr, sizeof(sockaddr)) == -1){
failure = errno;
if (failure == EINPROGRESS) {
pollfd connecting { .fd = socketid, .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(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);
}
// Each address in turn, in the order getaddrinfo ranked them; when none
// connects, the caller sees what the last one failed with, as with
// Python's create_connection.
std::exception_ptr last;
for (const Address& address : addresses) {
try {
socketid = ConnectTo(reinterpret_cast<const sockaddr*>(&address.storage), address.length, timeout);
return;
} catch (...) {
last = std::current_exception();
}
}
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() {