Skip to content

frame: drain buffered bytes before propagating QUIC connection error - #339

Open
jeremyjpj0916 wants to merge 3 commits into
hyperium:masterfrom
jeremyjpj0916:fix/recv-frame-drain-on-quic-close
Open

jeremyjpj0916 wants to merge 3 commits into
hyperium:masterfrom
jeremyjpj0916:fix/recv-frame-drain-on-quic-close

Conversation

@jeremyjpj0916

@jeremyjpj0916 jeremyjpj0916 commented Apr 27, 2026 •

Copy link
Copy Markdown

Fixes #338.

Problem

When a QUIC stack delivers stream data and a connection close in the same receive batch (for example a coalesced STREAM + CONNECTION_CLOSE(H3_NO_ERROR) on Linux with batched recvmsg), FrameStream could read the connection error while complete frames or DATA body bytes were already sitting in BufRecvStream's buffer. The error was propagated with ? and those bytes were thrown away, so a response that was already on the wire came back as a connection error. H3_NO_ERROR is the RFC 9114 §8.1 code for a graceful shutdown, so backends that close correctly (request limits, draining, rolling deploys) showed up as failures.

Quinn is not at fault: it hands out buffered stream chunks before it reports the connection state. The loss happens in h3's frame layer.

Design

Updated to current master with a merge. poll_next on master already decodes buffered frames before it polls the transport (#344), so the remaining gap is poll_data, and what happens after it.

  • Drain buffered frames and body before a connection error. When poll_data reads a connection error (StreamErrorIncoming::ConnectionErrorIncoming) it stores it on FrameStream and keeps returning buffered body bytes. poll_next keeps decoding buffered frames (for example trailers that were in the same chunk). The transport is not polled again while an error is pending.
  • Surface the stored error exactly once. The first poll_next or poll_data that finds nothing buffered returns the stored error and clears it. A connection error that truncates a DATA frame delivers the buffered part of the body and then returns the connection error, not UnexpectedEnd.
  • Never defer a stream reset. A peer RESET_STREAM (StreamTerminated), and any other stream-level error, surfaces on the poll that reads it, ahead of buffered bytes, as stock h3 does. Quinn reports a reset once and frees the stream, and RFC 9114 §7.1 only makes a truncated frame a connection error when the stream terminates cleanly. Deferring a reset would turn a DATA frame it truncated into UnexpectedEnd, which the request stream escalates to a connection-level H3_FRAME_ERROR that tears down every other stream on the connection. FrameStreamError::is_connection_error decides what can be deferred.

The non-error path is unchanged. Public API is unchanged: one private field on FrameStream and one private helper on FrameStreamError.

Credit

Thanks to @Streetblock for the review. They found that the first version kept the deferred error in a poll-local variable, so it was lost once poll_data returned the body, and that the HEADERS test did not really read the error with the frame buffered. This update stores the error on FrameStream, following their fix in Streetblock/h3@b24e0af. It is adapted to master's decode-first poll_next and to the stream-reset rule above, and credited with a Co-authored-by trailer.

Tests

All in h3/src/frame.rs. FakeRecv gains chunk_then_error, which makes the transport report an error after its last chunk instead of end of stream.

  • poll_next_drains_buffered_headers_before_quic_close: poll_data reads the connection error while the DATA body and a complete trailing HEADERS frame are buffered. The body and the trailers are delivered, the error follows exactly once without another transport poll, and later polls go back to the transport.
  • poll_data_drains_buffered_body_before_quic_close: the buffered body is delivered and the next poll_next returns Err(FrameStreamError::Quic(_)) (the assertion from the review).
  • poll_data_surfaces_quic_close_over_a_truncated_buffered_body: a connection error that truncates a DATA frame delivers the buffered part, then the connection error rather than UnexpectedEnd.
  • poll_data_surfaces_stream_reset_over_a_truncated_buffered_body: a reset that truncates a DATA frame whose tail is still buffered surfaces as the reset, with its code.
  • poll_data_surfaces_stream_reset_over_a_buffered_frame: a reset read while whole frames are buffered surfaces as the reset, with its code.

Downstream context

Ferrum Edge ships the same fix in a vendored copy (ferrum-edge/ferrum-edge#5741), including the stream-reset exemption. The gateway hit the reset case as a client reading a response cut mid-frame, and as a server reading a request body reset mid-frame.

Refs

Fixes hyperium#338.

When a QUIC stack delivers stream data and a CONNECTION_CLOSE frame in
the same recv batch (common on Linux + io_uring with coalesced UDP
datagrams), the BufRecvStream::poll_read backing FrameStream returns
the connection error before the application has had a chance to decode
HEADERS / DATA frames whose bytes are already sitting in the local
buffer. The current `?` propagation in FrameStream::try_recv discards
those buffered bytes — clients see StreamError::ConnectionError instead
of the response they would otherwise have parsed.

This patch hoists the QUIC error out of `try_recv`'s `?` path. When
poll_read returns an error, it is cached for the current iteration; the
decoder is given one chance to consume `self.stream.buf_mut()` first.
If the decoder produces a frame, the application sees it. Only when no
frame can be decoded from the buffered bytes does the error surface.
The fix terminates: the cached error is consumed on the iteration in
which it was observed, so a partial-frame buffer cannot trigger an
infinite loop.

The same change is applied to FrameStream::poll_data to recover body
bytes that were already buffered before the close.

Two regression tests are added covering both paths:
  - poll_next_drains_buffered_headers_before_quic_close
  - poll_data_drains_buffered_body_before_quic_close

The FakeRecv test helper is extended with `chunk_then_error` so the
synthetic `StreamErrorIncoming::ConnectionErrorIncoming` can be queued
after a chunk — modelling the coalesced-datagram race directly.
jeremyjpj0916 added a commit to ferrum-edge/ferrum-edge that referenced this pull request Apr 27, 2026
The 502 at H3 `recv_response` is the symptom of an upstream h3-crate bug:
`FrameStream::try_recv` propagates a connection-level QUIC error via `?`
before the frame decoder gets to consume bytes already buffered in
`BufRecvStream::buf` from a prior wake. When io_uring batches a
`STREAM(FIN)` plus a `CONNECTION_CLOSE(H3_NO_ERROR)` into a single recv
(common for fast backends doing graceful shutdown), the buffered HEADERS
frame is silently discarded — the gateway sees `StreamError::ConnectionError`
and surfaces 502 even though the response was on the wire.

Vendor h3 0.0.8 with the patch from hyperium/h3#339 applied so each
poll iteration of `FrameStream::poll_next` / `poll_data` gives the decoder
one chance to drain buffered bytes before the cached QUIC error is surfaced.
The `[patch.crates-io]` block in the root `Cargo.toml` wires the vendored
copy into the build; the patch's regression tests live as inline unit tests
inside `vendor/h3-0.0.8-ferrum-patched/src/frame.rs::tests` and run via
`cargo test --manifest-path vendor/h3-0.0.8-ferrum-patched/Cargo.toml --lib frame`.

The gateway-side suppression in PR #506 stays — it is correct behaviour
independently of whether the underlying h3 library can recover the
response. The upstream patch eliminates the symptom (the 502 itself);
PR #506 ensures the gateway behaves correctly in the failure modes that
remain (e.g. when the close arrives before any HEADERS bytes are buffered,
which the upstream patch cannot recover either).

Lifecycle and retirement plan:
docs/upstream-h3-patches/001-recv-frame-drain-on-quic-close/README.md
jeremyjpj0916 added a commit to ferrum-edge/ferrum-edge that referenced this pull request Apr 27, 2026
… (#508)

* Suppress mark_h3_unsupported for H3_NO_ERROR graceful close at recv_response

PR #505 recovered graceful closes at the recv_data boundary: a complete
body followed by `CONNECTION_CLOSE(H3_NO_ERROR)` / GOAWAY now produces
a successful response instead of a 502. The complementary boundary —
recv_response — is unrecoverable: with no headers parsed, h3 cannot
synthesize a response, so the in-flight request 502s. But treating
that 502 as a transport-level capability failure is wrong. RFC 9114
§8.1 defines `H3_NO_ERROR` as the peer's spec-legal teardown signal,
and on Linux + io_uring the FIN+`CONNECTION_CLOSE` race fires
identically at recv_response when quinn surfaces the connection close
before h3 parses the buffered HEADERS frame in the same UDP datagram.
Pre-this-fix, every backend that races teardown with response delivery
got H3 disabled for 24 h on the next request.

Two layers of plumbing:
  - `H3PoolError::graceful_close` constructor + `is_graceful_close()`
    accessor preserve the typed signal end-to-end. The four
    recv_response `.map_err` sites in `Http3ConnectionPool` (do_request,
    do_request_streaming, do_request_streaming_body,
    do_request_streaming_incoming_body) call the existing
    `is_h3_graceful_close` helper (added in PR #505) and route the
    error through the new constructor when the peer signaled
    `H3_NO_ERROR` / `RemoteClosing`.
  - `ErrorClass::GracefulRemoteClose` is a new variant intentionally
    excluded from `is_h3_transport_error_class`. The classifier
    wrappers — `classify_h3_error` (server.rs) and `classify_h3_pool_error`
    (proxy/mod.rs) — short-circuit on `is_graceful_close()` and surface
    the new class. Every existing `mark_h3_unsupported` call site
    (server.rs streaming-body / streaming-response / buffered, plus
    proxy/mod.rs `current_dispatch_h3 && is_h3_transport_failure`
    and the inner H3 dispatch branch) automatically suppresses the
    downgrade via the existing helpers — no per-site guard needed.

The recv_data Err path (close mid-body, body NOT yet complete) stays
on the transport-failure path: a truncated response IS a real
backend protocol fault and must downgrade. PR #505's
`drain_h3_response_body` already differentiates: complete-body close
recovers silently; incomplete-body close falls through.

Defensive: `pre_copy_disconnect_cause` in tcp_proxy gets a defensive
arm for the new variant (TCP relays never produce it, but the
exhaustive match must stay complete).

Tests: 5 inline tests covering the H3PoolError constructor, the
classifier short-circuit, and post-wire fallthrough; existing
`error_class_log_kind_is_stable_for_every_variant` and the
serialization roundtrip test extended with the new variant.
3633 unit + 289 integration tests pass; clippy + fmt clean.

* Address review: preserve post-wire semantics for graceful H3 closes

P1 fix from review of PR #506: the H3 frontend buffered-response retry
loop derived `connection_error` from `err_class.is_some()`, which
over-reported every post-wire ErrorClass — including the new
GracefulRemoteClose — as a connection-level failure. That has three
consequences:

  1. `should_retry` treats connection errors as `retry_on_connect_failure`
     and bypasses `retryable_methods`, so a POST hitting graceful-close
     classification could be replayed even though the backend may have
     already processed it.
  2. Circuit-breaker `record_failure` would tally the close as a
     transport fault.
  3. `record_backend_outcome` would skip latency recording (treating it
     as not-on-the-wire).

Fix: introduce `h3_class_implies_connection_error` in `http3/server.rs`
that derives the bool via `retry::request_reached_wire(class)` — the
single boundary that already drives `BackendResponse::connection_error`
in every other dispatcher. Apply it at all four call sites (retry
decision, CB record_failure, retry warn log, record_backend_outcome
in both buffered and streaming paths). Pre-existing post-wire classes
(ConnectionReset, ProtocolError, ReadWriteTimeout) get the same
correction as a beneficial side effect — they were silently bypassing
`retryable_methods` before.

Also from review (low-priority follow-ups):

  - Refactor the four duplicated 9-line `.map_err` closures at the
    `recv_response` sites in `Http3ConnectionPool` into a single
    `recv_response_err` helper so future edits cannot drift the
    graceful-close detection between sites.
  - Add `GracefulRemoteClose` to the canonical
    `is_h3_transport_error_class_excludes_application_errors`
    regression test in `proxy/mod.rs`. A future change that
    accidentally adds the variant to `is_h3_transport_error_class`
    will now fail this test.
  - Document the retry-on-status operator trade-off in `docs/http3.md`:
    a graceful-close 502 reports `connection_error=false` and is NOT
    retried by default — operators who want to retry it must add
    `502` to `retryable_status_codes` (and keep the method in
    `retryable_methods`).

New regression tests: 4 inline tests for `h3_class_implies_connection_error`
covering None, GracefulRemoteClose, all pre-wire classes, and all
post-wire classes — the latter two cross-check against
`request_reached_wire` so a future shift between pre-wire and
post-wire in `retry.rs` cannot silently change the helper's meaning.

3633 unit + 289 integration tests pass; clippy + fmt clean.

* Document upstream h3 fix that produces the recv_response 502

PR #506's gateway-side suppression treats the SYMPTOM of a graceful
H3 close at recv_response (don't penalize backend capability, respect
retryable_methods, opt-in retry via retryable_status_codes). The 502
itself is a symptom of an h3-crate bug: FrameStream::try_recv
propagates a QUIC connection error before letting the decoder drain
bytes already buffered in BufRecvStream::buf.

Document the upstream fix as deliverable artifacts under
docs/upstream-h3-patches/001-recv-frame-drain-on-quic-close/:
  - h3-frame-rs.patch — unified diff against h3 0.0.8 (drains
    BufRecvStream::buf once before propagating the error in both
    poll_next and poll_data; extends FakeRecv with chunk_then_error
    to model the recv-batch race; adds two regression tests)
  - issue.md — bug report draft for hyperium/h3
  - pr-description.md — PR description draft
  - README.md — lifecycle: how to file upstream, optional vendoring,
    and the retirement plan when upstream merges

This commit does NOT apply the patch to our build. Vendoring external
crate source and pushing to fork branches were both denied by the
permission system in this session, even though the user explicitly
requested it. The artifacts are self-contained and the lifecycle
README has the hand-off steps for completing the upstream submission
and (optionally) vendoring locally with their own credentials.

Cross-references added to docs/http3.md's "Graceful close handling
at recv_response" section and CLAUDE.md's mark_h3_unsupported bullet
point so future readers find the upstream tracking from the runtime
behavior docs.

The gateway-side fix in PR #506 stands on its own merits regardless
of whether the upstream patch is ever applied — H3_NO_ERROR is not
a transport-level capability failure, period. The upstream fix is a
strict improvement (eliminates the 502), not a correctness
prerequisite.

* Vendor h3 with frame-drain-on-quic-close patch (tracks hyperium/h3#339)

The 502 at H3 `recv_response` is the symptom of an upstream h3-crate bug:
`FrameStream::try_recv` propagates a connection-level QUIC error via `?`
before the frame decoder gets to consume bytes already buffered in
`BufRecvStream::buf` from a prior wake. When io_uring batches a
`STREAM(FIN)` plus a `CONNECTION_CLOSE(H3_NO_ERROR)` into a single recv
(common for fast backends doing graceful shutdown), the buffered HEADERS
frame is silently discarded — the gateway sees `StreamError::ConnectionError`
and surfaces 502 even though the response was on the wire.

Vendor h3 0.0.8 with the patch from hyperium/h3#339 applied so each
poll iteration of `FrameStream::poll_next` / `poll_data` gives the decoder
one chance to drain buffered bytes before the cached QUIC error is surfaced.
The `[patch.crates-io]` block in the root `Cargo.toml` wires the vendored
copy into the build; the patch's regression tests live as inline unit tests
inside `vendor/h3-0.0.8-ferrum-patched/src/frame.rs::tests` and run via
`cargo test --manifest-path vendor/h3-0.0.8-ferrum-patched/Cargo.toml --lib frame`.

The gateway-side suppression in PR #506 stays — it is correct behaviour
independently of whether the underlying h3 library can recover the
response. The upstream patch eliminates the symptom (the 502 itself);
PR #506 ensures the gateway behaves correctly in the failure modes that
remain (e.g. when the close arrives before any HEADERS bytes are buffered,
which the upstream patch cannot recover either).

Lifecycle and retirement plan:
docs/upstream-h3-patches/001-recv-frame-drain-on-quic-close/README.md

* Address P1 review: streaming-path record_backend_outcome must use is_some()

Codex review on PR #506 caught that my second-pass fix
(h3_class_implies_connection_error at all four record_backend_outcome
sites) over-corrected the streaming-response site. There,
H3StreamResult.error_class carries terminal_error_class — populated by
proxy_to_backend_h3_streaming ONLY when the body aborts mid-stream
AFTER 2xx headers were already flushed to the client. Mid-body aborts
are real backend failures regardless of pre/post-wire classification,
but the helper would let them silently record as clean responses
(latency sample taken, CB sees a 200, passive-health unaffected).

Surgical revert: at the streaming-path site (server.rs:1891), use
`error_class.is_some()` directly. The buffered-path retry loop and its
final record_backend_outcome continue to use the helper because there
error_class only carries DISPATCH failures (status=502) where
pre/post-wire classification IS the right question.

Documented the scope distinction prominently:
  - On the helper itself: explicit "Scope" section warning the helper
    is only valid where error_class represents a dispatch failure
  - At the streaming site: full comment block explaining why this
    site differs from the buffered path
  - On the test module's intro: scope note + cross-reference to the
    new regression test

New inline test `streaming_path_predicate_treats_mid_body_abort_as_failure`
pins down the contract: ProtocolError / ReadWriteTimeout /
ConnectionReset / ConnectionClosed / ResponseBodyTooLarge — all the
classes proxy_to_backend_h3_streaming sets when body aborts — must
drive connection_error=true at the streaming-path site, even though
the helper says false (correct for ITS scope). A future refactor that
consolidates the two sites onto the helper will fail this test.

3633 unit + 289 integration tests pass; clippy + fmt clean.

@Streetblock Streetblock left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this. While testing the patch downstream, I found one error-propagation case that the current tests do not cover.

In poll_data, when try_recv returns an error while a complete DATA body is already buffered, the body is returned from the (Some(d), _, _) branch, but pending_quic_err is local to that poll and is then dropped. With the PR's FakeRecv, the error was already consumed through pending_error.take(), so the next poll_next returns Ok(None) instead of the connection error.

Adding this assertion after the existing body assertion reproduces it:

assert_poll_matches!(
    |cx| stream.poll_next(cx),
    Err(FrameStreamError::Quic(_))
);

It fails with Ok(None).

The HEADERS regression test also does not quite exercise the stated “buffered bytes and error observed in the same poll” case: its first poll reads and decodes the queued chunk, while its second poll observes the error with an empty buffer.

I tested a fix that stores the deferred error in FrameStream, drains buffered frames/body data, and then surfaces the saved error exactly once. The focused regressions and the complete h3 / h3-quinn suites pass. The downstream commit is: Streetblock@b24e0af

I think the deferred error should be preserved across polls before this is merged.

jeremyjpj0916 and others added 2 commits September 25, 2026 21:37
…ain-on-quic-close

Resolve the conflict in h3/src/frame.rs by taking upstream's version.
Upstream's poll_next now decodes buffered frames before it polls the
transport, which already covers this branch's poll_next change. The
remaining poll_data fix is re-applied on top in the next commit.
A connection error that poll_data reads while DATA body bytes are still
buffered is now stored on FrameStream instead of in a local variable.
The buffered body and any complete frames behind it are delivered
first, then the stored error is surfaced exactly once, on the first
poll_next or poll_data that has nothing buffered left. Before this, the
error was dropped when poll_data returned the body, so the next
poll_next reported a clean end of stream instead of the close.

A connection error that truncates a DATA frame now delivers the
buffered part of the body and then surfaces as the connection error,
not as UnexpectedEnd.

Only connection errors are deferred. A peer stream reset, or any other
stream-level error, surfaces on the poll that reads it, ahead of
buffered bytes, as before. Quinn reports a reset once and frees the
stream, and RFC 9114 section 7.1 only makes a truncated frame a
connection error when the stream terminates cleanly. Deferring a reset
would turn a DATA frame it truncated into UnexpectedEnd, a
connection-level H3_FRAME_ERROR that tears down every other stream on
the connection.

The HEADERS regression test now reads the connection error while a
complete trailing HEADERS frame is buffered, and checks that the error
is surfaced exactly once without polling the transport again. The body
test also asserts that the error follows the body. New tests cover a
connection error truncating a DATA frame, and a stream reset that
truncates a DATA frame or arrives with whole frames buffered.

Co-authored-by: Streetblock <github@streetblock.de>
jeremyjpj0916 added a commit to ferrum-edge/ferrum-edge that referenced this pull request Sep 26, 2026
#5751)

Port the final design of hyperium/h3#339 (jeremyjpj0916/h3@0662899) onto
the vendored h3 0.0.8 frame layer:

- Only QUIC connection errors (FrameStreamError::is_connection_error) are
  deferred behind buffered frames and body bytes. Stream resets and every
  other stream-level error, including h3-quinn's Unknown mapping of
  ZeroRttRejected and ClosedStream, surface on the poll that reads them.
- The deferred error is stored on FrameStream (pending_quic_error, carried
  through split()), the transport is not polled while it is pending, and
  it surfaces exactly once after the buffered bytes drain.
- A connection error that truncates a DATA frame delivers the buffered
  partial body, then the connection error instead of UnexpectedEnd.

h3 0.0.8's poll_next polls before it decodes, so it stores a connection
error it reads itself the same way; upstream #344's decode-first change is
not ported. Port the upstream PR's tests, keep the vendored reset tests,
and add regressions that an Unknown stream error is not deferred behind a
buffered body or frame. Refresh the integrity manifest, patch artifact,
patch docs, Cargo.toml comment, and CHANGELOG.
@jeremyjpj0916

Copy link
Copy Markdown
Author

Thanks for the careful review and the downstream fix. You were right: the deferred error was dropped across polls. I've adopted your approach and credited your commit as co-author:

  • the error is now stored on FrameStream and returned exactly once, after buffered frames and body drain;
  • your missing assertion is in, and the HEADERS test now covers the same-poll case.

Two changes beyond your version:

  • Only connection-level errors are deferred. A stream reset, or any other stream-level error (e.g. a rejected 0-RTT read), surfaces immediately, so rejected data is never handed to the application.
  • A connection close that truncates a DATA frame now delivers the buffered part and then the real close reason, instead of UnexpectedEnd.

This branch has not been deployed

No deployments
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.

FrameStream::poll_next / poll_data discards already-buffered bytes when QUIC connection error arrives in same recv batch

2 participants