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>
QUIC_SETTINGS never set HandshakeIdleTimeoutMs, so it inherited the
120s idle timeout. The constructor blocks until msquic reports the
connection either up or shut down, which meant dialling a host that does
not answer parked the caller for minutes. Bound it to 10s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MsQuicConnectionClose was reachable twice for one handle: ~ClientQUIC
called it unconditionally, and the SHUTDOWN_COMPLETE callback also called
it whenever AppCloseInProgress was clear -- which it is for the whole
window between the destructor's ConnectionShutdown and its ConnectionClose.
The second call trips CXPLAT_TEL_ASSERT(!Connection->State.HandleClosed)
and aborts the process. Clearing impl->connection from the callback did not
help: the destructor had already loaded it.
`connection` is now a claim token, taken under the mutex, and only whoever
wins the claim closes. The callback keeps closing peer-dropped connections
so they do not leak while their ClientQUIC lives on.
Separately, the destructor now waits (bounded, 5s) for the connection's
streams to reach StreamClose before closing it. QUICStream::Stop only
initiates a shutdown whose async completion does the close, so a
connection could otherwise be closed with streams msquic still considers
open. The count lives in a shared StreamRegistry rather than behind the
ClientQUIC*, so a stream finalising after the wait gave up decrements a
live object.
A failed handshake also closes its connection and configuration now.
Throwing from the constructor body means ~ClientQUIC never runs, so both
handles leaked -- and the connection callback holds the impl that was
about to be destroyed with the half-built object.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HTTP/1.1 was plaintext-only, which left `https://` to either an HTTP/3
listener or a terminating proxy in front. Neither helps the callers this
stack exists for — curl scripts, CI tooling, old proxies — so wrap the
transport in libssl instead.
Two new partitions:
:Stream a ByteStream with per-call deadlines on both directions, plus
the plaintext socket implementation. The HTTP/1.1 client and
listener now hold a ByteStream& and never learn which
transport they have, which is what lets one code path serve
both schemes.
:TLS TLSContext/TLSStream over OpenSSL 3, with credentials for both
roles: chain and hostname verification on by default, private
trust anchors, client certificates, mutual TLS, ALPN, and an
in-process self-signed certificate for development.
Both descriptors go non-blocking and every read and write is driven by
poll() against a deadline. That is required for TLS — a blocking
descriptor cannot express a handshake timeout — and it means a plaintext
write can now time out too, instead of parking forever against a peer
that stopped reading.
ClientHTTP1 and ListenerHTTP1 gain credential-taking constructors; the
existing ones still speak http://. The listener handshakes on the
connection's own thread, so a peer that stalls mid-handshake costs one
thread rather than the accept loop, and a failed handshake is counted
rather than logged — on a public port it is ordinary traffic.
MessageParser gains SetDefaultScheme so origin-form targets report the
scheme the transport actually used; handlers shared with ListenerHTTP now
see the same "https" they would over HTTP/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ListenerHTTP1's docs promise the same route map shape as ListenerHTTP so
a handler can be registered with both and served over either protocol.
That only holds if the dispatch rule is the same on both, so mirror it
here: exact `:path`, then the query-stripped path, then `fallback`, then
the synthetic 404.
The query-strip half is a behaviour change on this listener. `/thing?x=1`
previously 404'd even with `/thing` registered, while HTTP/1.1 routed it
— the asymmetry the shared route map was supposed to avoid. It also
matters for `fallback`: without it a query string would divert a
registered path to the fallback over HTTP/3 but not over HTTP/1.1.
`fallback` covers `routes` only. An unmatched WebTransport CONNECT is
still a 404 — a WT handler takes a session, not a request, so there is
nothing sensible to hand it.
MakeBidiHandler now reads the maps off `self` instead of taking them as
pointer parameters. `self` was already captured and unused, and this
mirrors how ListenerHTTP1 reaches its own state through Impl::owner.
The route map only answers paths that are known when the listener is
built. A route with an unbounded segment — /shop/<slug>, /order/<token>,
/posts/<id> — cannot be pre-registered: the token space is unbounded and
the product set changes while the server runs. Every such request became
a synthetic 404 that the application never got to see.
Add one optional member, called for anything `routes` missed, with the
full target still in request.path. Precedence is exact path, then the
query-stripped path, then fallback, then the 404 as before, so a
default-constructed listener behaves byte-identically and no call site
changes.
A hook rather than a pattern syntax: consumers that already have a
router — one shared between a wasm frontend and the server, so a URL
cannot mean different things to a crawler and to the app — keep using
it, and there is no second route table to disagree with the first.
PathWithoutQuery moves out of this file into :HTTP as an exported
PathWithoutQueryHTTP, since ListenerHTTP now needs the same split and a
fallback handler almost always does too.
ListenerAsyncHTTP1 starts accepting inside its constructor, so assigning
`listener.fallback` afterwards would race the accept loop; it gets a
constructor overload that installs the fallback before the thread
starts.
README: HTTP/1.1 in the intro, feature list, module list, browser-build
exclusions, dependencies and test list, plus a Components section
covering both classes, the standalone codec, what is and is not
implemented, the smuggling-shaped inputs that are rejected, and an
explicit note that this path is plaintext and belongs behind a TLS
terminator.
ClientHTTP1::timeout was hard-coded and invisible; make it a public
member alongside `limits`, mirroring the listener's timeouts.
Also pipelining coverage in ShouldSendRecieveHTTP1: two requests written
before either is answered, driven from a raw socket since ClientHTTP1
waits for each response.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HTTP/3-only is not a deployable position yet: plenty of clients, proxies
and CI tooling still speak nothing but HTTP/1.1. This adds that path
using the request/response types the HTTP/3 stack already uses, so a
route handler or call site moves between the two protocols by changing
the class name.
- :HTTP1 — transport-free wire format. Serialisation with the framing
headers owned by the serialiser, and an incremental parser that takes
arbitrary socket chunks and yields one message at a time: keep-alive,
pipelining, content-length and chunked bodies (with trailers),
read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and
Expect: 100-continue. Ambiguous framing is rejected rather than
guessed at (content-length with transfer-encoding, disagreeing
content-lengths, whitespace before a colon), and CR/LF in a value we
are asked to serialise is refused.
- ClientHTTP1 — persistent connection, redialling once when a pooled
connection turns out to have been closed by the peer, which is the
race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a
response byte has arrived.
- ListenerHTTP1 — one thread per connection (keep-alive connections are
idle most of their life and would pin every ThreadPool thread),
automatic Date, HEAD, 100-continue, handler-requested close, idle and
request timeouts, and 400/404/500 responses. Routes fall back to the
query-stripped path so `/thing?x=1` reaches the handler for `/thing`.
No TLS: this is `http://` only. Encrypted traffic still goes over
HTTP/3, or through a TLS-terminating proxy.
Tests: codec unit tests including the malformed inputs above, a
client/server round-trip, keep-alive and stale-connection recovery, a
10 MiB body both ways, and interop both directions against curl and
python3's http.server (skipped when those are not installed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>