New "Routes that cannot be enumerated" section, linked from both
listener sections since the hook and its precedence are identical on
each. Also records the HTTP/3 query-strip change and why a fallback has
to be a constructor argument on the ListenerAsync* wrappers.
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.
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>
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>
The HTTP/1.1 stack sits directly on these two classes and each of these
bit it:
- gethostbyname() returning null on an unresolvable host was dereferenced
straight into a crash, and it is not thread safe; use getaddrinfo.
- A failed socket()/connect() only printed to stderr and handed back an
unusable ClientTCP, so the real error surfaced much later as an
unrelated errno from send().
- send() was assumed to accept everything it was offered. It does not
once a buffer outgrows the socket's send buffer, which silently
truncated multi-megabyte bodies. Loop, and pass MSG_NOSIGNAL so a
vanished peer raises EPIPE instead of killing the process.
- ClientTCP's move constructor closed the socket it had just taken
ownership of, and both it and the destructor tested `socketid != 1`
where they meant `!= -1`.
- ListenerTCP ignored bind()'s result, leaving a listener that accepted
nothing with no explanation, and did not set SO_REUSEADDR, so a
restart hit EADDRINUSE for the length of TIME_WAIT.
accept() failing during Stop() is expected and no longer logged.
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>