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:
Jorijn van der Graaf 2026-10-20 19:07:29 +02:00
commit f683216850
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
14 changed files with 1216 additions and 382 deletions

View 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);
}
}