- --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>
284 lines
13 KiB
C++
284 lines
13 KiB
C++
//SPDX-License-Identifier: LGPL-3.0-only
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module;
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
|
#include <msquic.h>
|
|
#endif
|
|
export module Crafter.Network:ClientQUIC;
|
|
import std;
|
|
|
|
namespace Crafter {
|
|
export class ListenerQUIC;
|
|
|
|
export class QUICException : public std::runtime_error {
|
|
public:
|
|
using std::runtime_error::runtime_error;
|
|
};
|
|
|
|
export class QUICClosedException : public std::exception {
|
|
public:
|
|
const char* what() const noexcept override { return "QUIC connection closed"; }
|
|
};
|
|
|
|
// Transport knobs applied to the msquic Configuration. The defaults
|
|
// reproduce the values these were hardcoded to before they were made
|
|
// configurable, so leaving this alone keeps the previous behaviour.
|
|
//
|
|
// peerUnidiStreamCount / peerBidiStreamCount cap how many streams the
|
|
// *peer* may open toward us. msquic defaults both to 0; a server's
|
|
// HTTP/3 control + QPACK streams alone need 3, and a protocol that
|
|
// fans data out over many concurrent streams needs correspondingly
|
|
// more. Raising these costs per-stream flow-control state, so raise
|
|
// them to what a protocol actually needs rather than to a large
|
|
// round number.
|
|
//
|
|
// keepAliveIntervalMs is client-only (the listener does not set it).
|
|
// Keep it below idleTimeoutMs or the peer's idle timer will expire.
|
|
// handshakeIdleTimeoutMs bounds how long a connect attempt to an
|
|
// unreachable peer takes to fail. This one is set explicitly where it
|
|
// previously was not: ClientQUIC's constructor waits on a condition
|
|
// variable with no timeout of its own, so without a bound here a client
|
|
// dialling a dead port blocks for minutes rather than failing and
|
|
// retrying. 10s matches msquic's own documented default.
|
|
export struct QUICSettings {
|
|
std::uint32_t idleTimeoutMs = 120'000;
|
|
std::uint32_t handshakeIdleTimeoutMs = 10'000;
|
|
std::uint32_t keepAliveIntervalMs = 10'000;
|
|
std::uint16_t peerUnidiStreamCount = 16;
|
|
std::uint16_t peerBidiStreamCount = 16;
|
|
bool datagramReceiveEnabled = true;
|
|
};
|
|
|
|
// Server certificate sources. Pick one of (filePaths) or (selfSigned).
|
|
// selfSigned generates an in-memory ephemeral cert — fine for dev/LAN.
|
|
export struct QUICServerCredentials {
|
|
std::string certPath;
|
|
std::string keyPath;
|
|
bool selfSigned = false;
|
|
QUICSettings settings;
|
|
};
|
|
|
|
// Client-side credential validation. By default we require a real cert.
|
|
// insecureNoServerValidation disables peer cert checks — only for dev,
|
|
// and silently ignored in the browser build (browsers enforce their own
|
|
// certificate policy). For browser dev against a self-signed listener,
|
|
// populate serverCertificateHash with the SHA-256 of the server's DER
|
|
// certificate; on the browser it is forwarded to WebTransport's
|
|
// serverCertificateHashes option. A zeroed array means "unused" — the
|
|
// browser will then require a publicly trusted cert.
|
|
export struct QUICClientCredentials {
|
|
bool insecureNoServerValidation = false;
|
|
std::array<std::uint8_t, 32> serverCertificateHash{};
|
|
QUICSettings settings;
|
|
};
|
|
|
|
export class ClientQUIC;
|
|
|
|
// A reliable, ordered stream within a QUIC connection. May be
|
|
// bidirectional or unidirectional; for unidi streams either canSend or
|
|
// canReceive will be false depending on which side initiated. Owned by
|
|
// ClientQUIC; do not destroy directly. Obtain via ClientQUIC::OpenStream
|
|
// (optionally with unidirectional=true) or via the on-stream callback
|
|
// for inbound streams initiated by the peer.
|
|
export class QUICStream {
|
|
public:
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
|
// Underlying msquic HQUIC handle. Treated as opaque by callers.
|
|
HQUIC handle = nullptr;
|
|
#endif
|
|
|
|
// The connection that owns this stream (non-owning).
|
|
ClientQUIC* connection = nullptr;
|
|
|
|
// Direction flags. Bidi streams have both true; outgoing unidi sets
|
|
// canReceive=false; incoming unidi (peer-initiated) sets canSend=false.
|
|
bool canSend = true;
|
|
bool canReceive = true;
|
|
|
|
QUICStream();
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
|
QUICStream(HQUIC handle, ClientQUIC* connection);
|
|
#else
|
|
// Browser-only constructor: wraps a JS-side WebTransport stream
|
|
// identified by its integer handle. Used by ClientQUIC::OpenStream
|
|
// and by the incoming-stream dispatcher in the JS bridge — not
|
|
// intended for direct use.
|
|
QUICStream(std::int32_t handle, ClientQUIC* connection,
|
|
bool canSend, bool canReceive);
|
|
#endif
|
|
~QUICStream();
|
|
QUICStream(const QUICStream&) = delete;
|
|
QUICStream(QUICStream&&) noexcept;
|
|
QUICStream& operator=(QUICStream&&) noexcept;
|
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
|
// Send a buffer. If finish=true, the send-side of the stream is closed
|
|
// after the buffer is delivered (peer will see graceful shutdown).
|
|
// Blocks until msquic accepts the buffer; throws on stream/conn close.
|
|
void SendSync(const void* buffer, std::uint32_t size, bool finish = false);
|
|
|
|
// Block until at least one byte is received; returns the received
|
|
// chunk. Throws QUICClosedException once the peer has closed the
|
|
// send-side and the buffer is drained.
|
|
std::vector<char> RecieveSync();
|
|
|
|
// Read until the peer closes the send-side, accumulating all chunks.
|
|
std::vector<char> RecieveUntilCloseSync();
|
|
|
|
// Read exactly bufferSize bytes. Throws if the peer closes early,
|
|
// leaving what did arrive for the next receive.
|
|
std::vector<char> RecieveUntilFullSync(std::uint32_t bufferSize);
|
|
#endif
|
|
|
|
// Send a buffer. If finish=true, the send-side is closed after the
|
|
// buffer is delivered. onSent fires with true once the transport has
|
|
// accepted the buffer (native) or the WritableStream writer has
|
|
// resolved (browser), and with false when the stream was closed or
|
|
// the send failed. Available on both native and browser builds.
|
|
void SendAsync(const void* buffer, std::uint32_t size, bool finish,
|
|
std::function<void(bool sent)> onSent);
|
|
|
|
// Async receive variants. Dispatched on Crafter.Thread's ThreadPool
|
|
// (native) or driven by a per-stream JS reader loop (browser). A
|
|
// stream that has closed gives the callback an empty chunk, and
|
|
// RecieveUntilFullAsync on one that closes early gives what arrived:
|
|
// the browser build has no exceptions to report it with, and on the
|
|
// ThreadPool an exception would end the program.
|
|
void RecieveAsync(std::function<void(std::vector<char>)> recieveCallback);
|
|
void RecieveUntilCloseAsync(std::function<void(std::vector<char>)> recieveCallback);
|
|
void RecieveUntilFullAsync(std::uint32_t bufferSize, std::function<void(std::vector<char>)> recieveCallback);
|
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
|
// Advanced: re-inject already-consumed bytes at the front of the
|
|
// receive queue so the next Recieve* call sees them. Used by
|
|
// protocol demuxers (e.g. the WebTransport stream router in
|
|
// ListenerHTTP) that need to peek a prefix off the wire, then hand
|
|
// the stream to user code as if the prefix had never been read.
|
|
void PrependReceived(std::vector<char> bytes);
|
|
|
|
// Underlying QUIC stream id. Stable for the stream's lifetime.
|
|
// Browsers identify WT streams by the session's CONNECT stream id,
|
|
// so the server has to query and remember it at session creation
|
|
// time. Throws if the stream is not yet started.
|
|
std::uint64_t GetStreamId() const;
|
|
#endif
|
|
|
|
// Shut the stream down in both directions: our send side gracefully
|
|
// (the peer sees a FIN), the receive side abortively. Aborting the
|
|
// receive side matters — a bidirectional stream whose peer keeps its
|
|
// send side open otherwise never completes its shutdown, and so never
|
|
// releases its transport handle. Returns immediately; completion is
|
|
// asynchronous. Called by the destructor, so an explicit call is only
|
|
// needed to shut a stream down ahead of its wrapper going away.
|
|
void Stop();
|
|
|
|
private:
|
|
struct Impl;
|
|
// shared_ptr (not unique_ptr): the msquic stream callback holds its own
|
|
// ref so it can never run on a freed Impl if the wrapper is destroyed
|
|
// before SHUTDOWN_COMPLETE fires. See Crafter.Network-ClientQUIC.cpp.
|
|
std::shared_ptr<Impl> impl;
|
|
friend class ClientQUIC;
|
|
};
|
|
|
|
// A QUIC connection. On the client side, constructing one initiates the
|
|
// handshake and (on native) blocks until it succeeds, or throws on
|
|
// failure. On the server side, ListenerQUIC instantiates these for
|
|
// accepted peers.
|
|
//
|
|
// A connection multiplexes:
|
|
// - Reliable, ordered streams (open via OpenStream() / observe inbound
|
|
// via OnStream()).
|
|
// - Unreliable, unordered datagrams (SendDatagram() / OnDatagram()).
|
|
//
|
|
// Lifetime: ~ClientQUIC shuts the connection down and closes it, waiting
|
|
// (bounded — 5s) for every stream on it to finish closing first, since
|
|
// QUICStream::Stop only initiates a shutdown that completes asynchronously.
|
|
// Streams outliving the connection are therefore safe to hold, though they
|
|
// are dead once it goes; prefer destroying or moving them out first.
|
|
//
|
|
// Browser build: the only QUIC-shaped API the browser exposes is
|
|
// WebTransport, which is HTTP/3-based and reached at a fixed URL. Here:
|
|
// - The constructor returns immediately; the connection is opened in
|
|
// the background. Operations issued before the connection is ready
|
|
// are queued JS-side until WebTransport's "ready" promise resolves
|
|
// (or fail with QUICClosedException if the connection rejects).
|
|
// - `alpn` is mapped to the URL path: new WebTransport(
|
|
// `https://${host}:${port}/${alpn}`). The QUIC-layer ALPN itself
|
|
// is fixed to "h3" by the browser and cannot be customised.
|
|
// - The server side must accept WebTransport sessions (HTTP/3 extended
|
|
// CONNECT) on the path equal to `alpn`. Plain QUIC with a custom
|
|
// ALPN — what ListenerQUIC offers today — is not reachable from a
|
|
// browser.
|
|
// - Synchronous send/receive methods are not compiled. Use the *Async
|
|
// variants instead.
|
|
export class ClientQUIC {
|
|
public:
|
|
// ALPN identifier exchanged in the handshake. Both peers must agree.
|
|
// For 3DForts use e.g. "f3d/1" or similar — a short stable token.
|
|
// On the browser build, this is the WebTransport URL path instead
|
|
// of an ALPN token; see the class comment above.
|
|
std::string alpn;
|
|
|
|
// Client constructor: connects to host:port using QUIC. ALPN must
|
|
// match the listener. Throws QUICException on connect failure.
|
|
// Blocks for at most the handshake idle timeout (10s), so a host that
|
|
// never answers fails in seconds rather than at the idle timeout.
|
|
ClientQUIC(const char* host, std::uint16_t port, std::string alpn,
|
|
QUICClientCredentials creds = {});
|
|
ClientQUIC(std::string host, std::uint16_t port, std::string alpn,
|
|
QUICClientCredentials creds = {});
|
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
|
// Server-side constructor used by ListenerQUIC for accepted peers.
|
|
// Takes ownership of an already-accepted msquic connection handle
|
|
// and the server configuration handle. Not intended for direct use.
|
|
ClientQUIC(HQUIC connectionHandle, HQUIC serverConfiguration, std::string alpn);
|
|
#endif
|
|
|
|
~ClientQUIC();
|
|
ClientQUIC(const ClientQUIC&) = delete;
|
|
ClientQUIC(ClientQUIC&&) noexcept;
|
|
|
|
// Open a new stream initiated by this side. Defaults to bidirectional;
|
|
// pass unidirectional=true to open a one-way send stream (used for
|
|
// HTTP/3's control + QPACK encoder/decoder streams).
|
|
// Blocks until the stream is started; throws on failure.
|
|
QUICStream OpenStream(bool unidirectional = false);
|
|
|
|
// Send a datagram. Best-effort: may be silently dropped under loss
|
|
// or congestion. Size must fit within the path MTU (msquic surfaces
|
|
// the maximum via QUIC_PARAM_CONN_DATAGRAM_SEND_ENABLED — typically
|
|
// ~1200 bytes safely on the open internet).
|
|
void SendDatagram(const void* buffer, std::uint32_t size);
|
|
|
|
// Register a handler for streams the peer initiates toward us.
|
|
// Called on the msquic worker; offload heavy work to ThreadPool.
|
|
void OnStream(std::function<void(QUICStream)> callback);
|
|
|
|
// Register a handler for datagrams from the peer. Called on the
|
|
// msquic worker; copy/queue and return promptly.
|
|
void OnDatagram(std::function<void(std::vector<char>)> callback);
|
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
|
// Block the caller until the next datagram arrives; returns it.
|
|
// Throws QUICClosedException if the connection closes first.
|
|
std::vector<char> RecieveDatagramSync();
|
|
#endif
|
|
|
|
// Cleanly shut down the connection.
|
|
void Stop();
|
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
|
// Underlying handle for advanced use (parameter queries, etc.).
|
|
HQUIC GetHandle() const;
|
|
#endif
|
|
|
|
private:
|
|
struct Impl;
|
|
std::unique_ptr<Impl> impl;
|
|
friend class ListenerQUIC;
|
|
friend class QUICStream;
|
|
};
|
|
}
|