Skip to content

Add support for HTTP/3 using nghttp3, experiment using claude - #6

Merged
pgit merged 36 commits into
masterfrom
http3
Aug 19, 2026
Merged

Add support for HTTP/3 using nghttp3, experiment using claude#6
pgit merged 36 commits into
masterfrom
http3

Conversation

@pgit

@pgit pgit commented Aug 19, 2026

Copy link
Copy Markdown
Owner

No description provided.

pgit and others added 30 commits August 1, 2026 19:42
First slice of a QUIC/HTTP3 server for anyhttp: enough plumbing for a
curl --http3-only client to complete the TLS handshake and receive a
hardcoded 200 OK on any request. Not yet wired into Session::Impl /
RequestHandler -- the QUICSession layer follows in the next commit.

- CMake: link libngtcp2, libnghttp3, libngtcp2_crypto_ossl into anyhttp.
- QuicHandler owns ngtcp2_conn + ngtcp2_crypto_ossl_ctx + nghttp3_conn
  driven by a boost::asio steady_timer over the shared UDP socket.
- Packet demux routes by DCID; on the first Initial we register the
  handler under both the client's DCID and every server-side SCID so
  retransmitted Initials don't spawn duplicates and CID rotation works.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rename QuicHandler to Http3Session, inherit from Session::Impl, and dispatch
incoming requests through the same RequestHandler used by the HTTP/1.1 and
HTTP/2 backends. Adds per-stream Http3Stream state, plus Http3Reader
(server::Request) and Http3Writer (server::Response) templates that mirror the
nghttp2 counterparts.

Request body chunks are queued in the stream and delivered via async_read_some.
Response body chunks are copied into stream-owned storage and handed to
nghttp3 via a data reader that pops one chunk per call -- nghttp3 keeps raw
pointers into those buffers, so they must outlive add_write_offset, which
accounts in framed bytes (frame header + payload) rather than payload alone.

Http3Session is registered with Server::Impl::m_sessions and its do_session()
awaits a per-connection sentinel timer, so QUIC connections participate in
server-wide shutdown just like the TCP-based sessions.

All 119 existing tests still pass. curl --http3-only /echo verified for
100..500000-byte payloads (md5 matches in both directions).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
listen_udp() now binds to the same address family and kernel-assigned port as
the TCP acceptor instead of hardcoding IPv6 wildcard. This lets the test fixture
use 127.0.0.2 (IPv4) for both TCP and UDP, which is needed for the new tests.

Generalise the endpoint formatter from tcp::endpoint to basic_endpoint<Proto>
so UDP endpoints format cleanly. Add ExternalH3 fixture with curl_http3 and
h2load_http3 tests that exercise the QUIC stack end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AWS-LC, nghttp3, ngtcp2, nghttp2 bumped; curl is now built from source
with --with-ngtcp2/--with-nghttp3 so HTTP/3 tests have a curl binary
that supports it. Also bind the host ~/.claude config into the
container and ignore .claude/ and Windows Zone.Identifier files.
ExternalH3 tests bind the server to 127.0.0.2 (distinct from the plain
TCP tests), so the test certs need that address in their SAN list too.
- Handle UDP_GRO-coalesced datagrams in udp_on_read() by splitting the
  recvmsg buffer back into individual QUIC packets using the GRO cmsg
  segment size before decoding.
- On error, buffer and send a CONNECTION_CLOSE packet, then keep the
  session alive for 3 PTO (schedule_close_timer/resend_conn_close) to
  resend it on retransmission and absorb late packets, instead of
  tearing the session down immediately. Sessions in the closing/
  draining period are now tracked until erase_quic_session() reaps
  them.
- Server::Impl::destroy() now destroys all active sessions (TCP and
  QUIC) so timers/async ops are cancelled and the io_context can drain.- arm_timer_from_ngtcp2() cancels the timer instead of arming it once
  the session is closed or ngtcp2 has no pending expiry.
- Wire ngtcp2's log_printf into spdlog trace output, add extra trace
  logging around read/write paths, and rename log_prefix() to
  logPrefix() for consistency.
CURL_PATH now points at the source-built /usr/local/bin/curl (has
HTTP/3 support). curl_http3 passes --cacert and requests the URL
twice under a timeout wrapper; add a curl_many test exercising
several concurrent HTTP/3 requests.
Adds Http3ClientSession/Http3ClientStream/Http3ClientWriter/Http3ClientReader
(client_impl_udp.cpp), structurally parallel to the existing server-side QUIC/
HTTP3 code but for the client role: its own connect()-ed UDP socket (no CID
demux needed), client-side TLS/QUIC handshake via ngtcp2_crypto_ossl, and
nghttp3 request submission / response parsing. Wires Protocol::h3 into
client::Client::async_connect() and parametrizes the ClientAsync test suite
over h1/h2/h3.

Also fixes several pre-existing bugs in the h3 server code, only surfaced now
that a real h3 client exercises it end-to-end:
- Http3Session::wake_write() called shared_from_this() from a Reader/Writer
  destructor running as part of the session's own teardown, throwing
  bad_weak_ptr; now captures a weak_ptr instead (same pattern applied to the
  new client code).
- A response destroyed without ever being submitted left the peer waiting
  forever with no signal; now aborts the stream at the transport level.
- Http3Session::destroy() never sent a CONNECTION_CLOSE, so a server
  shutdown left QUIC clients hanging until the 30s idle timeout; Server::
  Impl::destroy() now also closes sessions before the shared UDP socket so
  that packet can go out.

Known gaps, skipped for h3 with TODO comments: no write-side backpressure
yet (Backpressure, Cancellation*, WHEN_client_cancels_write_THEN_can_resume),
and a separate pre-existing use-after-free in server stream teardown when
the server is torn down mid-request (ResetServerDuringRequest).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds Protocol::h3 to External's parametrization and folds the h3-only curl/
h2load cases into the existing curl_https/curl_multiple_https/curl_many
tests (scheme + flag branch per protocol, wrapped in `timeout 1` for h3
specifically since QUIC handshakes can hang in ways h1/h2 curl doesn't).
`curl`/`curl_multiple` (plain http://) skip h3, which needs TLS.

nghttp2, h2spec, nc_crazy_chunked, h2load, and echo don't fit a clean
GetParam()-driven branch (a tool that only ever speaks one protocol, per-
protocol tuning that's mostly branches, or no protocol dependency at all)
and move to a new non-parametrized ExternalSingleProtocol fixture instead;
h2load keeps its three differently-tuned variants as separate tests rather
than force-unifying them.

Also fixes a latent bug carried over from the old ExternalH3.curl_many: it
sent a 4-byte placeholder body but asserted the echoed response matched the
full test file size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tocols

Verifies against pki/out/root.pem for HTTP/1.1 and HTTP/2 too, instead of
skipping certificate validation with -k -- matches what HTTP/3 already did.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
h2load negotiates HTTP/3 itself via --h3 regardless of URL scheme, so the
http:// URL used by the other two variants works here too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Now that all three variants share n=100/c=4/m=3, they collapse into one
TEST_P(External, h2load) branching only on the --h1/--h3 flag (h2load
defaults to HTTP/2, and negotiates h3 itself so the http:// URL still
works). Drops out of ExternalSingleProtocol, which now only holds tests
tied to a single protocol by tool constraints (h2spec, nc_crazy_chunked)
or that don't touch HTTP at all (echo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Only caller now that h2load is a single parametrized test case, so the
separate helper function just adds indirection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
External now only parametrizes over http11/h2 -- it no longer needs h3
skip branches for curl/curl_multiple, which are plain http:// only.

curl_https, curl_multiple_https, curl_many, and h2load all either require
TLS outright or already exercise h3 via a protocol-conditional scheme/flag,
so they move to a new ExternalTLS fixture (derived from External, reusing
its spawn_curl helper) parametrized over http11/h2/h3, keeping h3 coverage
for all four instead of losing it when External dropped h3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Enable ASAN in Debug builds and fix two UAF bugs it exposed:

- Http3ClientStream::fail() invokes handlers synchronously, which can
  resume a coroutine that reentrantly destroys the stream (via its
  Request/Response) mid-call. Hold a shared_from_this() reference for
  the duration of fail() so the stream survives until it returns.

- Neither Http3Stream nor Http3ClientStream detached their attached
  Writer/Reader on destruction, unlike NGHttp2Stream. When a session
  tore down its stream map while a suspended coroutine still held a
  Request/Response, that object's later destruction dereferenced the
  already-freed stream. Detach them in the destructor, mirroring the
  HTTP/2 implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…uplication

async_write() on both the QUIC client and server used to copy the buffer and
complete immediately, buffering unboundedly instead of respecting flow
control. Rework it into a queue (Http3PendingWrite) drained by
data_reader()/on_write_consumed(), so a write's handler only completes once
nghttp3/ngtcp2 actually place its bytes on the wire. Cancellation (e.g. via
operator||) now detaches just the completion handler through a token-based
cancellation slot; the underlying bytes keep draining in the background since
nghttp3 can't "un-offer" data once handed over.

Response/request-body read-side flow control was also a no-op: window credit
was granted immediately on receipt regardless of app consumption, so a peer's
send window never stayed exhausted. Stream-level credit is now granted only
once call_read_handler() actually delivers bytes to the app; connection-level
credit stays eager since it's shared with QPACK/control streams nghttp3
manages on its own.

Along the way, fixed two bugs this exposed:
- call_read_handler() resumed coroutines synchronously with no re-entrancy
  guard, unlike the HTTP/2 stream's equivalent, causing a stack overflow once
  enough data was buffered for backpressure to actually kick in.
- data_reader() could be asked for the same unconsumed bytes more than once
  before any consumption was confirmed, and nghttp3 would treat each repeat
  as additional stream data, duplicating small response bodies on the wire.
  Fixed by tracking bytes "offered" separately from "confirmed".

Un-skips ClientAsync.Backpressure/HTTP3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the per-write queue in both h3 stream implementations with flat
per-stream state. Only one async_write() may be active at a time, the usual
contract (Beast does the same), so the queue only ever held one entry plus
machinery for a case that no longer exists.

The caller's buffer is now referenced rather than copied up front, and drained
through a stream-owned 16KB chunk as nghttp3 actually consumes it. A 50MB
async_write() no longer costs a 50MB allocation and memcpy before the first
byte goes out; the copying is paced by the wire, mirroring how nghttp2 copies
into its frame buffer per callback. nghttp3 only ever sees pointers into that
chunk, never into the caller's buffer, so cancellation can complete
immediately and abandon the un-copied remainder -- the caller's buffer only has
to live until the handler fires, as asio requires.

Teardown got several things wrong, all of which the re-enabled tests below
caught:

  - Dropping a Request/Response without an explicit async_write({}) injected a
    FIN, presenting a partial body to the peer as a complete one. Reset the
    stream instead, as the HTTP/2 side does once its writer is gone with no EOF
    submitted, and fail the local read with partial_message.

  - Cancelling an EOF write abandoned the FIN permanently: eof_submitted stayed
    set, so nothing would ever send it and the stream sat half-open until the
    30s idle timeout. A FIN carries no data and cannot be un-sent -- it may just
    be waiting for credit -- so cancellation now detaches only the handler. A
    repeated send_eof adopts the pending FIN rather than starting a second one.

  - delete_writer() submitted its EOF even on an already-closed stream, leaving
    nghttp3 holding a FIN for a stream ngtcp2 had torn down, which it then
    offered for sending forever (a live-lock, not just a leak).

  - A stream dying before the response body was complete reported
    connection_reset; readers care that the rest is not coming, not which QUIC
    code carried the news, so report partial_message like HTTP/2 does.

Server-side, a handler that drops the Request without reading the body to its
end (not_found, say) now sends STOP_SENDING. Stream-level credit is only granted
as the application reads, so the peer would otherwise stall against a window
that never reopens -- WHEN_post_to_unknown_path_THEN_error_404 was hanging for
the full 30s idle timeout. QUIC half-closes just the read direction here, where
HTTP/2 has to reset the whole stream.

Also stop treating NGTCP2_ERR_STREAM_NOT_FOUND from ngtcp2_conn_writev_stream as
fatal: nghttp3 having data queued for a stream ngtcp2 already tore down kills
that stream, not the connection.

Re-enables WHEN_client_cancels_write_THEN_can_resume, CancellationContentLength
and CancellationRange for h3, whose skips claimed there was no write-side
backpressure to test. Whether a FIN slips out with the send window closed turns
out to depend on flow control timing, so neither Backpressure nor
WHEN_client_cancels_write_THEN_can_resume asserts it either way for h3; doing so
was a 1-in-5 flaky hang.

152 passing, 3 skipped, stable over repeated runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-enables ResetServerDuringRequest for h3. Under ASAN the test turned up
three distinct lifetime bugs, all variations on the same theme: a stream or
session is dereferenced after a user handler, invoked synchronously in the
middle of an operation, has destroyed it.

  - call_read_handler() kept the stream alive across the loop but not the
    session, then dereferenced the session in consume_stream() at the bottom.
    Delivering a read can resume a coroutine that drops the last Session
    reference, so the guard has to cover both.

  - delete_writer() called fail(), which can erase the stream from the session,
    and then went on to call maybe_close() on it.

  - find_stream() handed out a raw pointer, and callers routinely invoke user
    handlers on the result before touching it again -- h3_cb_stream_close does
    exactly that. It now returns a shared_ptr, which fixes that whole class of
    bug at the source rather than one call site at a time.

Two hangs on the same path, both of which left work permanently unfinishable
rather than merely slow:

  - When the peer's port goes away, the UDP receive loop exits on the resulting
    error but never tore the session down, so every request still waiting on
    that connection hung forever with nothing left running to complete it. The
    loop now closes the session on the way out; close() is idempotent and
    already fails all streams.

  - A read issued after the stream had already been failed was left pending,
    because call_read_handler() only ever completed on buffered data or EOF. It
    now reports partial_message once the stream is closed, matching what fail()
    delivers to a read that was already outstanding.

Verified with a separate ASAN build (RelWithDebInfo + -fsanitize=address): the
full suite is clean, 0 sanitizer reports. Note ASAN needs
detect_container_overflow=0 here -- the prebuilt gtest is not instrumented, so
libc++'s container annotations go out of sync and abort during gtest's static
registration, before main and unrelated to this code.

153 passing, 2 skipped, stable over repeated runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Beast's http::fields has no initializer-list constructor, so setting even two
headers takes a declaration plus a statement per header. fields() lets that be
spelled as an expression at the call site, which is what the async_submit()
signature wants anyway.

Values go through FieldValue, which accepts anything std::formattable: sizes
and counts no longer need a std::format("{}") at every call site. String-like
values are only referenced, never copied -- a FieldValue lives just long enough
for fields() to hand the bytes to Beast, which copies them -- so only formatted
values allocate. Copying is deleted, because the view may point into the
FieldValue's own buffer.

Also gives the h2spec handler a Content-Length, which it was submitting
without, and drops the now-redundant executor lookup in yield().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-header trace in async_submit() used mlogd(), which has no stream
context, so headers appeared detached from the request they belong to. Log
them with the stream's prefix instead, and highlight the name, matching how
received headers are already logged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SendMoreThanContentLength sent exactly one byte past the limit and asserted
nothing, so it only ever checked that nothing crashed. It now sends an
unbounded stream from a child coroutine and asserts the write actually fails
with connection_reset; renamed to say what it expects.

ServerYieldFirst raced read_response() against a 2s sleep, which made a hang
look like a pass. The sleep is gone.

ExternalSingleProtocol is renamed to ExternalCustom: the fixture is really
"external tests that do their own thing", and the comment claiming a
single-protocol rationale no longer matched its contents. Also drops a few
commented-out leftovers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same subject and SAN list as before, new validity window and key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pgit and others added 6 commits August 19, 2026 19:15
The workflow still pinned psedoc/anyhttp:0.25, which predates the aws-lc /
ngtcp2 / nghttp3 packages this branch needs; configuring against it fails in
src/ngtcp2/CMakeLists.txt.

gtest comes from the image and is not instrumented, so the ASAN job needs
detect_container_overflow=0 to avoid false positives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only pki/out/server.pem and server-key.pem were tracked, whitelisted against
the pki/out/* ignore rule. The files the code actually opens are
server-chain.pem (server_impl.cpp, server_impl_udp.cpp) and root.pem (used as
curl --cacert in test_server.cpp), and neither was in the repository, so TLS
failed on any fresh checkout.

Generate the whole chain from cmake/pki.cmake instead, and stop tracking the
certificates. pki/out is wiped before create.sh runs: the script skips any
stage whose output already exists, so a leftover leaf would otherwise end up
paired with a newly generated, unrelated CA.

The PKI is generated into the source tree rather than the build directory
because the certificate paths in the server and the tests are relative to the
project root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pgit
pgit merged commit 36bda1a into master Aug 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant