2026-07-22 17:55:45 +02:00
|
|
|
//SPDX-License-Identifier: LGPL-3.0-only
|
|
|
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
2026-05-06 04:06:17 +02:00
|
|
|
|
|
|
|
|
module;
|
2026-05-19 02:53:50 +02:00
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
2026-05-06 04:06:17 +02:00
|
|
|
#include <msquic.h>
|
2026-05-19 02:53:50 +02:00
|
|
|
#endif
|
2026-05-06 04:06:17 +02:00
|
|
|
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"; }
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-25 19:17:22 +02:00
|
|
|
// 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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-06 04:06:17 +02:00
|
|
|
// 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;
|
2026-08-25 19:17:22 +02:00
|
|
|
QUICSettings settings;
|
2026-05-06 04:06:17 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Client-side credential validation. By default we require a real cert.
|
2026-05-19 02:53:50 +02:00
|
|
|
// 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.
|
2026-05-06 04:06:17 +02:00
|
|
|
export struct QUICClientCredentials {
|
|
|
|
|
bool insecureNoServerValidation = false;
|
2026-05-19 02:53:50 +02:00
|
|
|
std::array<std::uint8_t, 32> serverCertificateHash{};
|
2026-08-25 19:17:22 +02:00
|
|
|
QUICSettings settings;
|
2026-05-06 04:06:17 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export class ClientQUIC;
|
|
|
|
|
|
2026-05-07 00:06:44 +02:00
|
|
|
// 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.
|
2026-05-06 04:06:17 +02:00
|
|
|
export class QUICStream {
|
|
|
|
|
public:
|
2026-05-19 02:53:50 +02:00
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
2026-05-06 04:06:17 +02:00
|
|
|
// Underlying msquic HQUIC handle. Treated as opaque by callers.
|
|
|
|
|
HQUIC handle = nullptr;
|
2026-05-19 02:53:50 +02:00
|
|
|
#endif
|
2026-05-06 04:06:17 +02:00
|
|
|
|
|
|
|
|
// The connection that owns this stream (non-owning).
|
|
|
|
|
ClientQUIC* connection = nullptr;
|
|
|
|
|
|
2026-05-07 00:06:44 +02:00
|
|
|
// 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();
|
2026-05-19 02:53:50 +02:00
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
2026-05-06 04:06:17 +02:00
|
|
|
QUICStream(HQUIC handle, ClientQUIC* connection);
|
2026-05-19 02:53:50 +02:00
|
|
|
#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
|
2026-05-06 04:06:17 +02:00
|
|
|
~QUICStream();
|
|
|
|
|
QUICStream(const QUICStream&) = delete;
|
|
|
|
|
QUICStream(QUICStream&&) noexcept;
|
|
|
|
|
QUICStream& operator=(QUICStream&&) noexcept;
|
|
|
|
|
|
2026-05-19 02:53:50 +02:00
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
2026-05-06 04:06:17 +02:00
|
|
|
// 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.
|
|
|
|
|
std::vector<char> RecieveUntilFullSync(std::uint32_t bufferSize);
|
2026-05-19 02:53:50 +02:00
|
|
|
#endif
|
2026-05-06 04:06:17 +02:00
|
|
|
|
2026-05-19 02:53:50 +02:00
|
|
|
// Send a buffer. If finish=true, the send-side is closed after the
|
|
|
|
|
// buffer is delivered. onSent fires once the transport has accepted
|
|
|
|
|
// the buffer (native) or the WritableStream writer has resolved
|
|
|
|
|
// (browser). Available on both native and browser builds.
|
|
|
|
|
void SendAsync(const void* buffer, std::uint32_t size, bool finish,
|
|
|
|
|
std::function<void()> onSent);
|
|
|
|
|
|
|
|
|
|
// Async receive variants. Dispatched on Crafter.Thread's ThreadPool
|
|
|
|
|
// (native) or driven by a per-stream JS reader loop (browser).
|
2026-05-06 04:06:17 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-05-19 02:53:50 +02:00
|
|
|
#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
|
|
|
|
|
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// 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.
|
2026-05-06 04:06:17 +02:00
|
|
|
void Stop();
|
|
|
|
|
|
|
|
|
|
private:
|
|
|
|
|
struct Impl;
|
2026-07-22 17:55:45 +02:00
|
|
|
// 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;
|
2026-05-06 04:06:17 +02:00
|
|
|
friend class ClientQUIC;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// A QUIC connection. On the client side, constructing one initiates the
|
2026-05-19 02:53:50 +02:00
|
|
|
// handshake and (on native) blocks until it succeeds, or throws on
|
|
|
|
|
// failure. On the server side, ListenerQUIC instantiates these for
|
|
|
|
|
// accepted peers.
|
2026-05-06 04:06:17 +02:00
|
|
|
//
|
|
|
|
|
// A connection multiplexes:
|
|
|
|
|
// - Reliable, ordered streams (open via OpenStream() / observe inbound
|
|
|
|
|
// via OnStream()).
|
|
|
|
|
// - Unreliable, unordered datagrams (SendDatagram() / OnDatagram()).
|
|
|
|
|
//
|
2026-08-25 17:06:51 +00:00
|
|
|
// 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.
|
2026-05-19 02:53:50 +02:00
|
|
|
//
|
|
|
|
|
// 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.
|
2026-05-06 04:06:17 +02:00
|
|
|
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.
|
2026-05-19 02:53:50 +02:00
|
|
|
// On the browser build, this is the WebTransport URL path instead
|
|
|
|
|
// of an ALPN token; see the class comment above.
|
2026-05-06 04:06:17 +02:00
|
|
|
std::string alpn;
|
|
|
|
|
|
|
|
|
|
// Client constructor: connects to host:port using QUIC. ALPN must
|
|
|
|
|
// match the listener. Throws QUICException on connect failure.
|
2026-08-25 17:06:51 +00:00
|
|
|
// Blocks for at most the handshake idle timeout (10s), so a host that
|
|
|
|
|
// never answers fails in seconds rather than at the idle timeout.
|
2026-05-06 04:06:17 +02:00
|
|
|
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 = {});
|
|
|
|
|
|
2026-05-19 02:53:50 +02:00
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
2026-05-06 04:06:17 +02:00
|
|
|
// 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);
|
2026-05-19 02:53:50 +02:00
|
|
|
#endif
|
2026-05-06 04:06:17 +02:00
|
|
|
|
|
|
|
|
~ClientQUIC();
|
|
|
|
|
ClientQUIC(const ClientQUIC&) = delete;
|
|
|
|
|
ClientQUIC(ClientQUIC&&) noexcept;
|
|
|
|
|
|
2026-05-07 00:06:44 +02:00
|
|
|
// 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).
|
2026-05-06 04:06:17 +02:00
|
|
|
// Blocks until the stream is started; throws on failure.
|
2026-05-07 00:06:44 +02:00
|
|
|
QUICStream OpenStream(bool unidirectional = false);
|
2026-05-06 04:06:17 +02:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
2026-05-19 02:53:50 +02:00
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
2026-05-06 04:06:17 +02:00
|
|
|
// Block the caller until the next datagram arrives; returns it.
|
|
|
|
|
// Throws QUICClosedException if the connection closes first.
|
|
|
|
|
std::vector<char> RecieveDatagramSync();
|
2026-05-19 02:53:50 +02:00
|
|
|
#endif
|
2026-05-06 04:06:17 +02:00
|
|
|
|
|
|
|
|
// Cleanly shut down the connection.
|
|
|
|
|
void Stop();
|
|
|
|
|
|
2026-05-19 02:53:50 +02:00
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
2026-05-06 04:06:17 +02:00
|
|
|
// Underlying handle for advanced use (parameter queries, etc.).
|
|
|
|
|
HQUIC GetHandle() const;
|
2026-05-19 02:53:50 +02:00
|
|
|
#endif
|
2026-05-06 04:06:17 +02:00
|
|
|
|
|
|
|
|
private:
|
|
|
|
|
struct Impl;
|
|
|
|
|
std::unique_ptr<Impl> impl;
|
|
|
|
|
friend class ListenerQUIC;
|
|
|
|
|
friend class QUICStream;
|
|
|
|
|
};
|
|
|
|
|
}
|