Skip to content

refactor(mux-h2): drop the unreachable refusal of an oversized CONTINUATION block - #1923

Merged
FlorentinDUBOIS merged 2 commits into
fix/h2-reset-stream-slot-recyclefrom
refactor/h2-dead-continuation-refusal
Nov 12, 2026
Merged

FlorentinDUBOIS merged 2 commits into
fix/h2-reset-stream-slot-recyclefrom
refactor/h2-dead-continuation-refusal

Conversation

@FlorentinDUBOIS

Copy link
Copy Markdown
Collaborator

Summary

Two clean-ups announced in #1922, stacked on its branch (fix/h2-reset-stream-slot-recycle). No behaviour change in a release build.

  1. Remove the refusal of an oversized CONTINUATION block in ConnectionH2::handle_continuation_header_state, which could not run, and correct the prose that described it as live.
  2. Make the "TO SEE THIS RED" recipes of the half-written-frame tests state the failure the adopt_tail mutation produces now.

Review this PR after #1922. If #1922 is squash-merged, this branch needs a rebase onto main before it merges.

1. The unreachable CONTINUATION refusal

The branch refused a header block whose CONTINUATION frames carried it past max_header_list_size with RST_STREAM(REFUSED_STREAM). It sat right after check_flood_or_return!, and H2FloodDetector::check_flood applies the same strict > to the same two values (accumulated_header_size, max_header_list_size). Every flood violation ends in ConnectionH2::goaway, on a frontend and on a backend connection alike. So an oversized block always ended the connection with GOAWAY(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_ownership forbids.

Evidence that it was dead:

  • Reading: check_flood has no early None and no "already reported" state; maybe_reset_window touches only the rate counters; handle_flood_violation does not branch on the position.
  • Running: with a 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.
  • No test reached it. The tests on a refused header block (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 of ConnectionH2::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 in zero.storage ends 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 the check_flood_or_return! trips the new debug_assert!, and with that deleted too, the test fails on the oversized block must end the connection, got Header.

Prose corrected: doc/h2_mux_internals.md (its pinned h2.rs blocks re-anchored), doc/testing.md, lib/src/protocol/mux/LIFECYCLE.md, and the doc comments of DiscardedFieldBlock, HeaderBlockAccumulator, H2FloodConfig::from_optional and ConfigError::H2ThresholdBelowMinimum. One LIFECYCLE.md sentence about the live discard path is corrected in passing: it said a refused multi-frame block is not decoded, while ConnectionH2::pending_discarded_block keeps 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_tail recipes

The recipes name the mutation that replaces self.output.adopt_tail(kawa, tail); by let _ = (kawa, tail); and quote the wire assertions that fail under it. Since #1646, the debug assertion an H2 stream queues whole frames only of h2_transmit::queued_data_payload fires first, in every test built on ack_after_half_written_frame.

Each recipe was replayed and now says what is observed:

Build Failure under the mutation
default (debug assertions on) an H2 stream queues whole frames only, in all of them
CARGO_PROFILE_TEST_DEBUG_ASSERTIONS=false the wire assertions (frame type ... remain); a_deferred_ack_does_not_strand_reads_when_its_parked_stream_is_removed passes

The 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 -- --check
  • cargo clippy --all-targets --locked -- -D warnings
  • cargo clippy --all-targets --all-features --locked -- -D warnings
  • cargo check --release --locked -p sozu-lib
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p sozu-lib --locked
  • python3 .github/scripts/check_doc_citations.py --base 95e109f88
  • cargo test --workspace --locked --no-fail-fast -- --skip tests::fuzz_tests::: 3469 passed, 0 failed, 56 ignored

The 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_discard now receives one value for each of its error, origin and discarded parameters, and no production caller reads the bytes HeaderBlockAccumulator::finish returns. Both are left as they are.
  • The discard path of ConnectionH2::handle_header_state tests the accumulated header size after its own check_flood_or_return!. That operand is redundant for the same reason, but it ends the connection, so it is harmless and stays.

…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
FlorentinDUBOIS merged commit 4b7cefd into fix/h2-reset-stream-slot-recycle Nov 12, 2026
46 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