The README's QUIC section gains receiving by callback: a server
answering one request per stream with a size cap enforced by Abort,
the threads the callbacks run on, what they may call, and the
std::stop_callback rule for destroying the wrapper; its follower
example now drops a lagging peer with Abort rather than a graceful
Stop. The consumer notes give the full contract, the weak_ptr idiom for
reaching a stream from its own callbacks, and list the callbacks among
what runs on msquic's workers; the agent notes count the new test,
describe the per-stream delivery in the partition list, and add the
callbacks to the worker-thread gotcha.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ShouldRecieveByCallbackQUIC, seventeen checks. Bytes and a FIN that came
before OnRecieve are delivered by it before it returns. Registered while
1000 KiB keep arriving, with the first callbacks slowed to widen the
window and an echo sent from inside onData, every byte arrives in order
and no two of the stream's callbacks, onSent included, overlap; the
echo comes back whole. With every pool worker parked, 256 KiB goes out
and comes back by callback and SendAsync alone. The peer's Abort() and
a lost connection each end the stream not clean, once. Abort() from
inside onData caps a 1 MiB upload: no onData after it, onEnd(false),
and the peer's receive side ends while its next send is refused. A
wrapper destroyed inside its own onEnd, or moved while bytes flow,
breaks nothing; one destroyed on another thread mid-flow has no
callback run once its destructor returned. A second OnRecieve throws,
and a stream that cannot receive, or none, gets onEnd(false) at once.
With the serialisation disabled the overlap and order checks fail every
run; with Unregister's wait removed the destroyed-wrapper check fails
in most.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
ShouldSendWithoutBlockingQUIC parked every pool worker on tasks that
polled a release flag local to the block, and the block could end, the
flag with it, within the millisecond a task takes to notice the
release. The flags now live on the heap and the block waits for every
parked task to leave.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README's QUIC section shows sending without a thread: what onSent's
true and false mean, the thread it runs on and what that forbids, and
BytesInFlight as the way to drop a follower that cannot keep up. The
consumer notes give the full contract, take SendAsync out of the list
of what goes through the pool (but for a send msquic never took) and
into what runs on msquic's workers; the agent notes count the new test
and add onSent to the callbacks that really do run on the worker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ShouldSendWithoutBlockingQUIC: a burst of 256 sends of 64 KiB returns in
a few milliseconds with most of it still in flight by BytesInFlight();
every send then reports true, once, in the order sent, the counters
drain to zero and the peer reads all 16 MiB in order. With every pool
worker parked, a chain of 100 sends, each made from the previous one's
onSent, completes. A connection stopped under a burst reports every send
once, the trues before the falses; sends made just before the wrapper is
destroyed go out and report true; four threads sending on one stream at
once all hear back; and a send on a closed stream reports false from a
pool task rather than from inside SendAsync. Readers on the server are
threads of their own, so parking the pool parks nothing they need.
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>
The README's QUIC section gains how to own accepted connections: the
OnClosed callback, the thread it runs on, what not to do in it and the
std::stop_callback rule that makes deleting a connection under a lock
its callback takes a deadlock. The consumer notes give the same
contract with the map-under-a-mutex pattern for a server that also
deletes on shutdown, list OnClosed among what runs on msquic's workers
rather than the pool, say that ~ClientQUIC now waits up to 1 s so the
peer hears the close, and that SendSync throws only for its own send.
The agent notes count the three new tests, add OnClosed's thread and
the destructor's new wait to the gotchas, and the forking tests and
their alarm() watchdog to the testing conventions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ShouldNotifyClosedConnectionQUIC: a server that deletes each accepted
ClientQUIC from inside its OnClosed callback serves 25 rounds of four
clients that connect, echo and close; all 100 are reported, each once,
and none is left. main then returns rather than _Exit, under a 45 s
watchdog, so msquic's RegistrationClose at exit is the check that
nothing was left open. Also, each reported once: a peer that exits
without closing (15 ms here, its closed port answering the server's
next packet), one that freezes with SIGSTOP and keeps its port (1.5 s,
the listener's idle timeout), and a server-side Stop(), which the
client's own OnClosed hears too; a connection that closed before
OnClosed was set is reported from a pool task rather than from inside
OnClosed; and an owner that deletes the connection first never hears
it. The vanishing peers are children forked before any thread exists.
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>
ShouldReturnFromSendSyncQUIC: 500 rounds of four fresh connections, each
sending one message on its first stream to a server that echoes it with
a FIN and drops the stream; every SendSync must return and every echo
come back. The race it guards needs the sending thread descheduled for
a round trip, so it is a stress test, not a deterministic one: against
the previous SendSync it failed 6 runs of 7, with one or two of the 2000
sends throwing each time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
~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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- --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>
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>
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>
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>
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>
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>
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>
New "Routes that cannot be enumerated" section, linked from both
listener sections since the hook and its precedence are identical on
each. Also records the HTTP/3 query-strip change and why a fallback has
to be a constructor argument on the ListenerAsync* wrappers.
The point of the feature is that a URL means the same thing over either
protocol, so the test states it that way: one route map plus one
fallback, registered with ListenerHTTP1 and ListenerHTTP, asked the same
eight questions, asserting identical answers.
Covers exact routes beating the fallback, query strings still routing to
the bare path, the fallback seeing the full target including the query,
the fallback choosing its own status (404 and 303), a throwing fallback
becoming a 500, and an unset fallback still producing the listener's own
synthetic 404.
Verified against a broken build both ways: dropping the fallback lookup
fails 9 checks, dropping the query-strip fails 2.
ListenerHTTP1's docs promise the same route map shape as ListenerHTTP so
a handler can be registered with both and served over either protocol.
That only holds if the dispatch rule is the same on both, so mirror it
here: exact `:path`, then the query-stripped path, then `fallback`, then
the synthetic 404.
The query-strip half is a behaviour change on this listener. `/thing?x=1`
previously 404'd even with `/thing` registered, while HTTP/1.1 routed it
— the asymmetry the shared route map was supposed to avoid. It also
matters for `fallback`: without it a query string would divert a
registered path to the fallback over HTTP/3 but not over HTTP/1.1.
`fallback` covers `routes` only. An unmatched WebTransport CONNECT is
still a 404 — a WT handler takes a session, not a request, so there is
nothing sensible to hand it.
MakeBidiHandler now reads the maps off `self` instead of taking them as
pointer parameters. `self` was already captured and unused, and this
mirrors how ListenerHTTP1 reaches its own state through Impl::owner.
The route map only answers paths that are known when the listener is
built. A route with an unbounded segment — /shop/<slug>, /order/<token>,
/posts/<id> — cannot be pre-registered: the token space is unbounded and
the product set changes while the server runs. Every such request became
a synthetic 404 that the application never got to see.
Add one optional member, called for anything `routes` missed, with the
full target still in request.path. Precedence is exact path, then the
query-stripped path, then fallback, then the 404 as before, so a
default-constructed listener behaves byte-identically and no call site
changes.
A hook rather than a pattern syntax: consumers that already have a
router — one shared between a wasm frontend and the server, so a URL
cannot mean different things to a crawler and to the app — keep using
it, and there is no second route table to disagree with the first.
PathWithoutQuery moves out of this file into :HTTP as an exported
PathWithoutQueryHTTP, since ListenerHTTP now needs the same split and a
fallback handler almost always does too.
ListenerAsyncHTTP1 starts accepting inside its constructor, so assigning
`listener.fallback` afterwards would race the accept loop; it gets a
constructor overload that installs the fallback before the thread
starts.