feat: TCP-only builds; TCP options and timeouts; receives that report and keep what arrived
- --no-quic leaves QUIC, HTTP/3 and WebTransport out of the build, and with
them msquic: a consumer that only needs TCP builds no msquic. The flag
isn't passed on to dependencies.
- --local takes Crafter.Thread from the sibling checkout, as the other
Crafter projects do.
- ClientTCP gains SetNoDelay, SetReceiveTimeout, a connect timeout and
SocketTimeoutException.
- TCP receives return the bytes that arrived:
- RecieveSync(n) returned the whole buffer when fewer had come;
- RecieveUntilCloseSync truncated what it had read;
- a signal (EINTR) during a receive threw where it should wait on;
- RecieveSync(size, buffer) returns the count and throws like the others;
- a receive of no bytes returns at once: recv's 0 for it isn't a close.
- Every TCP *Async receive takes an onError(std::exception_ptr): an
exception escaping a ThreadPool task ended the program. An empty onError
drops the error.
- QUIC streams:
- async receives deliver an empty chunk on close, as the browser build does;
- RecieveUntilFullAsync gives what arrived on a stream that closes early;
- RecieveUntilFullSync takes all it asked for or nothing, under one hold
of the lock, so what did arrive stays for the next receive and no
other receive can take bytes from among it;
- SendAsync's callback hears whether the send went out (bool sent): native,
browser, the JS bridge and the example.
- msquic is pinned to v2.6.3 and built against the system OpenSSL
(QUIC_TLS_LIB=openssl, 3.5 or newer). Its bundled quictls read the system's
openssl.cnf, and the Flatpak runtime's (a PKCS#11 engine, with
config_diagnostics on) made it fail to initialise. The README says to use
--no-quic where the distro's OpenSSL is older.
- Tests now register on any Linux triple: they never ran under the Flatpak's
x86_64-unknown-linux-gnu. New: ShouldBuildWithoutQUIC, ShouldConfigureTCP,
ShouldRecieveTCP, ShouldReportClosedQUICStream.
Breaking: SendAsync's callback takes a bool, the TCP *Async receives take
an onError, and RecieveSync(size, buffer) returns the count.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
05f7652770
commit
f683216850
14 changed files with 1216 additions and 382 deletions
62
tests/ShouldBuildWithoutQUIC/main.cpp
Normal file
62
tests/ShouldBuildWithoutQUIC/main.cpp
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// A build with --no-quic leaves out the QUIC, HTTP/3 and WebTransport
|
||||
// partitions, and msquic with them, for consumers that only need TCP or
|
||||
// HTTP/1.1: building msquic means cloning it with its submodules and running
|
||||
// its CMake build. This test is only built that way. It checks that msquic is
|
||||
// not in the process and that TCP and HTTP/1.1 still work.
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <cstdlib>
|
||||
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
std::ifstream maps("/proc/self/maps");
|
||||
const std::string mapped((std::istreambuf_iterator<char>(maps)), std::istreambuf_iterator<char>());
|
||||
Check(!mapped.empty() && mapped.find("msquic") == std::string::npos, "libmsquic is not loaded");
|
||||
|
||||
const int listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
sockaddr_in address {};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
bind(listener, reinterpret_cast<sockaddr*>(&address), sizeof(address));
|
||||
listen(listener, 1);
|
||||
socklen_t length = sizeof(address);
|
||||
getsockname(listener, reinterpret_cast<sockaddr*>(&address), &length);
|
||||
|
||||
ClientTCP client("127.0.0.1", ntohs(address.sin_port));
|
||||
const int peer = accept(listener, nullptr, nullptr);
|
||||
client.Send("ping", 4);
|
||||
char echoed[4] {};
|
||||
recv(peer, echoed, sizeof(echoed), MSG_WAITALL);
|
||||
Check(std::string_view(echoed, 4) == "ping", "a ClientTCP still connects and sends over loopback");
|
||||
|
||||
// HTTP/1.1 stays in this build.
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
|
||||
const std::string_view wire = "GET / HTTP/1.1\r\nHost: h\r\n\r\n";
|
||||
parser.Feed(wire.data(), wire.size());
|
||||
Check(parser.Complete() && parser.TakeRequest().path == "/", "the HTTP/1.1 parser is still there");
|
||||
|
||||
close(peer);
|
||||
close(listener);
|
||||
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
150
tests/ShouldConfigureTCP/main.cpp
Normal file
150
tests/ShouldConfigureTCP/main.cpp
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// ClientTCP's socket options:
|
||||
// - SetNoDelay turns Nagle's algorithm off, so a small message goes out at
|
||||
// once instead of waiting for the ACK of the one before it, which a client
|
||||
// streaming small frames needs.
|
||||
// - A receive timeout makes a receive that waits too long throw
|
||||
// SocketTimeoutException.
|
||||
// - A connect timeout does the same for a connect nobody answers, where
|
||||
// connect() would otherwise sit through the kernel's SYN retries, about
|
||||
// two minutes.
|
||||
// Before, a ClientTCP had none of these, so a stalled peer blocked the
|
||||
// reading thread for good.
|
||||
//
|
||||
// Everything runs over loopback. The unanswered connect goes to a listener
|
||||
// whose accept queue is full: Linux drops the SYNs of further connects to it.
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#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 127.0.0.1, on a port the kernel picks.
|
||||
struct LoopbackListener {
|
||||
int fd = -1;
|
||||
std::uint16_t port = 0;
|
||||
|
||||
explicit LoopbackListener(int backlog) {
|
||||
fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
sockaddr_in address {};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
bind(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address));
|
||||
listen(fd, backlog);
|
||||
socklen_t length = sizeof(address);
|
||||
getsockname(fd, reinterpret_cast<sockaddr*>(&address), &length);
|
||||
port = ntohs(address.sin_port);
|
||||
}
|
||||
~LoopbackListener() { close(fd); }
|
||||
};
|
||||
|
||||
long long Milliseconds(std::chrono::steady_clock::duration duration) {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(duration).count();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// ── SetNoDelay ───────────────────────────────────────────────────────────
|
||||
{
|
||||
LoopbackListener listener(4);
|
||||
ClientTCP client("127.0.0.1", listener.port);
|
||||
auto noDelay = [&] {
|
||||
int value = 0;
|
||||
socklen_t size = sizeof(value);
|
||||
getsockopt(client.socketid, IPPROTO_TCP, TCP_NODELAY, &value, &size);
|
||||
return value != 0;
|
||||
};
|
||||
Check(!noDelay(), "a new connection uses Nagle's algorithm");
|
||||
client.SetNoDelay(true);
|
||||
Check(noDelay(), "SetNoDelay(true) turns it off");
|
||||
client.SetNoDelay(false);
|
||||
Check(!noDelay(), "SetNoDelay(false) turns it back on");
|
||||
}
|
||||
|
||||
// ── Receive timeout ──────────────────────────────────────────────────────
|
||||
{
|
||||
LoopbackListener listener(4);
|
||||
ClientTCP client("127.0.0.1", listener.port);
|
||||
const int peer = accept(listener.fd, nullptr, nullptr);
|
||||
client.SetReceiveTimeout(150ms);
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
bool timedOut = false;
|
||||
try {
|
||||
client.RecieveUntilFullSync(16);
|
||||
} catch (const SocketTimeoutException&) {
|
||||
timedOut = true;
|
||||
}
|
||||
const auto waited = std::chrono::steady_clock::now() - start;
|
||||
Check(timedOut, "a receive from a peer that sends nothing throws SocketTimeoutException");
|
||||
Check(waited >= 140ms && waited < 1s, std::format("after about the timeout ({} ms)", Milliseconds(waited)));
|
||||
|
||||
send(peer, "hello", 5, 0);
|
||||
const std::vector<char> received = client.RecieveUntilFullSync(5);
|
||||
Check(std::string_view(received.data(), received.size()) == "hello",
|
||||
"the connection still receives what arrives after a timeout");
|
||||
close(peer);
|
||||
}
|
||||
|
||||
// ── Connect timeout ──────────────────────────────────────────────────────
|
||||
{
|
||||
// Connects queue up until the accept queue is full, which with a
|
||||
// backlog of 0 is after one or two; the next one goes unanswered.
|
||||
LoopbackListener full(0);
|
||||
std::vector<ClientTCP> queued;
|
||||
bool timedOut = false;
|
||||
std::chrono::steady_clock::duration waited {};
|
||||
for (int i = 0; i < 8 && !timedOut; ++i) {
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
try {
|
||||
queued.emplace_back("127.0.0.1", full.port, 200ms);
|
||||
} catch (const SocketTimeoutException&) {
|
||||
timedOut = true;
|
||||
waited = std::chrono::steady_clock::now() - start;
|
||||
}
|
||||
}
|
||||
Check(timedOut, std::format("a connect nobody answers throws SocketTimeoutException (after {} queued)", queued.size()));
|
||||
Check(waited >= 190ms && waited < 1s, std::format("after about the timeout ({} ms)", Milliseconds(waited)));
|
||||
}
|
||||
|
||||
// ── A refused connect still fails at once ────────────────────────────────
|
||||
{
|
||||
std::uint16_t closedPort = 0;
|
||||
{
|
||||
LoopbackListener gone(1);
|
||||
closedPort = gone.port;
|
||||
}
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
bool refused = false;
|
||||
try {
|
||||
ClientTCP client("127.0.0.1", closedPort, 2s);
|
||||
} catch (const SocketTimeoutException&) {
|
||||
} catch (const std::runtime_error&) {
|
||||
refused = true;
|
||||
}
|
||||
Check(refused && std::chrono::steady_clock::now() - start < 1s,
|
||||
"a connect to a closed port fails at once, not after the timeout");
|
||||
}
|
||||
|
||||
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
272
tests/ShouldRecieveTCP/main.cpp
Normal file
272
tests/ShouldRecieveTCP/main.cpp
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// ClientTCP's receive calls return exactly the bytes that arrived:
|
||||
// - RecieveSync(bufferSize) returned the whole buffer when fewer bytes had
|
||||
// arrived, so the caller read garbage after them.
|
||||
// - RecieveUntilCloseSync kept the unused end of its first buffer, and then
|
||||
// cut everything it had read down to the size of the last read.
|
||||
// - A signal arriving while a receive waited made it throw "Interrupted
|
||||
// system call", where the receive should simply wait on.
|
||||
// - RecieveSync(bufferSize, buffer) returned recv's -1 or 0 where the other
|
||||
// receives throw: it now returns the count and throws the same way. A
|
||||
// receive of no bytes returns at once: recv's 0 for it isn't a close.
|
||||
// - The *Async receives let a closed peer's exception escape the thread
|
||||
// pool's task, which ended the whole program; it now goes to onError,
|
||||
// and without an onError it's dropped.
|
||||
//
|
||||
// Everything runs over loopback.
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <cstdlib>
|
||||
|
||||
import Crafter.Network;
|
||||
import Crafter.Thread;
|
||||
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 connected pair: a ClientTCP and the socket the listener accepted for it.
|
||||
struct Connection {
|
||||
int listener = -1;
|
||||
int peer = -1;
|
||||
std::optional<ClientTCP> client;
|
||||
|
||||
Connection() {
|
||||
listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
sockaddr_in address {};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
bind(listener, reinterpret_cast<sockaddr*>(&address), sizeof(address));
|
||||
listen(listener, 1);
|
||||
socklen_t length = sizeof(address);
|
||||
getsockname(listener, reinterpret_cast<sockaddr*>(&address), &length);
|
||||
client.emplace("127.0.0.1", ntohs(address.sin_port));
|
||||
peer = accept(listener, nullptr, nullptr);
|
||||
}
|
||||
~Connection() {
|
||||
if (peer != -1) close(peer);
|
||||
close(listener);
|
||||
}
|
||||
void Send(std::string_view bytes) const {
|
||||
send(peer, bytes.data(), bytes.size(), 0);
|
||||
}
|
||||
void ClosePeer() {
|
||||
close(peer);
|
||||
peer = -1;
|
||||
}
|
||||
};
|
||||
|
||||
std::string_view Text(const std::vector<char>& bytes) {
|
||||
return std::string_view(bytes.data(), bytes.size());
|
||||
}
|
||||
|
||||
// What a receive threw, by kind.
|
||||
std::string Kind(std::exception_ptr error) {
|
||||
try {
|
||||
std::rethrow_exception(error);
|
||||
} catch (const SocketClosedException&) {
|
||||
return "closed";
|
||||
} catch (const SocketTimeoutException&) {
|
||||
return "timeout";
|
||||
} catch (const std::exception& e) {
|
||||
return std::string("other: ") + e.what();
|
||||
}
|
||||
}
|
||||
|
||||
// An async receive's outcome. Shared with the callbacks, so it outlives
|
||||
// whichever thread sets it.
|
||||
struct Outcome {
|
||||
std::mutex mutex;
|
||||
std::condition_variable done;
|
||||
std::optional<std::string> value;
|
||||
|
||||
void Set(std::string what) {
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
value = std::move(what);
|
||||
}
|
||||
done.notify_all();
|
||||
}
|
||||
std::string Wait() {
|
||||
std::unique_lock lock(mutex);
|
||||
done.wait_for(lock, 2s, [&] { return value.has_value(); });
|
||||
return value.value_or("nothing within 2 s");
|
||||
}
|
||||
};
|
||||
|
||||
void IgnoreSignal(int) {}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// ── A short read returns only what arrived ───────────────────────────────
|
||||
{
|
||||
Connection connection;
|
||||
connection.Send("hello");
|
||||
std::this_thread::sleep_for(20ms);
|
||||
const std::vector<char> received = connection.client->RecieveSync(64);
|
||||
Check(Text(received) == "hello", std::format("RecieveSync(64) returns the 5 bytes that arrived (got {})", received.size()));
|
||||
}
|
||||
|
||||
// ── Until close: every chunk, in order, nothing more ─────────────────────
|
||||
{
|
||||
Connection connection;
|
||||
std::thread sender([&] {
|
||||
connection.Send("abc");
|
||||
std::this_thread::sleep_for(30ms);
|
||||
connection.Send("defgh");
|
||||
std::this_thread::sleep_for(30ms);
|
||||
connection.Send("ij");
|
||||
std::this_thread::sleep_for(30ms);
|
||||
connection.ClosePeer();
|
||||
});
|
||||
const std::vector<char> received = connection.client->RecieveUntilCloseSync();
|
||||
sender.join();
|
||||
Check(Text(received) == "abcdefghij",
|
||||
std::format("RecieveUntilCloseSync returns the three chunks it was sent ({} bytes)", received.size()));
|
||||
}
|
||||
|
||||
// ── A signal doesn't break a waiting receive ─────────────────────────────
|
||||
{
|
||||
// No SA_RESTART, so the signal makes recv return EINTR.
|
||||
struct sigaction action {};
|
||||
action.sa_handler = IgnoreSignal;
|
||||
sigemptyset(&action.sa_mask);
|
||||
sigaction(SIGUSR1, &action, nullptr);
|
||||
|
||||
Connection connection;
|
||||
std::string outcome;
|
||||
std::thread reader([&] {
|
||||
try {
|
||||
outcome = Text(connection.client->RecieveUntilFullSync(4));
|
||||
} catch (const std::exception& e) {
|
||||
outcome = std::string("threw: ") + e.what();
|
||||
}
|
||||
});
|
||||
std::this_thread::sleep_for(50ms);
|
||||
pthread_kill(reader.native_handle(), SIGUSR1);
|
||||
std::this_thread::sleep_for(50ms);
|
||||
connection.Send("data");
|
||||
reader.join();
|
||||
Check(outcome == "data", std::format("a receive interrupted by a signal waits on for its data ({})", outcome));
|
||||
}
|
||||
|
||||
// ── Into a buffer: the count, and the same exceptions ────────────────────
|
||||
{
|
||||
Connection connection;
|
||||
connection.Send("hello");
|
||||
std::this_thread::sleep_for(20ms);
|
||||
std::array<char, 64> buffer {};
|
||||
const std::uint32_t got = connection.client->RecieveSync(buffer.size(), buffer.data());
|
||||
Check(got == 5 && std::string_view(buffer.data(), got) == "hello", std::format("RecieveSync(64, buffer) returns the 5 bytes that arrived ({})", got));
|
||||
connection.ClosePeer();
|
||||
std::string closed;
|
||||
try {
|
||||
(void)connection.client->RecieveSync(buffer.size(), buffer.data());
|
||||
} catch (...) {
|
||||
closed = Kind(std::current_exception());
|
||||
}
|
||||
Check(closed == "closed", std::format("and throws SocketClosedException once the peer has closed ({})", closed.empty() ? "returned" : closed));
|
||||
}
|
||||
{
|
||||
Connection connection;
|
||||
std::array<char, 16> buffer {};
|
||||
std::string outcome = "returned";
|
||||
try {
|
||||
const bool empty = connection.client->RecieveSync(0).empty() && connection.client->RecieveSync(0, buffer.data()) == 0;
|
||||
if (!empty) outcome = "bytes";
|
||||
} catch (...) {
|
||||
outcome = Kind(std::current_exception());
|
||||
}
|
||||
Check(outcome == "returned", std::format("a receive of no bytes returns nothing on a live connection ({})", outcome));
|
||||
}
|
||||
{
|
||||
Connection connection;
|
||||
connection.client->SetReceiveTimeout(50ms);
|
||||
std::array<char, 16> buffer {};
|
||||
std::string timedOut;
|
||||
try {
|
||||
(void)connection.client->RecieveSync(buffer.size(), buffer.data());
|
||||
} catch (...) {
|
||||
timedOut = Kind(std::current_exception());
|
||||
}
|
||||
Check(timedOut == "timeout", std::format("and SocketTimeoutException when nothing comes in time ({})", timedOut.empty() ? "returned" : timedOut));
|
||||
}
|
||||
|
||||
// ── Async: data to the callback, failures to onError ─────────────────────
|
||||
ThreadPool::Start(3);
|
||||
{
|
||||
Connection connection;
|
||||
connection.Send("hi");
|
||||
auto outcome = std::make_shared<Outcome>();
|
||||
connection.client->RecieveAsync(64, [outcome](std::vector<char> data) { outcome->Set(std::string(Text(data))); },
|
||||
[outcome](std::exception_ptr error) { outcome->Set(Kind(error)); });
|
||||
const std::string got = outcome->Wait();
|
||||
Check(got == "hi", std::format("RecieveAsync hands the data to its callback ({})", got));
|
||||
}
|
||||
{
|
||||
Connection connection;
|
||||
connection.ClosePeer();
|
||||
auto outcome = std::make_shared<Outcome>();
|
||||
connection.client->RecieveAsync(64, [outcome](std::vector<char>) { outcome->Set("data"); },
|
||||
[outcome](std::exception_ptr error) { outcome->Set(Kind(error)); });
|
||||
const std::string got = outcome->Wait();
|
||||
Check(got == "closed", std::format("and a closed peer to onError, the program carrying on ({})", got));
|
||||
}
|
||||
{
|
||||
Connection connection;
|
||||
connection.Send("ab");
|
||||
connection.ClosePeer();
|
||||
auto outcome = std::make_shared<Outcome>();
|
||||
connection.client->RecieveUntilFullAsync(4, [outcome](std::vector<char>) { outcome->Set("data"); },
|
||||
[outcome](std::exception_ptr error) { outcome->Set(Kind(error)); });
|
||||
const std::string got = outcome->Wait();
|
||||
Check(got == "closed", std::format("RecieveUntilFullAsync reports a peer that closes early to onError ({})", got));
|
||||
}
|
||||
{
|
||||
Connection connection;
|
||||
connection.Send("xyz");
|
||||
std::array<char, 16> buffer {};
|
||||
auto outcome = std::make_shared<Outcome>();
|
||||
connection.client->RecieveAsync(buffer.size(), [outcome, &buffer](std::uint32_t count) { outcome->Set(std::string(buffer.data(), count)); },
|
||||
buffer.data(), [outcome](std::exception_ptr error) { outcome->Set(Kind(error)); });
|
||||
const std::string got = outcome->Wait();
|
||||
Check(got == "xyz", std::format("the buffer RecieveAsync hands its callback the count ({})", got));
|
||||
}
|
||||
{
|
||||
Connection connection;
|
||||
connection.ClosePeer();
|
||||
auto outcome = std::make_shared<Outcome>();
|
||||
connection.client->RecieveUntilCloseAsync([outcome](std::vector<char>) { outcome->Set("data"); },
|
||||
[outcome](std::exception_ptr error) { outcome->Set(Kind(error)); });
|
||||
const std::string got = outcome->Wait();
|
||||
Check(got == "closed", std::format("RecieveUntilCloseAsync on a peer that sent nothing goes to onError ({})", got));
|
||||
}
|
||||
{
|
||||
Connection connection;
|
||||
connection.ClosePeer();
|
||||
connection.client->RecieveAsync(64, [](std::vector<char>) {}, {});
|
||||
// Calling the empty onError ended the program from the pool's task.
|
||||
std::this_thread::sleep_for(300ms);
|
||||
Check(true, "without an onError, a closed peer's exception is dropped, the program carrying on");
|
||||
}
|
||||
ThreadPool::Stop();
|
||||
|
||||
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
135
tests/ShouldReportClosedQUICStream/main.cpp
Normal file
135
tests/ShouldReportClosedQUICStream/main.cpp
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// A QUIC stream's async calls report a closed stream to their callbacks, the
|
||||
// same way on native as in the browser build, which has no exceptions:
|
||||
// - a receive on a closed stream gets an empty chunk. Natively it used to
|
||||
// let QUICClosedException escape the ThreadPool task, which ended the
|
||||
// program;
|
||||
// - RecieveUntilFullAsync on a stream that closes early gets what arrived;
|
||||
// RecieveUntilFullSync still throws, and leaves those bytes for the next
|
||||
// receive instead of dropping them;
|
||||
// - SendAsync tells its callback whether the stream took the buffer. It
|
||||
// used to call it the same way either way.
|
||||
// A server answers a stream with "abc" and closes its side.
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
import Crafter.Network;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
// A callback's result, shared with it so it outlives whichever thread sets it.
|
||||
template <typename T>
|
||||
struct Outcome {
|
||||
std::mutex mutex;
|
||||
std::condition_variable done;
|
||||
std::optional<T> value;
|
||||
|
||||
void Set(T result) {
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
value = std::move(result);
|
||||
}
|
||||
done.notify_all();
|
||||
}
|
||||
std::optional<T> Wait() {
|
||||
std::unique_lock lock(mutex);
|
||||
done.wait_for(lock, std::chrono::seconds(5), [&] { return value.has_value(); });
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
std::string Text(const std::vector<char>& bytes) {
|
||||
return std::string(bytes.begin(), bytes.end());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
ThreadPool::Start();
|
||||
constexpr std::uint16_t port = 9188;
|
||||
constexpr std::string_view alpn = "crafter/test-closed-stream";
|
||||
|
||||
QUICServerCredentials serverCreds;
|
||||
serverCreds.selfSigned = true;
|
||||
ListenerQUIC listener(port, std::string(alpn), serverCreds, [&](ClientQUIC* peer) {
|
||||
peer->OnStream([](QUICStream stream) {
|
||||
try {
|
||||
(void)stream.RecieveSync();
|
||||
stream.SendSync("abc", 3, true);
|
||||
} catch (...) {}
|
||||
});
|
||||
});
|
||||
listener.ListenAsyncAsync();
|
||||
|
||||
try {
|
||||
// ── A stream that was never opened ───────────────────────────────────
|
||||
{
|
||||
QUICStream closed;
|
||||
auto received = std::make_shared<Outcome<std::string>>();
|
||||
closed.RecieveAsync([received](std::vector<char> chunk) { received->Set(Text(chunk)); });
|
||||
const std::optional<std::string> got = received->Wait();
|
||||
Check(got && got->empty(), "a receive on a closed stream gets an empty chunk, and the program carries on");
|
||||
auto sent = std::make_shared<Outcome<bool>>();
|
||||
closed.SendAsync("x", 1, false, [sent](bool ok) { sent->Set(ok); });
|
||||
const std::optional<bool> ok = sent->Wait();
|
||||
Check(ok && !*ok, "a send on it tells its callback the stream didn't take it");
|
||||
}
|
||||
|
||||
QUICClientCredentials clientCreds;
|
||||
clientCreds.insecureNoServerValidation = true;
|
||||
ClientQUIC client("localhost", port, std::string(alpn), clientCreds);
|
||||
|
||||
// ── The server answers, then closes its side ─────────────────────────
|
||||
{
|
||||
QUICStream stream = client.OpenStream();
|
||||
auto sent = std::make_shared<Outcome<bool>>();
|
||||
stream.SendAsync("hi", 2, false, [sent](bool ok) { sent->Set(ok); });
|
||||
const std::optional<bool> ok = sent->Wait();
|
||||
Check(ok && *ok, "a send the stream takes tells its callback so");
|
||||
|
||||
auto first = std::make_shared<Outcome<std::string>>();
|
||||
stream.RecieveUntilFullAsync(10, [first](std::vector<char> chunk) { first->Set(Text(chunk)); });
|
||||
const std::optional<std::string> got = first->Wait();
|
||||
Check(got == "abc", std::format("RecieveUntilFullAsync on a stream that closes early gets what arrived ({})", got.value_or("nothing")));
|
||||
|
||||
auto after = std::make_shared<Outcome<std::string>>();
|
||||
stream.RecieveAsync([after](std::vector<char> chunk) { after->Set(Text(chunk)); });
|
||||
const std::optional<std::string> next = after->Wait();
|
||||
Check(next && next->empty(), "a receive after the close gets an empty chunk");
|
||||
}
|
||||
{
|
||||
QUICStream stream = client.OpenStream();
|
||||
stream.SendSync("hi", 2, false);
|
||||
bool threw = false;
|
||||
try {
|
||||
(void)stream.RecieveUntilFullSync(10);
|
||||
} catch (const QUICClosedException&) {
|
||||
threw = true;
|
||||
}
|
||||
const std::vector<char> left = stream.RecieveUntilCloseSync();
|
||||
Check(threw && Text(left) == "abc",
|
||||
std::format("RecieveUntilFullSync throws on the early close and leaves the bytes for the next receive ({})", Text(left)));
|
||||
}
|
||||
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||
// As the other QUIC tests: skip the static teardown, which waits for
|
||||
// the accepted peer's idle timeout. _Exit doesn't flush.
|
||||
std::fflush(stdout);
|
||||
std::_Exit(failures == 0 ? 0 : 1);
|
||||
} catch (const std::exception& e) {
|
||||
std::println("FAIL {}", e.what());
|
||||
std::fflush(stdout);
|
||||
std::_Exit(1);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue