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().
This commit is contained in:
Jorijn van der Graaf 2026-10-31 20:33:37 +01:00
commit e86c3b70a0
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
9 changed files with 107 additions and 63 deletions

View file

@ -92,7 +92,7 @@ int main() {
try {
ClientTCP client(*outside, port, 2s);
} catch (const SocketErrorException& e) {
refused = e.code == ECONNREFUSED;
refused = e.phase == SocketPhase::Connect && e.code() == std::errc::connection_refused;
} catch (const std::exception&) {
}
Check(refused, std::format("through {} it is refused: it listens on 127.0.0.1 alone", *outside));

View file

@ -140,7 +140,7 @@ int main() {
try {
ClientTCP client("localhost", closedPort, 2s);
} catch (const SocketErrorException& e) {
refused = e.kind == SocketErrorKind::System && e.code == ECONNREFUSED;
refused = e.phase == SocketPhase::Connect && e.code() == std::errc::connection_refused;
} catch (const std::exception&) {
}
Check(refused, "when no address answers, the last failure is thrown, with its errno");

View file

@ -1,13 +1,15 @@
//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.
// A failed resolve or connect throws SocketErrorException, a std::system_error
// whose code() carries what it failed with: the errno for a connect, under
// std::generic_category(); the getaddrinfo result for a name, under
// ResolverCategory(), whose text is gai_strerror's. `phase` says which step
// failed. Before, both were a bare std::runtime_error, so a caller that
// reports the cause in its own words — "[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>
@ -54,8 +56,11 @@ int main() {
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(e.phase == SocketPhase::Connect, "a refused connect failed in the connect phase");
Check(e.code().category() == std::generic_category(), "its code is an errno");
Check(e.code().value() == ECONNREFUSED, std::format("it carries ECONNREFUSED (got {})", e.code().value()));
Check(e.code() == std::errc::connection_refused, "and compares with std::errc::connection_refused");
Check(e.code().message() == std::strerror(ECONNREFUSED), std::format("its code's text is strerror's (got '{}')", e.code().message()));
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()));
}
@ -64,12 +69,14 @@ int main() {
{
bool caught = false;
bool system = false;
try {
ClientTCP client("127.0.0.1", ClosedPort(), 2s);
} catch (const std::runtime_error&) {
caught = true;
} catch (const std::system_error& e) {
system = e.code() == std::errc::connection_refused;
caught = dynamic_cast<const std::runtime_error*>(&e) != nullptr;
}
Check(caught, "it is still caught as std::runtime_error");
Check(caught && system, "it is caught as std::system_error, and is still a std::runtime_error");
}
{
@ -80,10 +87,12 @@ int main() {
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(e.phase == SocketPhase::Resolve, "a name that does not resolve failed in the resolve phase");
Check(e.code().category() == ResolverCategory(), "its code is in the resolver category");
Check(e.code().value() != 0, std::format("it carries the getaddrinfo code (got {})", e.code().value()));
Check(e.code().message() == gai_strerror(e.code().value()), std::format("the category's text is gai_strerror's (got '{}')", e.code().message()));
Check(std::string_view(e.what()) == std::string("Could not resolve host 'no-such-host.invalid': ") + gai_strerror(e.code().value()),
std::format("its message names the host, the code's text after it (got '{}')", e.what()));
}
Check(thrown, "an unresolvable host throws SocketErrorException");
}

View file

@ -1,12 +1,12 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A send or a receive that fails throws SocketErrorException with its errno,
// as a failed connect does: a caller that reports the cause in its own words
// — "the connection broke ([Errno 104] Connection reset by peer)" — needs the
// code, and the message alone is the strerror text a send and a receive
// always threw. A receive timeout is still SocketTimeoutException and a
// clean close SocketClosedException.
// A send or a receive that fails throws SocketErrorException with its errno
// in code() and the phase that failed, as a failed connect does: a caller
// that reports the cause in its own words — "the connection broke ([Errno
// 104] Connection reset by peer)" — needs the code, and the message alone is
// the strerror text a send and a receive always threw. A receive timeout is
// still SocketTimeoutException and a clean close SocketClosedException.
#include <arpa/inet.h>
#include <netinet/in.h>
@ -73,8 +73,8 @@ int main() {
client.RecieveSync(16);
} catch (const SocketErrorException& e) {
thrown = true;
Check(e.kind == SocketErrorKind::System, "a reset connection fails the receive as a system error");
Check(e.code == ECONNRESET, std::format("it carries ECONNRESET (got {})", e.code));
Check(e.phase == SocketPhase::Receive, "a reset connection fails in the receive phase");
Check(e.code() == std::errc::connection_reset && e.code().value() == ECONNRESET, std::format("it carries ECONNRESET (got {})", e.code().value()));
Check(std::string_view(e.what()) == std::strerror(ECONNRESET), std::format("its message is the strerror text (got '{}')", e.what()));
} catch (const std::exception& e) {
Check(false, std::format("the receive threw something else: {}", e.what()));
@ -90,12 +90,14 @@ int main() {
// kernel answers the next one with EPIPE or ECONNRESET.
std::int32_t code = 0;
bool runtime = false;
bool sending = false;
const std::string data(4096, 'x');
for (int i = 0; i < 50 && code == 0; ++i) {
try {
client.Send(data.data(), static_cast<std::uint32_t>(data.size()));
} catch (const SocketErrorException& e) {
code = e.code;
code = e.code().value();
sending = e.phase == SocketPhase::Send && e.code().category() == std::generic_category();
runtime = dynamic_cast<const std::runtime_error*>(&e) != nullptr;
} catch (const std::exception&) {
break;
@ -103,7 +105,7 @@ int main() {
std::this_thread::sleep_for(10ms);
}
Check(code == EPIPE || code == ECONNRESET, std::format("a send into a reset connection throws SocketErrorException with EPIPE or ECONNRESET (got {})", code));
Check(runtime, "and it is still a std::runtime_error");
Check(sending && runtime, "in the send phase, an errno, and still a std::runtime_error");
}
std::println("{}", failures == 0 ? "all passed" : std::format("{} failed", failures));