Repository navigation
refactor(mux-h2): drop the unreachable refusal of an oversized CONTINUATION block - #1923
Merged
FlorentinDUBOIS merged 2 commits intoNov 12, 2026
Conversation
…UATION block `ConnectionH2::handle_continuation_header_state` held a branch that refused a header block whose CONTINUATION frames carried it past `max_header_list_size` with RST_STREAM(REFUSED_STREAM). It could not run. `check_flood_or_return!` sits right before it and calls `H2FloodDetector::check_flood`, whose `accumulated header size` check is the same strict `>` on the same two values (`H2FloodDetector::accumulated_header_size` and `H2FloodConfig::max_header_list_size`), and no path of that function or of `ConnectionH2::handle_flood_violation` lets such a frame through: `maybe_reset_window` touches only the rate counters, and every violation ends in `ConnectionH2::goaway`, for a frontend and a backend connection alike. The oversized block therefore ends the connection with GOAWAY(ENHANCE_YOUR_CALM), which `an_oversized_header_block_ends_the_connection_without_refusing_its_stream` pins on both sides. Had it run, the branch would have dropped the stream's wire id with its slot not recycled, the state `ConnectionH2::check_slot_ownership` forbids. The branch is replaced by a `debug_assert!` stating what the flood check guarantees: a CONTINUATION that got past it leaves the accumulated size within `max_header_list_size`. The check that follows, a payload larger than the room left in `zero.storage` ending the connection, is unchanged, and release behaviour is unchanged. Nothing else became dead: `refuse_stream_and_discard` keeps its three callers, which now only pass `DiscardedFieldBlock::New` and `RstOrigin::Local`; `DiscardedFieldBlock::Continuation` is still built for the CONTINUATION of a block that is already being discarded (`ConnectionH2::handle_header_state`); and `HeaderBlockAccumulator::finish` keeps its callers in `ConnectionH2::handle_headers_frame`, though no production caller reads the bytes it returns any more. Both are left as they are. The prose that described the refusal as live (`doc/h2_mux_internals.md`, `doc/testing.md`, the mux `LIFECYCLE.md`, the `ConfigError::H2ThresholdBelowMinimum` and `H2FloodConfig::from_optional` docs, the `DiscardedFieldBlock` and accumulator docs) now says what is true, and the pinned `h2.rs` blocks of `doc/h2_mux_internals.md` follow the lines the deletion moved. The test's red recipe no longer describes the removed branch: deleting the `check_flood_or_return!` now trips the new `debug_assert!`, and, with that deleted as well, the test's own `got Header` assertion. The test is renamed because nothing follows the end of the connection any more. Signed-off-by: Florentin Dubois <florentin.dubois@clever.cloud>
The "TO SEE THIS RED" recipes of the half-written-frame tests name the mutation that replaces `self.output.adopt_tail(kawa, tail);` in `ConnectionH2::handle_write` by `let _ = (kawa, tail);`, and quote the assertions that fail under it. They no longer fail where the recipes say. The `an H2 stream queues whole frames only` debug assertion of `h2_transmit::queued_data_payload`, added with the send credit given back for DATA dropped unsent (#1646), fires first in every test on the `ack_after_half_written_frame` fixture, so none reaches `h2_transmit::frame_tail`'s `a frame runs past the gathered bytes` or its own wire assertion. The recipes now say so. They also record that the same mutation, with debug assertions off (`CARGO_PROFILE_TEST_DEBUG_ASSERTIONS=false`), reaches the wire assertions, and that the removed-stream and peer-reset tests then fail with the three `frame type ... remain` messages they have always quoted. `a_deferred_ack_does_not_strand_reads_when_its_parked_stream_is_removed` passes in that configuration. Comments only: no assertion, fixture or production line changes. Signed-off-by: Florentin Dubois <florentin.dubois@clever.cloud>
FlorentinDUBOIS
merged commit Nov 12, 2026
4b7cefd
into
fix/h2-reset-stream-slot-recycle
46 checks passed
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.
Summary
Two clean-ups announced in #1922, stacked on its branch (
fix/h2-reset-stream-slot-recycle). No behaviour change in a release build.ConnectionH2::handle_continuation_header_state, which could not run, and correct the prose that described it as live.adopt_tailmutation produces now.Review this PR after #1922. If #1922 is squash-merged, this branch needs a rebase onto
mainbefore it merges.1. The unreachable CONTINUATION refusal
The branch refused a header block whose CONTINUATION frames carried it past
max_header_list_sizewithRST_STREAM(REFUSED_STREAM). It sat right aftercheck_flood_or_return!, andH2FloodDetector::check_floodapplies the same strict>to the same two values (accumulated_header_size,max_header_list_size). Every flood violation ends inConnectionH2::goaway, on a frontend and on a backend connection alike. So an oversized block always ended the connection withGOAWAY(ENHANCE_YOUR_CALM)and the refusal never ran.Had it run, it would have dropped the stream's wire id without recycling its slot, the state
ConnectionH2::check_slot_ownershipforbids.Evidence that it was dead:
check_floodhas no earlyNoneand no "already reported" state;maybe_reset_windowtouches only the rate counters;handle_flood_violationdoes not branch on the position.panic!as the first statement of the branch,cargo test --workspace --locked --no-fail-fast -- --skip tests::fuzz_tests::exits 0 with 3469 passed, 0 failed, and the panic never fires.a_continuation_of_a_refused_header_block_is_discarded_with_it,a_refused_header_block_counts_its_first_fragment_toward_its_size) go through the discard path ofConnectionH2::handle_header_state, which is live and unchanged.What replaces it: a
debug_assert!stating what the flood check guarantees. The check that follows it (a payload larger than the room left inzero.storageends the connection) is unchanged.The test that pinned the order is renamed
an_oversized_header_block_ends_the_connection_without_refusing_its_stream; its body is unchanged. Its recipe was rewritten and seen red: deleting thecheck_flood_or_return!trips the newdebug_assert!, and with that deleted too, the test fails onthe oversized block must end the connection, got Header.Prose corrected:
doc/h2_mux_internals.md(its pinnedh2.rsblocks re-anchored),doc/testing.md,lib/src/protocol/mux/LIFECYCLE.md, and the doc comments ofDiscardedFieldBlock,HeaderBlockAccumulator,H2FloodConfig::from_optionalandConfigError::H2ThresholdBelowMinimum. OneLIFECYCLE.mdsentence about the live discard path is corrected in passing: it said a refused multi-frame block is not decoded, whileConnectionH2::pending_discarded_blockkeeps its fragments and decodes the block whole. Historical CHANGELOG entries are untouched, and no new entry is added: nothing user-visible changes.2. The
adopt_tailrecipesThe recipes name the mutation that replaces
self.output.adopt_tail(kawa, tail);bylet _ = (kawa, tail);and quote the wire assertions that fail under it. Since #1646, the debug assertionan H2 stream queues whole frames onlyofh2_transmit::queued_data_payloadfires first, in every test built onack_after_half_written_frame.Each recipe was replayed and now says what is observed:
an H2 stream queues whole frames only, in all of themCARGO_PROFILE_TEST_DEBUG_ASSERTIONS=falseframe type ... remain);a_deferred_ack_does_not_strand_reads_when_its_parked_stream_is_removedpassesThe matching sentence of
LIFECYCLE.md(where a violation of the frame-boundary invariant surfaces first) is corrected too. This commit changes comments and that one doc bullet only: no assertion, fixture or production line.Commands run
All on the pushed head, exit code 0:
cargo +nightly fmt --all -- --checkcargo clippy --all-targets --locked -- -D warningscargo clippy --all-targets --all-features --locked -- -D warningscargo check --release --locked -p sozu-libRUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p sozu-lib --lockedpython3 .github/scripts/check_doc_citations.py --base 95e109f88cargo test --workspace --locked --no-fail-fast -- --skip tests::fuzz_tests::: 3469 passed, 0 failed, 56 ignoredThe diff was reviewed by a second, independent reviewer that saw only the diff and replayed every recipe in a scratch copy. The Codex cross-check was not run.
Not in this PR
ConnectionH2::refuse_stream_and_discardnow receives one value for each of itserror,originanddiscardedparameters, and no production caller reads the bytesHeaderBlockAccumulator::finishreturns. Both are left as they are.ConnectionH2::handle_header_statetests the accumulated header size after its owncheck_flood_or_return!. That operand is redundant for the same reason, but it ends the connection, so it is harmless and stays.