Commit graph Crafter.Network/interfaces
Author SHA1 Message Date
1b7417c2ba
feat(quic): QUICStream::SendAsync holds no thread and reports from SEND_COMPLETE
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>
2026-11-01 02:24:15 +01:00
f0769838e5
feat(quic): ClientQUIC::OnClosed tells the owner the connection is over
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>
2026-11-01 02:18:50 +01:00
795f06178d
fix(quic): let ~ClientQUIC's CONNECTION_CLOSE reach the peer
~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>
2026-11-01 02:06:39 +01:00
61d3e8c26a
fix(listener): make the selfSigned QUIC certificate per user, in-process, without a race
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>
2026-11-01 01:56:44 +01:00
bffb757ab7
feat(listener): ListenerQUIC listens on the address it is given
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>
2026-11-01 01:48:30 +01:00
7dace8bb03
feat(listener): ListenerQUIC::Port() reads back the port it bound
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>
2026-11-01 01:45:31 +01:00
7ee938c98e
feat(http1): ClientHTTP1::Fetch follows redirects by the RFC, on a URL type
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`.
2026-10-31 20:39:43 +01:00
e86c3b70a0
feat(tcp): socket errors as std::system_error, with the phase that failed
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().
2026-10-31 20:33:37 +01:00
16da0a1979
feat(tcp): a failed send or receive says which errno it failed with
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>
2026-10-31 20:28:32 +01:00
f9a45c143e
feat(http1): bound the connect, and FetchHTTP1 for a URL with redirects
- 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>
2026-10-31 20:28:32 +01:00
d6f8f4edcb
feat(tcp): ListenerTCP binds the address it is given, at port 0 if asked
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>
2026-10-31 20:28:32 +01:00
78853033e8
feat(tcp): connect over IPv4 and IPv6, and say why a connect failed
- 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>
2026-10-31 20:28:32 +01:00
f683216850
feat: TCP-only builds; TCP options and timeouts; receives that report and keep what arrived
- --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>
2026-10-20 20:04:57 +02:00
catbot
ffc118f825 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
9a5cab88eb merge 2026-08-25 19:18:32 +02:00
675476dfea quic settings 2026-08-25 19:17:22 +02:00
catbot
f14b85694f fix(quic): bound the client handshake
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>
2026-08-25 17:06:51 +00:00
catbot
42a34de244 fix(quic): stop ~ClientQUIC racing the callback to close the connection
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>
2026-08-25 17:06:51 +00:00
catbot
9c22cbe09e feat(tls): add a libssl TLS transport and an https:// HTTP/1.1 stack
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>
2026-07-28 20:13:30 +00:00
catbot
7a524cfdd0 feat(http): give ListenerHTTP the same fallback and query-strip routing
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.
2026-07-28 19:42:06 +00:00
catbot
4d5ef7c96b feat(http1): dispatch unmatched paths to an optional fallback handler
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.
2026-07-28 19:41:57 +00:00
catbot
a79ab6a024 docs(http1): document the HTTP/1.1 stack, and expose the client's timeout
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>
2026-07-27 01:02:37 +00:00
catbot
337ce32eca feat(http1): add an HTTP/1.1 client and listener
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>
2026-07-27 00:45:09 +00:00
e33ec5b72e SPDX license 2026-07-22 17:55:45 +02:00
e8630528af browser wasm 2026-05-19 02:53:50 +02:00
28fab2509b full QUIC support 2026-05-07 00:06:44 +02:00
45479a46ff added QUIC 2026-05-06 04:06:17 +02:00
de2073422c crafter build V2 2026-05-06 01:09:40 +02:00
c49f947a9b fixes 2025-11-03 15:51:13 +01:00
9bdf133d0f fixes 2025-11-03 14:25:51 +01:00
64739c39d8 headers are now always lower case 2025-11-02 16:04:32 +01:00
0fbc5bad52 initial commit 2025-11-02 15:00:53 +01:00