Every way to wait for a stream's bytes held a thread: RecieveSync and
RecieveUntilCloseSync block their caller with no bound, and
RecieveAsync and RecieveUntilCloseAsync are those run as pool tasks, so
they block a worker of the pool the host shares, for good on a stream a
client keeps open. A server embedded in an app, answering requests from
the app's main loop and streaming logs to followers, can afford neither.
OnRecieve(onData(span), onEnd(clean)) delivers the bytes as they arrive
and the end once: clean for the peer's FIN (PEER_SEND_SHUTDOWN, which
msquic raises only once every byte before it has been taken), not clean
for a reset by either side or a connection that went first. Bytes that
arrived before the call are delivered by it, before it returns, and so
is the end; from then on the callbacks run on the msquic worker inside
the event that made them due, with msquic's own buffers handed over
when nothing is queued, so nothing is copied and no thread waits.
The callbacks of one stream, SendAsync's onSent included, never run at
once. Whichever thread finds nobody delivering becomes the delivering
thread and runs what is ready, picking up what others queue meanwhile;
the others queue and return, the worker never waiting. A mutex held
across the callbacks was the alternative: it would make the worker wait
whenever the registering thread was delivering, and deadlock a callback
that called into msquic and waited for that worker. A failed SendAsync
now reports after the callback that made it, or from a pool task.
Abort(errorCode) resets both directions with an application error code
(ABORT_SEND | ABORT_RECEIVE | IMMEDIATE, so SHUTDOWN_COMPLETE, and
onEnd(false), follow without waiting for the peer's acknowledgement).
From inside onData it is how a server enforces a byte cap: no onData
follows, what was queued is dropped, onEnd(false) comes once.
Destroying the wrapper, or Stop(), unregisters the callbacks the way
std::stop_callback does: once it returns neither runs again, one
running on another thread has finished, and one on the destructor's own
thread, a callback destroying its own wrapper, is not waited for. After
the end or the unregistering the callbacks are released, so a callback
holding its own stream does not keep it forever. The callbacks use the
shared Impl only, so moving the wrapper under them is safe.
Breaking: none. Existing receives are unchanged, and must not be mixed
with OnRecieve on one stream.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native SendAsync was SendSync run as a pool task. That held a worker of
Crafter.Thread's pool, which the host shares and which runs each
worker's queue in order, for as long as msquic took to accept the
buffer, and a peer that reads slowly stretches that to its own pace: a
server streaming to a slow follower stalled the tasks queued behind it.
The task also reached back through `this`, so moving or destroying the
wrapper before it ran was a use-after-free, and two sends could reach
msquic out of order from two workers.
Now SendAsync copies the buffer into a SendRequest, hands it to msquic
and returns. SEND_COMPLETE, which msquic delivers exactly once for every
send it took and before the stream's SHUTDOWN_COMPLETE, frees the copy
and calls onSent on the msquic worker: true when msquic took the whole
buffer (in its send buffer or acknowledged, which is what SendSync's
return has always meant), false when it was canceled because the stream
or the connection ended first. Sends queue in msquic in call order and
none waits for another. A send msquic never took (a closed stream, a
moved-from wrapper, StreamSend refusing it) reports false from a pool
task, never from inside SendAsync, so a caller may hold a lock its own
callback takes. Only `impl` is read, not `handle`, so SendAsync is
callable from any thread, a callback included; on a stopped stream
msquic cancels the send.
BytesInFlight() and SendsInFlight() count what SendAsync and SendSync
handed msquic whose SEND_COMPLETE has not come, so a server can drop a
follower that falls too far behind.
Rewritten rather than added beside it: nothing in this repository calls
the native SendAsync (ListenerHTTP, WebTransport and ClientHTTP use
SendSync; the example's SendAsync is the browser build's), nor does any
consumer found here (3DForts uses SendSync natively); its contract is
unchanged, copy now and onSent once with whether it went; and the old
one's pool thread, `this` capture and reordering were defects, not
behaviour to keep. What changes is onSent's thread, msquic's worker
rather than a pool task, so a callback that blocks, a *Sync call above
all, now stalls its connection rather than a pool worker. The browser
build is untouched.
Breaking: native onSent runs on msquic's worker thread, not as a pool
task, and must not block.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A ListenerQUIC hands each accepted connection to its callback as a
heap-allocated ClientQUIC the callback owns, and nothing said when to
delete it: the SHUTDOWN_COMPLETE handler closed the msquic handle of a
connection the peer or the idle timeout ended, but the object stayed, so
a server either leaked its peers or deleted them blind, and one still
alive at exit holds msquic's registration open until its idle timeout.
OnClosed(callback) is told once when the connection has shut down for
any reason but this object's destruction: the peer closed it, the idle
timeout or a transport error ended it, or Stop() was called. It runs on
the msquic worker, in the SHUTDOWN_COMPLETE handler, after that handler
has claimed and closed the handle, so deleting the ClientQUIC from
inside it is safe: ~ClientQUIC then finds no handle to claim and waits
for nothing. Delivering it there, rather than as a pool task, keeps it
independent of a pool the host shares and possibly saturates; the cost
is the usual worker rule, which the interface spells out (no blocking,
no *Sync call, no deleting another ClientQUIC from inside it). A
callback set after the close, which happens when the peer goes before a
ListenerQUIC's connectCallback runs, fires as a pool task instead, never
from inside OnClosed, where the caller is still using the object.
Its state is a shared CloseNotice rather than part of the Impl, since
the handler fires it after giving up the handle, when ~ClientQUIC on
another thread no longer waits for the worker and may free the Impl.
The rules are std::stop_callback's: ~ClientQUIC marks the owner gone
first, after which the callback never starts; one already running on
another thread is waited for, so the owner may free what it uses as
soon as the destructor returns; one on the destructor's own thread, a
callback deleting its connection, is not. The handler takes the handle
and the notice under one lock, so the destructor cannot slip between.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
~ClientQUIC asked msquic to shut the connection down, waited for its
streams to drain, and closed the handle. With no stream open the drain
returns at once, so ConnectionClose followed ConnectionShutdown within
microseconds. msquic sends the CONNECTION_CLOSE frame from a FLUSH_SEND
operation it queues while it processes the shutdown; the close the
destructor had queued meanwhile reached the worker first, forced the
shutdown to complete silently (QuicConnTryClose: "silent close forced
after we already started the close process"), and the flush, which is
ignored once ShutdownComplete is set, sent nothing. The peer learned of
the close only at its idle timeout, two minutes by default: a server
whose clients each live for one request, such as a debug server driven
by a CLI that connects once per command, kept every one of them that
long.
The destructor now also waits, after the drain, for the connection's
SHUTDOWN_COMPLETE before closing the handle. That comes about one round
trip later once the peer answers the close, or after msquic's closing
period (three PTOs) when it does not; the wait is bounded at 1 s, since
the frame went out at the start either way and a peer that is gone must
not hold the destructor for the whole closing period. The handler sets a
flag for it before it looks at the handle, which the destructor holds,
so the close stays the destructor's.
Measured with ShouldTellPeerOfCloseQUIC: before, the server was not told
of the first of 20 client closes within 2 s (and, counted another way,
8 of 100 peer closes reached the server within 10 s in one run, 0 of
100 in the next); after, all 20 within a millisecond.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
QUICServerCredentials{selfSigned} shelled out to the openssl CLI and kept
the pair in the fixed /tmp/crafter-network-quic-cert/, guarded by an
in-process mutex only. Three things went wrong with that:
- Processes started at once (crafter-build runs tests in parallel, and
inside the Flatpak they share one /tmp) each found no pair and each
ran `openssl req` over the same key.pem and cert.pem, which it writes
in place one after the other. A process could load one run's key with
another's certificate and fail in ConfigurationLoadCredential.
- /tmp is shared by every user of the machine: whoever made the
directory first could keep everyone else from writing there, or plant
a pair in it.
- Without the openssl CLI there was no certificate at all, and
ComputeCertificateHashSHA256 piped the file through it as well.
The pair now lives in $XDG_CACHE_HOME/crafter-network/quic-cert/ (else
~/.cache/..., else $XDG_RUNTIME_DIR/...). Missing directories are made
0700, and the last is opened O_NOFOLLOW, refused unless the user owns
it, and made 0700 if it was not. A POSIX record lock on a file in it,
given up on after 10 s, covers both the check of the pair on disk and
its replacement, so one process makes the pair and the rest read it;
each file is written under a temporary name, fsynced and renamed into
place; and the listener hands msquic the pair from memory as PKCS#12
(QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12) instead of the two paths, so
a replacement cannot land between its reads of the key and the
certificate. A pair that does not parse, whose key does not match, or
that has less than a day left is replaced, which also repairs one a
crash tore between the two renames. A record lock rather than flock: an
flock is shared with a child forked while another thread holds it, and
the child holds it for its whole life. Threads of one process are kept
apart by the existing mutex, and the process keeps the pair it loaded,
so all its listeners serve one certificate until it nears its end.
The certificate is made in-process by :TLS in the shape the CLI
produced (ECDSA P-256, ecdsa-with-SHA256, 10 days, BasicConstraints
CA:FALSE, KeyUsage digitalSignature, EKU serverAuth, SANs localhost,
127.0.0.1 and ::1), with a random serial where the CLI also drew one;
ComputeCertificateHashSHA256 is X509_digest over the file's
certificate. The OpenSSL helpers are module-internal declarations in
:TLS, so libssl stays behind that one partition, and the TLS
partition's own development certificate is unchanged.
As before, the pair is reused across restarts while valid, so a
browser's pinned hash survives one; it is now replaced a day before it
expires rather than after, so a server is not started on a certificate
about to lapse. In the Flatpak, where /tmp went away with the last
instance, it now survives between runs too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new constructor takes a numeric bind address first, as ListenerTCP's
does: 127.0.0.1 or ::1 for this machine alone, 0.0.0.0 or :: for every
address of one family. Until now every QUIC listener listened on every
address of both families (QUIC_ADDRESS_FAMILY_UNSPEC), so a server meant
for this machine's own processes, such as a debug server embedded in an
app, was reachable from the network. A name such as `localhost` is
refused (AI_NUMERICHOST) rather than bound to whichever address the
resolver ranks first. The port-only constructor keeps listening on every
address.
msquic binds the dual-stack wildcard UDP socket for the port whatever
address it is given, and filters by the address a connection was sent
to in its own listener lookup (QuicBindingGetListener). A connection to
another address of the machine is closed with no_application_protocol
during its first flight, so the client fails at once
(QUIC_STATUS_ALPN_NEG_FAILURE, 4 ms on loopback here) rather than
waiting out its handshake timeout, and never reaches connectCallback.
Both constructors now share Impl::Start, which leaves nothing open when
it throws.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port 0 asks msquic for an ephemeral port, but nothing read that port
back, so a QUIC server had to pick a fixed one and hope nothing else held
it, and this suite has to hand every QUIC test a port of its own.
ListenerStart writes the port of the UDP socket it bound into the
listener's local address; the constructor now reads it with
QUIC_PARAM_LISTENER_LOCAL_ADDRESS right after the start, and Port()
returns it. It is valid as soon as the constructor returns and still
after Stop(), and 0 on a moved-from listener. ListenerTCP has had the
same since d6f8f4e.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One request to an http(s) URL with redirects followed by RFC 9110 §15.4:
301, 302, 303, 307 and 308 for every method; a 303 makes the request a
GET without its body and content headers, a 301 or 302 does so for a POST
alone, a 307 or 308 keeps the method and the body. The Location is
resolved against the sending URL by RFC 3986, a hop between http and
https is allowed, Authorization, Proxy-Authorization and Cookie are
dropped on a change of origin, and each hop is a client of its own, since
one ClientHTTP1 is one origin. A redirect without a usable Location, to
another scheme, back to a URL already visited or past maxRedirects comes
back as the response, with `stop` saying why.
The new :URL partition is the URL type behind it, exported: Parse,
Resolve (RFC 3986 §5.2; its §5.4 examples are the test), ToString, and
the pieces a request is built from.
This replaces the FetchHTTP1 free function, which followed redirects the
way Python's urllib did (its repeat limit, its quoting of the Location, a
POST turned back on a 307) for a port that no longer needs the fidelity.
Breaking: FetchHTTP1, FetchOptionsHTTP1 and FetchResultHTTP1 are gone;
use ClientHTTP1::Fetch, FetchOptions and FetchResult, whose `url` is a
URL and whose `limited` became `stop`.
SocketErrorException is now a std::system_error: code() carries the errno
under std::generic_category(), or getaddrinfo's result under the new
ResolverCategory(), whose message() is gai_strerror's text; `phase` says
which step failed (resolve, open, connect, send, receive). It is still a
std::runtime_error with the message ClientTCP always threw, the code's
text after it, so existing handlers keep working.
Before, the exception carried a `kind` and an int, and a caller that had
to print the cause its own way still had to match the message prefix to
learn where the failure happened. The standard type ends both: the
category knows how to print the number, and the phase is a field. A
caller writes "[Errno {}] {}" from code().value() and code().message(),
or compares code() with std::errc::connection_refused.
Breaking: SocketErrorException's `kind` and `code` members are gone;
read code().category() and code().value().
Send() and the receives threw a bare std::runtime_error with the
strerror text, so a caller that reports the cause the way Python does -
"the connection broke ([Errno 104] Connection reset by peer)", which a
language model reads in Crafter.Review's SQL helper output - could not
tell the code. They throw SocketErrorException now, as a failed connect
already does: kind System, the errno, and the same message, still a
std::runtime_error. A receive timeout stays SocketTimeoutException and
a closed peer SocketClosedException.
ShouldReportIoErrors: a peer that resets the connection (SO_LINGER 0)
fails the next receive with ECONNRESET and a send into it with EPIPE
or ECONNRESET.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ClientHTTP1 gains connectTimeout (default 15 s), applied to each
address the host resolves to. `timeout` only ever bounded the waits for
a response, so a host that drops the SYNs held Send() for the kernel's
retries, about two minutes, whatever the caller had asked for.
- The default Host header puts an IPv6 literal in brackets.
- FetchHTTP1(url, options) makes one request to an http:// or https://
URL and follows redirects as Python's urllib does, so urllib code ported
over keeps its behaviour: 301/302/303/307/308 for GET and HEAD,
301/302/303 for POST (re-sent as a GET without its body), a relative
Location resolved as urljoin resolves it and percent-encoded as urllib
quotes it, http and https hops mixed, each redirect's body read before
the next request, and the chain stopped by urllib's limits (4 repeats
of one URL, 10 different URLs) with the last redirect returned and
`limited` set. Proxies are not consulted.
New: ShouldTimeoutSilentHTTP1, ShouldFollowRedirectsHTTP1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new constructor takes a numeric bind address (127.0.0.1, ::1, 0.0.0.0,
::) and accepts port 0, and Port() reports the port the listener got.
Until now every listener bound 0.0.0.0 at a port the caller chose, so a
helper meant for this machine's own processes was reachable from the
network, and the caller had to guess a free port. A name such as
`localhost` is refused instead of bound to whichever address the
resolver ranks first. The port-only constructor still listens on every
IPv4 interface.
`running` is atomic now: Stop() runs on another thread than the accept
loop that reads it. The accept loops take a sockaddr_storage, so an IPv6
peer's address is no longer cut short.
New: ShouldBindLoopbackEphemeral.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ClientTCP resolves with AF_UNSPEC and tries every address the name
resolves to, in getaddrinfo's order, the way Python's
socket.create_connection does. It used to ask for AF_INET only and keep
the first answer, so `localhost` could not reach a server listening on
::1 alone, which is what a dev server on a recent Node does. The connect
timeout bounds each address on its own; when none connects, what the
last one failed with is thrown.
- A failed resolve, socket() or connect throws SocketErrorException: the
std::runtime_error it always was, with the same message, plus the errno
(SocketErrorKind::System) or the getaddrinfo code
(SocketErrorKind::Resolver). A caller that reports the cause in its own
words, such as a Python-style "[Errno 111] Connection refused", no
longer has to parse it back out of the text.
- The move constructor carries the resolved addresses along.
New: ShouldConnectDualStack, ShouldReportConnectErrors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- --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>
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>