The point of the feature is that a URL means the same thing over either
protocol, so the test states it that way: one route map plus one
fallback, registered with ListenerHTTP1 and ListenerHTTP, asked the same
eight questions, asserting identical answers.
Covers exact routes beating the fallback, query strings still routing to
the bare path, the fallback seeing the full target including the query,
the fallback choosing its own status (404 and 303), a throwing fallback
becoming a 500, and an unset fallback still producing the listener's own
synthetic 404.
Verified against a broken build both ways: dropping the fallback lookup
fails 9 checks, dropping the query-strip fails 2.
A connection's socket was owned by the registry entry and only released
when the next accept() reaped it, so a peer we had finished with — after
a 408, a 400, or a `connection: close` — never saw EOF and sat waiting
for a server that was done talking. On a server that goes quiet it also
held every descriptor from the last burst indefinitely.
The connection thread now closes its own socket the moment Serve()
returns, under the registry lock so Stop()'s shutdown() can never name a
descriptor that has already been released, and Stop() waits on a
condition variable for the last thread rather than assuming the vector
it moved out is quiescent. Adopt() is also fully guarded: it runs on
ListenerTCP's accept loop, which has no handler, so anything escaping it
would abort the process.
Found by ShouldSurviveAbuseHTTP1, added here: 24 concurrent keep-alive
clients, peers that vanish mid-request or send garbage, and a peer that
stalls forever — the server must keep serving and still stop promptly.
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>
ListenerQUIC installed only a no-op bootstrap connection callback in the
NEW_CONNECTION handler and deferred the real ClientQUIC callback to the
ThreadPool, alongside per-connection onConnect setup. An HTTP/3 peer (notably
Chromium) opens its control + QPACK + request streams the instant the QUIC
handshake completes — potentially before that deferred task ran. Those early
PEER_STREAM_STARTED events were delivered to the bootstrap and silently
dropped, so the session never completed. Over the network this surfaced as an
intermittent "WebTransport connection rejected" that cleared on retry.
Construct the ClientQUIC (and thus install its real connection callback)
synchronously inside NEW_CONNECTION, before the handler returns and before
msquic delivers any further events. pendingAccepted now holds the constructed
ClientQUIC*; the accept loops just dispatch it, and the destructor cleans up
any peer accepted but never dispatched.
Also park WT data streams that arrive before their CONNECT session is
registered (the stream demux races the CONNECT handler) and drain them on
registration, instead of dropping them.
Tests:
- New ShouldNotDropEarlyStreams reproduces the race deterministically by
saturating the ThreadPool so onConnect is gated while the client opens its
request stream; fails on the pre-fix build, passes after.
- Give ShouldEchoWebTransport its own port (8085) so it no longer collides
with ShouldSendRecieveKeepaliveHTTP (8083) under the parallel test runner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>