Commit graph

58 commits

Author SHA1 Message Date
b1b0633a27
fix(quic): SendSync throws only when its own send failed
SendSync waited on one sendInFlight flag per stream and, once woken,
threw QUICClosedException whenever the stream had finished shutting
down by then, even though its own send had completed. A peer that
answers at once and finishes the stream (echo, FIN, drop it, its Stop()
also stopping our sending side) completes the stream within a round
trip, which on loopback is shorter than a busy machine can take to wake
the sending thread, so SendSync threw for a send the peer had already
answered. The shared flag also let two SendSync calls on one stream wake
on each other's completion.

Each send now carries its own request: the copy msquic reads, the
QUIC_BUFFER describing it (msquic keeps a pointer to that, not a copy,
which the old code satisfied only because the waiter's stack outlived
the send), and an outcome shared with the waiter. SEND_COMPLETE, which
msquic delivers exactly once for every send it took and always before
the stream's SHUTDOWN_COMPLETE, records whether it was Canceled and
frees the request; SendSync returns unless its own send was canceled or
never completed.

Measured with ShouldReturnFromSendSyncQUIC, 2000 exchanges on fresh
connections four at a time: before, 1 or 2 spurious throws in 6 of 7
runs, the echo of each failed send arriving intact; after, none in 9
runs. It surfaced as one lost echo in a hundred in the OnClosed test
being written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 02:16:18 +01:00
793b518718
test(quic): cover the peer being told that a ClientQUIC closed
ShouldTellPeerOfCloseQUIC: twenty clients connect to a listener whose
idle timeout is 20 s and are destroyed in turn; for each, the server
side's RecieveDatagramSync, which throws once the peer has closed the
connection, must return within 2 s. Before the fix the first one never
did, since ~ClientQUIC closed its handle before msquic had sent the
CONNECTION_CLOSE frame; the loop stops at the first miss, as every
later connection would wait out its 2 s too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 02:06:39 +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
76e927595d
docs(listener): where the selfSigned QUIC certificate lives now, and that it needs no CLI
The README's self-signed section, its dependency list, the consumer
notes and the agent notes described the openssl CLI and the shared
/tmp/crafter-network-quic-cert/. They now give the per-user location
and its fallbacks, the day-before-expiry rollover (which is when a
pinned hash changes), what keeps processes that start together on one
pair, and that nothing has to be installed. The agent notes' dev
environment says where the Flatpak keeps the certificate between runs,
drops the two gotchas the move resolved (the CLI, and the comments in
-ListenerQUIC.cpp and ClientQUIC.cppm that disagreed with the code),
and notes the module-internal functions :TLS declares for the listener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:57:57 +01:00
6b8b485e38
test(listener): six processes share one selfSigned certificate from cold
ShouldShareSelfSignedCertificateQUIC: six processes, forked before any
thread or msquic exists and released together, each start two threads
that create a selfSigned listener at once against an empty cache
(XDG_CACHE_HOME pointed at a fresh directory) and with no openssl on
PATH, and connect to both. All twelve listeners serve, and all six
processes report the same certificate hash and a path under
XDG_CACHE_HOME. The directory is 0700 and the key 0600, nothing
temporary is left behind, a process started later serves the same
certificate, and a mismatched key planted on disk, what the race used
to leave, is replaced by the next process rather than loaded.

Against the previous implementation every child fails (`openssl:
command not found`), as do the location and permission checks. The
parent starts no thread, so forking stays safe, and its watchdog is
alarm(55); each child's is alarm(40).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:56:44 +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
5068f0acd7
docs(listener): ListenerQUIC's bind address, and how msquic enforces it
The README's QUIC section and the consumer notes show the new
constructor and say what binding an address means under msquic: the UDP
port is bound on every address anyway, and a connection sent to another
one is refused during its first flight (0x5c, in milliseconds), so a
client of a 127.0.0.1 listener dials 127.0.0.1, not `localhost`. The
agent notes' family gotcha says the same and counts the new test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:49:01 +01:00
a50e3f415c
test(listener): cover ListenerQUIC's bind address
ShouldBindLoopbackQUIC: a listener on 127.0.0.1 at port 0 serves a
client through 127.0.0.1 and refuses, within 3 s of a 6 s handshake
timeout, one through this host's first non-loopback IPv4 address (found
with getifaddrs; that half is skipped with a line saying so when there
is none) and one through ::1; only the served connection reaches
connectCallback. A listener on ::1 serves ::1 and refuses 127.0.0.1, and
`localhost` is refused as a bind address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:48:30 +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
fdb0ac2524
docs(listener): ListenerQUIC::Port(), and port 0 for tests
The README gains a QUIC section, which it never had: plain QUIC under an
ALPN of your own, starting with a listener on port 0 and Port(). The
consumer notes describe Port() and point tests at port 0 rather than at
binding a socket by hand; the agent notes count the new test and make
port 0 the default for a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:46:29 +01:00
3457ff08ce
test(listener): cover ListenerQUIC::Port()
ShouldReportListenerPortQUIC: a listener on port 0 reports a nonzero
port and a client reaches it there; a second listener on port 0 gets a
different port; a fixed port reads back as itself; the port is still
there after Stop() and moves with the listener. It binds no fixed port of
its own: the fixed-port check reuses the ephemeral port a listener just
let go of.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:45:31 +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
3a58564422
docs: agent notes for this repository and for its consumers
AGENTS.template.md holds the notes for working on Crafter.Network itself.
crafter-build 4.0.0 writes AGENTS.md from it on every build and test run,
with the notes of each dependency that ships some merged in, so AGENTS.md
is build output and gitignored. AGENTS.consumer.md holds the notes for
agents working in a project that depends on this library; crafter-build
merges it into that project's AGENTS.md, as the Crafter.Build README's
"Dependency notes for agents" describes.

Both were written from the interfaces, implementations, tests and
project.cpp rather than from the README. Where a comment in the sources
disagrees with the code (the HTTP3 header, the -fno-exceptions remarks,
"on the msquic worker", OpenStream(true) "throws", the QUIC certificate's
location and validity), the notes follow the code and list the comment
as a gotcha.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:22:49 +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
05f7652770 Merge pull request 'fix(quic): close stream handles whose async StreamStart lost the teardown race' (#10) from claude/issue-9 into master 2026-08-25 23:19:15 +00: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
e8e9c0d180 Merge pull request 'fix(quic): stop ~ClientQUIC aborting the process on teardown' (#8) from claude/issue-7 into master 2026-08-25 17:10:08 +00: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
25e8c84794 test(quic): cover connection teardown
Every other QUIC test ends in std::_Exit(0), so nothing in the suite ever
ran a ~ClientQUIC. This adds 25 build-up/tear-down cycles with reader
threads parked inside RecieveSync -- the shape a reconnect loop has --
which aborts on ~10/20 runs against the current implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:06:25 +00:00
0827e1f566 Merge pull request 'https' (#6) from claude/issue-3 into master
Reviewed-on: #6
2026-07-30 18:18:38 +02:00
catbot
14e0a6bab1 test(https): give the TLS tests ports of their own
The new tests reused ports the existing suite already binds — 8095 with
ShouldSurviveAbuseHTTP1, and 8097/8098 with ShouldFallbackUnknownRoutes'
plaintext and HTTP/3 listeners. SO_REUSEADDR does not let two live
listeners share a port, so under the parallel runner whichever bound
second failed, and which test that was came down to scheduling. Move the
TLS tests to 8110-8114, which nothing else uses.

That collision also showed up as a SIGABRT rather than a reported error,
so harden the path it took: ~ListenerHTTP1 calls Stop(), which joins
threads and touches sockets and can therefore throw. A second listener
failing to bind unwinds past a live first one, and a throw out of its
destructor mid-unwind terminates the process — turning a diagnosable bind
failure into a crash. Swallow it there, where there is nothing left to
report it to.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:34:23 +00:00
catbot
331cf0c66c docs: describe HTTPS, the TLS layer, and its system dependency
The HTTP/1.1 section promised the opposite of what the code now does — a
"No TLS" heading stating there was no plan to link a TLS stack into this
path. Replace it with what to actually pass, and lead with the part that
gets deployed wrong: verifying the chain without the hostname is not a
check, and a private trust anchor is the answer for a self-signed peer
rather than insecureNoServerValidation.

Also document :Stream and :TLS as modules in their own right — TLSStream
is a ByteStream over any descriptor, not something only HTTP can use —
and record libssl as a system dependency, including why it is not
vendored the way msquic is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:23:40 +00:00
catbot
b7fe39871b fix(tls): stop a peer that closed first from killing the process
OpenSSL's socket BIO writes with write(2) rather than send(2), so unlike
PlainStream it cannot pass MSG_NOSIGNAL. Writing to a peer that is gone
therefore raised SIGPIPE, and with the default disposition that takes the
whole process down.

This is not an edge case. It fires on any teardown where the far side
closed first, because SSL_shutdown still tries to put a close_notify on
the wire — which is exactly what ShouldSendRecieveHTTPS1 does when it
drops a client whose certificate check failed. The test died on SIGPIPE
with every assertion passing.

Installing a process-wide SIG_IGN would fix it by changing how the
caller's own writes report failure, which a library has no business
doing. SIGPIPE from write(2) is delivered to the writing thread, so block
it for that thread across each OpenSSL call instead and drain any pending
instance before unblocking. A caller who already blocks SIGPIPE is left
untouched — a pending signal there may be theirs to consume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:18:51 +00:00
catbot
39ff5806ed test(https): cover the TLS transport from three angles
ShouldSendRecieveHTTPS1 replays the plaintext round-trip over TLS, so a
regression in the transport shows up as an HTTP failure rather than
nothing at all, and adds what only exists under TLS: ALPN, scheme=https
reaching handlers, a body spanning many records, and the two ways
verification must fail — an untrusted self-signed certificate, and a
trusted certificate presented for the wrong name. A plaintext peer
knocking on the TLS port is asserted to be counted and shrugged off.

ShouldInteropCurlHTTPS1 puts real implementations on the other end, since
two OpenSSL peers can agree on a mistake. curl verifies our certificate
with --cacert rather than --insecure, and an h2-only curl is asserted to
be refused rather than mis-served. python3's http.server behind
ssl.wrap_socket answers HTTP/1.0 with Connection: close, which frames the
body by close_notify — the path a reader is most likely to get wrong.

ShouldRequireClientCertificateHTTPS1 covers mutual TLS both ways, and
drives TLSStream directly with hand-written HTTP/1.1 to keep the :TLS
layer honest as something usable without :ClientHTTP1 on top.

Also fix a delegation that `{}` no longer disambiguates now that a
three-argument TLS constructor exists alongside the fallback one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:17:07 +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
e1bb116b2d Merge pull request 'Add a fallback handler for routes that cannot be enumerated' (#5) from claude/issue-4 into master 2026-07-28 21:43:08 +02:00
catbot
bb25250e75 docs: document fallback routing for both listeners
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.
2026-07-28 19:42:16 +00:00
catbot
fc0adddbe9 test(http): replay one fallback route table over both listeners
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.
2026-07-28 19:42:16 +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
219a31a8f7 Merge pull request 'Add HTTP/1.1 client and server' (#2) from claude/issue-1 into master 2026-07-27 03:05:15 +02: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
ea1310faec fix(http1): close a finished connection instead of holding it until reap
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>
2026-07-27 00:56:53 +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
catbot
b758419007 fix(tcp): resolve, connect, bind and send failures were silent or wrong
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>
2026-07-27 00:44:56 +00:00
e33ec5b72e SPDX license 2026-07-22 17:55:45 +02:00
43fdd7fb53 fix(listener): stop dropping a peer's streams that arrive during connection setup
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>
2026-05-29 16:56:07 +02:00
f5f142f993 new tests 2026-05-28 16:48:55 +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