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

@ -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 {
public:
int socketid;
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
// SocketTimeoutException after that long, where it would otherwise
// wait out the kernel's retries, about two minutes. A refused connect
// fails at once either way.
// wait out the kernel's retries, about two minutes. It bounds each
// 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(std::string host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero());
~ClientTCP();
@ -83,7 +113,13 @@ namespace Crafter {
// 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;
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