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>
328 lines
13 KiB
C++
Executable file
328 lines
13 KiB
C++
Executable file
//SPDX-License-Identifier: LGPL-3.0-only
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module;
|
|
|
|
#include <stdio.h>
|
|
#include <sys/types.h>
|
|
#include <sys/socket.h>
|
|
#include <netinet/in.h>
|
|
#include <arpa/inet.h>
|
|
#include <stdlib.h>
|
|
#include <sys/uio.h>
|
|
#include <sys/time.h>
|
|
#include <sys/wait.h>
|
|
#include <sys/ioctl.h>
|
|
#include <netdb.h>
|
|
#include <strings.h>
|
|
#include <cerrno>
|
|
#include <fcntl.h>
|
|
#include <netinet/tcp.h>
|
|
#include <poll.h>
|
|
|
|
module Crafter.Network:ClientTCP_impl;
|
|
import :ClientTCP;
|
|
import Crafter.Thread;
|
|
import std;
|
|
|
|
using namespace Crafter;
|
|
|
|
namespace {
|
|
// recv, retried when a signal interrupts it: a receive waits on through
|
|
// a signal rather than failing.
|
|
ssize_t RecieveRetrying(int socketid, void* buffer, std::size_t size) {
|
|
ssize_t read;
|
|
do {
|
|
read = recv(socketid, buffer, size, 0);
|
|
} while (read < 0 && errno == EINTR);
|
|
return read;
|
|
}
|
|
|
|
// 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() {
|
|
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
|
|
// 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(const char* hostName, std::uint16_t port, std::chrono::milliseconds connectTimeout)
|
|
{
|
|
// 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_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 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);
|
|
}
|
|
freeaddrinfo(resolved);
|
|
|
|
Connect(connectTimeout);
|
|
}
|
|
|
|
ClientTCP::ClientTCP(std::string hostName, std::uint16_t port, std::chrono::milliseconds connectTimeout): ClientTCP(hostName.c_str(), port, connectTimeout)
|
|
{
|
|
|
|
}
|
|
|
|
// 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), addresses(std::move(other.addresses)) {
|
|
other.socketid = -1;
|
|
}
|
|
|
|
ClientTCP::~ClientTCP()
|
|
{
|
|
if(socketid != -1) {
|
|
shutdown(socketid, SHUT_RDWR);
|
|
close(socketid);
|
|
}
|
|
}
|
|
|
|
void ClientTCP::Connect(std::chrono::milliseconds timeout) {
|
|
if (addresses.empty()) {
|
|
throw std::logic_error("ClientTCP::Connect: no address to connect to; a socket handed over by accept() cannot connect again");
|
|
}
|
|
// 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();
|
|
}
|
|
}
|
|
socketid = -1;
|
|
std::rethrow_exception(last);
|
|
}
|
|
|
|
void ClientTCP::Stop() {
|
|
shutdown(socketid, SHUT_RDWR);
|
|
close(socketid);
|
|
socketid = -1;
|
|
}
|
|
|
|
void ClientTCP::SetNoDelay(bool enabled) {
|
|
const int value = enabled ? 1 : 0;
|
|
if (setsockopt(socketid, IPPROTO_TCP, TCP_NODELAY, &value, sizeof(value)) == -1) {
|
|
throw std::runtime_error(std::string("Could not set TCP_NODELAY: ") + std::strerror(errno));
|
|
}
|
|
}
|
|
|
|
void ClientTCP::SetReceiveTimeout(std::chrono::milliseconds timeout) {
|
|
const auto seconds = std::chrono::duration_cast<std::chrono::seconds>(timeout);
|
|
timeval value {};
|
|
value.tv_sec = static_cast<time_t>(seconds.count());
|
|
value.tv_usec = static_cast<suseconds_t>(std::chrono::duration_cast<std::chrono::microseconds>(timeout - seconds).count());
|
|
if (setsockopt(socketid, SOL_SOCKET, SO_RCVTIMEO, &value, sizeof(value)) == -1) {
|
|
throw std::runtime_error(std::string("Could not set the receive timeout: ") + std::strerror(errno));
|
|
}
|
|
}
|
|
|
|
void ClientTCP::Send(const void* buffer, std::uint32_t size) const {
|
|
// send() is free to accept less than it was offered, and does so
|
|
// routinely once a buffer outgrows the socket's send buffer — a
|
|
// multi-megabyte HTTP body, say. Loop until it is all handed over.
|
|
const char* data = reinterpret_cast<const char*>(buffer);
|
|
std::uint32_t sent = 0;
|
|
while (sent < size) {
|
|
const auto status = send(socketid, data + sent, size - sent, MSG_NOSIGNAL);
|
|
if (status == 0) {
|
|
throw SocketClosedException();
|
|
} else if (status < 0) {
|
|
if (errno == EINTR) continue;
|
|
const int failure = errno;
|
|
throw SocketErrorException(SocketErrorKind::System, failure, std::strerror(failure));
|
|
}
|
|
sent += static_cast<std::uint32_t>(status);
|
|
}
|
|
}
|
|
std::vector<char> ClientTCP::RecieveSync(std::uint32_t bufferSize) const {
|
|
// recv's 0 means a closed peer, but not for a receive of no bytes.
|
|
if (bufferSize == 0) return {};
|
|
std::vector<char> buffer(bufferSize);
|
|
const ssize_t read = RecieveRetrying(socketid, buffer.data(), bufferSize);
|
|
if (read < 0) ThrowRecieveError();
|
|
if (read == 0) throw SocketClosedException();
|
|
buffer.resize(static_cast<std::size_t>(read));
|
|
return buffer;
|
|
}
|
|
std::uint32_t ClientTCP::RecieveSync(std::uint32_t bufferSize, void* buffer) const {
|
|
if (bufferSize == 0) return 0;
|
|
const ssize_t read = RecieveRetrying(socketid, buffer, bufferSize);
|
|
if (read < 0) ThrowRecieveError();
|
|
if (read == 0) throw SocketClosedException();
|
|
return static_cast<std::uint32_t>(read);
|
|
}
|
|
|
|
std::vector<char> ClientTCP::RecieveSync() const {
|
|
int count = 0;
|
|
ioctl(socketid, FIONREAD, &count);
|
|
if (count <= 0) {
|
|
count = 1024;
|
|
}
|
|
std::vector<char> buffer(static_cast<std::size_t>(count));
|
|
const ssize_t read = RecieveRetrying(socketid, buffer.data(), buffer.size());
|
|
if (read < 0) ThrowRecieveError();
|
|
if (read == 0) throw SocketClosedException();
|
|
buffer.resize(static_cast<std::size_t>(read));
|
|
return buffer;
|
|
}
|
|
|
|
std::vector<char> ClientTCP::RecieveUntilCloseSync() const {
|
|
std::vector<char> buffer;
|
|
while (true) {
|
|
int count = 0;
|
|
ioctl(socketid, FIONREAD, &count);
|
|
if (count <= 0) {
|
|
count = 1024;
|
|
}
|
|
const std::size_t oldSize = buffer.size();
|
|
buffer.resize(oldSize + static_cast<std::size_t>(count));
|
|
const ssize_t read = RecieveRetrying(socketid, buffer.data() + oldSize, static_cast<std::size_t>(count));
|
|
if (read < 0) ThrowRecieveError();
|
|
buffer.resize(oldSize + static_cast<std::size_t>(read));
|
|
if (read == 0) {
|
|
// A peer that closes before sending anything is reported the
|
|
// way the other receives report it.
|
|
if (buffer.empty()) throw SocketClosedException();
|
|
return buffer;
|
|
}
|
|
}
|
|
}
|
|
|
|
std::vector<char> ClientTCP::RecieveUntilFullSync(std::uint32_t bufferSize) const {
|
|
std::vector<char> buffer(bufferSize);
|
|
std::size_t read = 0;
|
|
while (read < bufferSize) {
|
|
const ssize_t newRead = RecieveRetrying(socketid, buffer.data() + read, bufferSize - read);
|
|
if (newRead < 0) ThrowRecieveError();
|
|
if (newRead == 0) throw SocketClosedException();
|
|
read += static_cast<std::size_t>(newRead);
|
|
}
|
|
return buffer;
|
|
}
|
|
|
|
namespace {
|
|
// Runs `receive` on the ThreadPool and hands its result to `onReceived`,
|
|
// or what it threw to `onError` (dropped without one): a pool task must
|
|
// not let an exception escape, or it ends the program. The callback runs
|
|
// outside the try, so its own exceptions aren't taken for a failed
|
|
// receive.
|
|
template <typename Result>
|
|
void EnqueueReceive(std::function<Result()> receive, std::function<void(Result)> onReceived, std::function<void(std::exception_ptr)> onError) {
|
|
ThreadPool::Enqueue([receive = std::move(receive), onReceived = std::move(onReceived), onError = std::move(onError)]() {
|
|
std::optional<Result> result;
|
|
try {
|
|
result = receive();
|
|
} catch (...) {
|
|
if (onError) onError(std::current_exception());
|
|
return;
|
|
}
|
|
onReceived(std::move(*result));
|
|
});
|
|
}
|
|
}
|
|
|
|
void ClientTCP::RecieveAsync(std::uint32_t bufferSize, std::function<void(std::vector<char>)> recieveCallback, std::function<void(std::exception_ptr)> onError) const {
|
|
EnqueueReceive<std::vector<char>>([this, bufferSize] { return RecieveSync(bufferSize); }, std::move(recieveCallback), std::move(onError));
|
|
}
|
|
|
|
void ClientTCP::RecieveAsync(std::uint32_t bufferSize, std::function<void(std::uint32_t)> recieveCallback, char* buffer, std::function<void(std::exception_ptr)> onError) const {
|
|
EnqueueReceive<std::uint32_t>([this, bufferSize, buffer] { return RecieveSync(bufferSize, buffer); }, std::move(recieveCallback), std::move(onError));
|
|
}
|
|
|
|
void ClientTCP::RecieveUntilFullAsync(std::uint32_t bufferSize, std::function<void(std::vector<char>)> recieveCallback, std::function<void(std::exception_ptr)> onError) const {
|
|
EnqueueReceive<std::vector<char>>([this, bufferSize] { return RecieveUntilFullSync(bufferSize); }, std::move(recieveCallback), std::move(onError));
|
|
}
|
|
|
|
void ClientTCP::RecieveAsync(std::function<void(std::vector<char>)> recieveCallback, std::function<void(std::exception_ptr)> onError) const {
|
|
EnqueueReceive<std::vector<char>>([this] { return RecieveSync(); }, std::move(recieveCallback), std::move(onError));
|
|
}
|
|
|
|
void ClientTCP::RecieveUntilCloseAsync(std::function<void(std::vector<char>)> recieveCallback, std::function<void(std::exception_ptr)> onError) const {
|
|
EnqueueReceive<std::vector<char>>([this] { return RecieveUntilCloseSync(); }, std::move(recieveCallback), std::move(onError));
|
|
}
|