Conversation
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>
… to utilize aggregated packet writes
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.