Add H3 quiche traffic handling tests and provide fixes#13213
Open
bneradt wants to merge 1 commit into
Open
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR improves HTTP/3 over QUIC handling for larger bodies and stream lifecycle edge cases, and adds H3 interoperability/timeout coverage around curl, Proxy Verifier, SNI, session tickets, and stream cleanup.
Changes:
- Keeps QUIC stream write data pending until accepted, schedules packet writes on stream updates, and adjusts H3 transaction cleanup.
- Drains UDP
recvmmsgbatches for edge-triggered sockets. - Adds/updates HTTP/3 gold tests and replay support for QUIC ports.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/iocore/net/quic/QUICStream.cc |
Adds pending send-block handling and delayed consumption. |
src/iocore/net/quic/QUICStreamVCAdapter.cc |
Defers write consumption and schedules stream write updates. |
src/iocore/net/quic/QUICStreamAdapter.cc |
Adds explicit consume path. |
src/iocore/net/QUICNetVConnection.cc |
Schedules packet writes when streams update. |
src/iocore/net/UnixUDPNet.cc |
Drains full recvmmsg batches. |
src/proxy/http3/Http3Transaction.cc |
Updates H3 transaction read/write/close lifecycle. |
src/proxy/http3/Http3StreamDataVIOAdaptor.cc |
Buffers and finalizes DATA delivery via persistent reader. |
src/proxy/http3/Http3HeaderVIOAdaptor.cc |
Tracks decoded header bytes and signals decode completion. |
src/proxy/http3/Http3App.cc |
Notifies transactions when streams close. |
include/** QUIC/H3 headers |
Adds APIs and state needed by the implementation changes. |
include/iocore/net/quic/Mock.h |
Updates QUIC mocks for new interfaces. |
src/iocore/net/P_UDPNet.h |
Updates UDP batch-read return type. |
src/iocore/net/P_QUICNetVConnection.h |
Declares stream update callback. |
tests/gold_tests/autest-site/ats_replay.test.ext |
Adds HTTP/3 port wiring for replay tests. |
tests/gold_tests/h3/* |
Adds H3 curl, Proxy Verifier, stream lifetime, timeout, SNI, and session ticket tests/replays. |
tests/gold_tests/timeout/* |
Updates QUIC feature gating for timeout tests. |
Contributor
|
[approve ci autest 2] |
778e8bb to
f69c806
Compare
bneradt
commented
May 29, 2026
10 tasks
f69c806 to
ba62f1b
Compare
ba62f1b to
6297520
Compare
6297520 to
061dc63
Compare
061dc63 to
b9f298a
Compare
b9f298a to
13b7dab
Compare
13b7dab to
0587b3a
Compare
0587b3a to
39ecc1a
Compare
Contributor
Author
|
[approve ci freebsd] |
30cb52f to
eb4e362
Compare
eb4e362 to
749b7c8
Compare
# Overview This patch extends the HTTP/3 autest coverage, using curl, Go, Python/aioquic, and Proxy Verifier HTTP/3 clients to generate their implementations of H3 traffic. It also adds request and response bodies of various sizes, including "large" 300k bodies to exercise multiple packet, buffer, and flow control ATS HTTP/3 implementations. It also exercises interesting requests and responses, such as HEAD, 204, PUT, DELETE, OPTIONS, H3-to-H2 origin forwarding, range responses over cached objects, and malformed HTTP/3 frame behavior. This patch also includes the various production fixes needed for these tests. # Issues Found and their Fixes ## UDP batches could stall large H3 transfers Large request and response bodies exposed a UDP receive starvation bug in the UDP read path. On systems using `recvmmsg()` with edge-triggered readiness, ATS could read one full batch of datagrams and then leave the rest queued in the kernel without another readable event to wake the QUIC stack. This changes `UDPNetProcessorInternal::read_multiple_messages_from_net()` in `src/iocore/net/UnixUDPNet.cc` to return whether the kernel supplied a full batch. `udp_read_from_net()` now processes a bounded number of full batches per event, preserving UDP batching for H3 while avoiding both unread UDP bursts and unbounded net-thread monopolization under sustained QUIC load. ## QUIC stream writes consumed data before quiche accepted it The stream write path consumed the `QUICStreamVCAdapter` write reader inside `_read()`, before `QUICStream::send_data()` knew whether `quiche_conn_stream_send()` had accepted the bytes. When quiche accepted only a partial write or returned a flow-control error, ATS could lose stream data and report write progress too early. This makes `QUICStream::send_data()` keep a pending `IOBufferBlock`/FIN pair until quiche reports successful consumption, and only then calls the new `QUICStreamAdapter::consume()` hook. The concrete reader accounting lives in `QUICStreamVCAdapter::_consume()`, while `QUICStream::has_data_to_send()`, `QUICStream::on_write()`, and `QUICNetVConnection::on_stream_updated()` make newly writable stream data schedule packet writes again. This also treats completed finite writes with only FIN left as writable stream state, so empty bodies and fully consumed bodies still close the H3 stream cleanly. ## QUIC stream reads could expose bytes beyond the VIO request The new H3-to-H2 and large-body tests exposed that `QUICStreamVCAdapter::_read()` could hand more data to the transaction than the read VIO requested. That was usually hidden by small bodies, but larger reads and protocol translation made finite request-body accounting fragile. This clamps cloned input blocks in `QUICStreamVCAdapter::_read()` to the requested and available byte count before filling the read VIO. The adapter now also checks for a missing reader before touching the read buffer, which makes late stream cleanup paths more defensive. ## H3 transaction cleanup raced with stream closure The timeout and stream lifetime tests exposed cases where an `HQTransaction` could be deleted while an event handler was still active, or while the QUIC stream adapter still had read/write cleanup to finish. That left later stream-close and timeout paths touching state that had already been torn down. This adds explicit transaction lifetime state in `HQTransaction`: `_closed`, `_stream_closed`, `_event_handler_active`, and `_is_write_buffer_flushed()`. `Http3App::on_stream_close()` now calls `HQTransaction::stream_closed()` while holding the transaction mutex, and `HQTransaction::_delete_if_possible()` waits until the transaction is done, the stream is closed or no longer readable, and pending writes have flushed before deleting the transaction. ## Malformed H3 streams could leave transactions behind The aioquic edge-case probes found malformed request streams that were correctly rejected at the H3 layer but still left partially constructed transactions attached to the session. Session teardown then either asserted because the transaction list was not empty or touched the H3 session after `Http3Session` had already nulled its network connection. This adds `HQSession::_close_transactions()` and drains any remaining transactions before destroying the H3 session-specific state. It also lets `Http3App::on_stream_close()` attach a cleanup callback to the transaction so the application stream map is erased when the transaction is actually destroyed, rather than when quiche first reports stream closure. ## H3 read completion could run before headers and DATA were settled The H3 request read path could signal completion before asynchronous QPACK header decode and buffered DATA delivery had finished updating the sink VIO. That showed up around HEAD, 204, and stream-close timing because the HTTP state machine needed a stable view of whether headers were decoded and whether a request body existed. This updates `Http3HeaderVIOAdaptor::_on_qpack_decode_complete()` to add the printed header length to the sink VIO and notify `Http3Transaction::on_header_decode_complete()`, which schedules the appropriate read event. `Http3StreamDataVIOAdaptor::finalize()` now uses a persistent reader, writes buffered DATA into the sink VIO exactly once, and updates `ndone`/`nbytes` consistently before the transaction is signaled. ## Malformed H3 frames were not consistently enforced The aioquic client can write raw QUIC stream data, which exposed gaps in ATS's HTTP/3 frame validation. Reserved frames on request streams, DATA-before-HEADERS, client-created push streams, and duplicate control streams did not all reliably close the QUIC connection with an H3 application error. This adds request-stream enforcement through `Http3ProtocolEnforcer` in `Http3Transaction`, recognizes reserved HTTP/3 frame types in `Http3Frame`, and routes connection-level errors through `Http3App::_handle_error()` and `Http3Transaction::_handle_error()` to close the QUIC connection. The transaction signal path now also avoids calling the HTTP state machine through closed transactions or the initial zero-byte write VIO created before the HTTP response handler is installed. ## H3-to-H2 origin traffic exposed H2 body accounting bugs The H3-to-H2 origin coverage found that HEAD and large request-body translation depended on HTTP/2 knowing both the original request method and the exact remaining write VIO byte count. Without that, an H2 origin stream could send DATA past the finite request body or mishandle no-body HEAD semantics. This records the sent request method in `Http2Stream` and uses it when validating response body framing. `Http2ConnectionState::send_a_data_frame()` now caps DATA payloads to the write VIO `ntodo()` value and sends END_STREAM when a finite body has been exhausted, even if the reader has additional buffered bytes. ## The QPACK static table had drifted from the standard table The HEAD, 204, and quic-go coverage exposed that ATS's static QPACK table was not the table used by external HTTP/3 implementations. The extra zstd entry and modified `accept-encoding` value in `src/proxy/http3/QPACK.cc` shifted later static indexes, so an externally encoded `:status 204` could decode as a different status. This restores the standard static table entries by using `accept-encoding: gzip, deflate, br` and removing the non-standard `content-encoding: zstd` entry. The new 204 cases in `tests/gold_tests/h3/replays/h3_proxy_verifier.replay.yaml`, `tests/gold_tests/h3/replays/h3_server_for_go_client.replay.yaml`, and `tests/gold_tests/h3/replays/h3_server_for_python_client.replay.yaml` cover this interoperability point with Proxy Verifier, quic-go, and aioquic.
749b7c8 to
eb3339f
Compare
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.
Overview
This patch extends the HTTP/3 autest coverage, using curl, Go,
Python/aioquic, and Proxy Verifier HTTP/3 clients to generate their
implementations of H3 traffic. It also adds request and response bodies
of various sizes, including "large" 300k bodies to exercise multiple
packet, buffer, and flow control ATS HTTP/3 implementations. It also
exercises interesting requests and responses, such as HEAD, 204, PUT,
DELETE, OPTIONS, H3-to-H2 origin forwarding, range responses over cached
objects, and malformed HTTP/3 frame behavior. This patch also includes
the various production fixes needed for these tests.
Issues Found and their Fixes
UDP batches could stall large H3 transfers
Large request and response bodies exposed a UDP receive starvation bug
in the UDP read path. On systems using
recvmmsg()withedge-triggered readiness, ATS could read one full batch of datagrams
and then leave the rest queued in the kernel without another readable
event to wake the QUIC stack.
This changes
UDPNetProcessorInternal::read_multiple_messages_from_net()insrc/iocore/net/UnixUDPNet.ccto return whether the kernel supplied afull batch.
udp_read_from_net()now processes a bounded number offull batches per event, preserving UDP batching for H3 while avoiding
both unread UDP bursts and unbounded net-thread monopolization under
sustained QUIC load.
QUIC stream writes consumed data before quiche accepted it
The stream write path consumed the
QUICStreamVCAdapterwrite readerinside
_read(), beforeQUICStream::send_data()knew whetherquiche_conn_stream_send()had accepted the bytes. When quiche acceptedonly a partial write or returned a flow-control error, ATS could lose
stream data and report write progress too early.
This makes
QUICStream::send_data()keep a pendingIOBufferBlock/FIN pair until quiche reports successful consumption,and only then calls the new
QUICStreamAdapter::consume()hook. Theconcrete reader accounting lives in
QUICStreamVCAdapter::_consume(),while
QUICStream::has_data_to_send(),QUICStream::on_write(), andQUICNetVConnection::on_stream_updated()make newly writable streamdata schedule packet writes again. This also treats completed finite
writes with only FIN left as writable stream state, so empty bodies and
fully consumed bodies still close the H3 stream cleanly.
QUIC stream reads could expose bytes beyond the VIO request
The new H3-to-H2 and large-body tests exposed that
QUICStreamVCAdapter::_read()could hand more data to the transactionthan the read VIO requested. That was usually hidden by small bodies,
but larger reads and protocol translation made finite request-body
accounting fragile.
This clamps cloned input blocks in
QUICStreamVCAdapter::_read()to therequested and available byte count before filling the read VIO. The
adapter now also checks for a missing reader before touching the read
buffer, which makes late stream cleanup paths more defensive.
H3 transaction cleanup raced with stream closure
The timeout and stream lifetime tests exposed cases where an
HQTransactioncould be deleted while an event handler was stillactive, or while the QUIC stream adapter still had read/write cleanup to
finish. That left later stream-close and timeout paths touching state
that had already been torn down.
This adds explicit transaction lifetime state in
HQTransaction:_closed,_stream_closed,_event_handler_active, and_is_write_buffer_flushed().Http3App::on_stream_close()now callsHQTransaction::stream_closed()while holding the transaction mutex,and
HQTransaction::_delete_if_possible()waits until the transactionis done, the stream is closed or no longer readable, and pending writes
have flushed before deleting the transaction.
Malformed H3 streams could leave transactions behind
The aioquic edge-case probes found malformed request streams that were
correctly rejected at the H3 layer but still left partially constructed
transactions attached to the session. Session teardown then either
asserted because the transaction list was not empty or touched the H3
session after
Http3Sessionhad already nulled its network connection.This adds
HQSession::_close_transactions()and drains any remainingtransactions before destroying the H3 session-specific state. It also
lets
Http3App::on_stream_close()attach a cleanup callback to thetransaction so the application stream map is erased when the transaction
is actually destroyed, rather than when quiche first reports stream
closure.
H3 read completion could run before headers and DATA were settled
The H3 request read path could signal completion before asynchronous
QPACK header decode and buffered DATA delivery had finished updating the
sink VIO. That showed up around HEAD, 204, and stream-close timing
because the HTTP state machine needed a stable view of whether headers
were decoded and whether a request body existed.
This updates
Http3HeaderVIOAdaptor::_on_qpack_decode_complete()to addthe printed header length to the sink VIO and notify
Http3Transaction::on_header_decode_complete(), which schedules theappropriate read event.
Http3StreamDataVIOAdaptor::finalize()now usesa persistent reader, writes buffered DATA into the sink VIO exactly
once, and updates
ndone/nbytesconsistently before the transactionis signaled.
Malformed H3 frames were not consistently enforced
The aioquic client can write raw QUIC stream data, which exposed gaps in
ATS's HTTP/3 frame validation. Reserved frames on request streams,
DATA-before-HEADERS, client-created push streams, and duplicate control
streams did not all reliably close the QUIC connection with an H3
application error.
This adds request-stream enforcement through
Http3ProtocolEnforcerinHttp3Transaction, recognizes reserved HTTP/3 frame types inHttp3Frame, and routes connection-level errors throughHttp3App::_handle_error()andHttp3Transaction::_handle_error()toclose the QUIC connection. The transaction signal path now also avoids
calling the HTTP state machine through closed transactions or the
initial zero-byte write VIO created before the HTTP response handler is
installed.
H3-to-H2 origin traffic exposed H2 body accounting bugs
The H3-to-H2 origin coverage found that HEAD and large request-body
translation depended on HTTP/2 knowing both the original request method
and the exact remaining write VIO byte count. Without that, an H2 origin
stream could send DATA past the finite request body or mishandle
no-body HEAD semantics.
This records the sent request method in
Http2Streamand uses it whenvalidating response body framing.
Http2ConnectionState::send_a_data_frame()now caps DATA payloads tothe write VIO
ntodo()value and sends END_STREAM when a finite bodyhas been exhausted, even if the reader has additional buffered bytes.
The QPACK static table had drifted from the standard table
The HEAD, 204, and quic-go coverage exposed that ATS's static QPACK
table was not the table used by external HTTP/3 implementations. The
extra zstd entry and modified
accept-encodingvalue insrc/proxy/http3/QPACK.ccshifted later static indexes, so anexternally encoded
:status 204could decode as a different status.This restores the standard static table entries by using
accept-encoding: gzip, deflate, brand removing the non-standardcontent-encoding: zstdentry. The new 204 cases intests/gold_tests/h3/replays/h3_proxy_verifier.replay.yaml,tests/gold_tests/h3/replays/h3_server_for_go_client.replay.yaml, andtests/gold_tests/h3/replays/h3_server_for_python_client.replay.yamlcover this interoperability point with Proxy Verifier, quic-go, and
aioquic.