//! H2 mux connection wrapper (RFC 9113).
//!
//! Owns wire-side connection state: HPACK encoder/decoder, peer settings,
//! flow window, GOAWAY/RST attribution, and orchestrates the
//! [`h2_flood_detector::H2FloodDetector`] backing the CVE-2023-44487 /
//! CVE-2024-27316 / CVE-2025-8671 mitigations. Stream
//! storage lives in the sibling `Context<L>` (`mux/mod.rs`); this module is
//! the canonical home for the edge-trigger discipline — paths that queue
//! bytes for a later event-loop pass must arm writable / signal pending
//! write (cf. `arm_writable()` at the deferred-control-frame sites and
//! `Readiness::arm_writable` / `Readiness::signal_pending_write` in `lib/src/lib.rs`).
use std::{
cell::Cell,
cmp::min,
collections::BTreeMap,
io::{IoSlice, Write as _},
time::{Duration, Instant},
};
/// Compile-time guard: `payload_len as usize` casts in the H2 parser assume at
/// least 32-bit pointer width. This prevents silent truncation on platforms
/// with smaller pointers (e.g. 16-bit embedded targets).
const _: () = assert!(
std::mem::size_of::<usize>() >= 4,
"sozu requires at least 32-bit pointers"
);
use rusty_ulid::Ulid;
use sozu_command::{logging::ansi_palette, ready::Ready};
use crate::metrics::names;
use crate::{
L7ListenerHandler, ListenerHandler, Protocol, Readiness, SessionMetrics,
protocol::mux::{
BackendChange, BackendStatus, Context, DebugEvent, DebugHistory, Endpoint,
GenericHttpStream, GlobalStreamId, MuxResult, Position, Stream, StreamId, StreamState,
converter, forcefully_terminate_answer,
h2_close::{self, CloseAction, FinalizeAction, TlsFlushPhase},
h2_control_tx,
h2_drain::{self, GracefulDrainDecision},
h2_flood_detector::{self, H2FloodConfig, H2FloodViolation},
h2_flow_control, h2_header_reassembly, h2_output, h2_scheduler, h2_stream_table,
h2_transmit,
h2_write_pass::{H2WritePass, H2WritePhase},
hpack_state,
parser::{self, Frame, FrameHeader, FrameType, H2Error, Headers, WindowUpdate},
pkawa, remove_backend_stream, serializer, set_default_answer,
shared::{
ERROR_GOAWAY_LINGER_MAX_BYTES, ERROR_GOAWAY_LINGER_TIMEOUT, EndStreamAction,
LINGER_MAX_BYTES, Linger, LingerRead, drain_discard, drain_tls_close_notify,
end_stream_decision,
},
update_readiness_after_read, update_readiness_after_write,
},
socket::{SocketHandler, SocketResult},
};
/// Protocol label + session descriptor used as a prefix on every
/// [`ConnectionH2`] log line. Matches the RUSTLS log-context convention:
/// `MUX-H2\tSession(...)\t >>>`. When colored output is enabled (via
/// [`ansi_palette`]) the label is wrapped in bold bright-white ANSI (uniform
/// across every protocol) and the session detail is rendered in light grey.
///
/// Fields included in the session block (chosen to surface the most common
/// H2 troubleshooting axes — flow stall, leaked stream, draining state,
/// peer-side gap, reset-flood exposure):
/// - `peer` — peer address via [`SocketHandler::peer_addr`](crate::socket::SocketHandler::peer_addr):
/// the snapshot the handler cached, not a live `getpeername(2)`. It therefore
/// survives the peer's RST (which is when these lines are read) and, on a
/// PROXY-protocol frontend, names the advertised client rather than the load
/// balancer — matching the `HTTPS`/`HTTP` line for the same request id
/// - `position` — `Server` / `Client(...)` orientation
/// - `state` — current [`H2State`]
/// - `streams` — number of in-flight streams on this connection
/// - `last_peer_id` — `highest_peer_stream_id` (gap to the peer's view)
/// - `window` — connection-level send window (RFC 9113 §6.9)
/// - `draining` — set after the first GOAWAY of a graceful shutdown
/// - `total_rst_streams_emitted_lifetime` — MadeYouReset counter (CVE-2025-8671)
/// - `total_rst_received_lifetime` — Rapid Reset counter (CVE-2023-44487)
/// - `readiness` — connection-level mio readiness snapshot
///
/// Computed lazily on each callsite — the helper only materialises when the
/// log level is enabled, so uncolored hot paths keep a single thread-local
/// read (the colored check) and one `format!` allocation.
macro_rules! log_context {
($self:expr) => {{
let (open, reset, grey, gray, white) = ansi_palette();
format!(
"[{ulid} - - -]\t{open}MUX-H2{reset}\t{grey}Session{reset}({gray}peer{reset}={white}{peer:?}{reset}, {gray}position{reset}={white}{position:?}{reset}, {gray}state{reset}={white}{state:?}{reset}, {gray}streams{reset}={white}{streams}{reset}, {gray}last_peer_id{reset}={white}{last_peer_id}{reset}, {gray}window{reset}={white}{window}{reset}, {gray}draining{reset}={white}{draining}{reset}, {gray}total_rst_streams_emitted_lifetime{reset}={white}{total_rst_streams_emitted_lifetime}{reset}, {gray}total_rst_received_lifetime{reset}={white}{total_rst_received_lifetime}{reset}, {gray}readiness{reset}={white}{readiness}{reset})\t >>>",
open = open,
reset = reset,
grey = grey,
gray = gray,
white = white,
ulid = $self.session_ulid,
peer = $self.peer_address,
position = $self.position,
state = $self.state,
streams = $self.stream_table.len(),
last_peer_id = $self.stream_table.highest_peer_stream_id(),
window = $self.flow_control.window(),
draining = $self.drain.draining(),
total_rst_streams_emitted_lifetime = $self.flood_detector.total_rst_streams_emitted_lifetime(),
total_rst_received_lifetime = $self.flood_detector.total_rst_received_lifetime(),
readiness = $self.readiness,
)
}};
}
/// Per-stream variant of [`log_context!`] used when a [`Stream`]'s
/// [`HttpContext`](crate::protocol::kawa_h1::editor::HttpContext) is in
/// scope. Populates the `request_id`, `cluster_id` and `backend_id` slots of
/// the bracket so the log line can be filtered by the specific H2 stream it
/// belongs to.
#[allow(unused_macros)]
macro_rules! log_context_stream {
($self:expr, $http_context:expr) => {{
let (open, reset, grey, gray, white) = ansi_palette();
format!(
"[{ulid} {req} {cluster} {backend}]\t{open}MUX-H2{reset}\t{grey}Session{reset}({gray}peer{reset}={white}{peer:?}{reset}, {gray}position{reset}={white}{position:?}{reset}, {gray}state{reset}={white}{state:?}{reset}, {gray}streams{reset}={white}{streams}{reset}, {gray}last_peer_id{reset}={white}{last_peer_id}{reset}, {gray}window{reset}={white}{window}{reset}, {gray}draining{reset}={white}{draining}{reset}, {gray}total_rst_streams_emitted_lifetime{reset}={white}{total_rst_streams_emitted_lifetime}{reset}, {gray}total_rst_received_lifetime{reset}={white}{total_rst_received_lifetime}{reset}, {gray}readiness{reset}={white}{readiness}{reset})\t >>>",
open = open,
reset = reset,
grey = grey,
gray = gray,
white = white,
ulid = $self.session_ulid,
req = $http_context.id,
cluster = $http_context.cluster_id.as_deref().unwrap_or("-"),
backend = $http_context.backend_id.as_deref().unwrap_or("-"),
peer = $self.peer_address,
position = $self.position,
state = $self.state,
streams = $self.stream_table.len(),
last_peer_id = $self.stream_table.highest_peer_stream_id(),
window = $self.flow_control.window(),
draining = $self.drain.draining(),
total_rst_streams_emitted_lifetime = $self.flood_detector.total_rst_streams_emitted_lifetime(),
total_rst_received_lifetime = $self.flood_detector.total_rst_received_lifetime(),
readiness = $self.readiness,
)
}};
}
/// Module-level prefix without session context, for logs emitted from
/// free functions, `H2ConnectionConfig` validation and other sites where no
/// `ConnectionH2` is in scope. Keeps the `MUX-H2` label consistent with
/// connection logs and honours the colored flag.
macro_rules! log_module_context {
() => {{
let (open, reset, _, _, _) = ansi_palette();
format!("{open}MUX-H2{reset}\t >>>", open = open, reset = reset)
}};
}
/// `if let Some(violation) = self.flood_detector.check_flood(self.now) { return self.handle_flood_violation(violation); }`
/// pattern wrapped as a single statement. Pure dispatch — the actual flood
/// thresholds and counters live inside `H2FloodDetector::check_flood` and
/// `ConnectionH2::handle_flood_violation`, which the macro does not touch.
/// Use this at every per-frame counter bump site so the wrapper stays
/// uniform and a future grep for "flood-check forgot to return" finds zero.
macro_rules! check_flood_or_return {
($self:expr) => {
if let Some(violation) = $self.flood_detector.check_flood($self.now) {
return $self.handle_flood_violation(violation);
}
};
}
// ── RFC 9113 §6.5.2 Settings Defaults ───────────────────────────────────────
const DEFAULT_HEADER_TABLE_SIZE: u32 = 4096;
const DEFAULT_MAX_CONCURRENT_STREAMS: u32 = 100;
pub(super) const DEFAULT_INITIAL_WINDOW_SIZE: u32 = (1 << 16) - 1; // 65535
const DEFAULT_MAX_FRAME_SIZE: u32 = 1 << 14; // 16384
// RFC 9113 §6.5.2: SETTINGS_MAX_FRAME_SIZE valid range [2^14, 2^24)
const MIN_MAX_FRAME_SIZE: u32 = 1 << 14; // 16384
const MAX_MAX_FRAME_SIZE: u32 = 1 << 24; // 16777216 (exclusive upper bound)
// RFC 9113 §6.9: maximum flow control window size (2^31 - 1)
const FLOW_CONTROL_MAX_WINDOW: u32 = (1 << 31) - 1;
// RFC 9113 §5.1.1: stream identifiers are 31-bit unsigned integers (2^31 - 1).
const STREAM_ID_MAX: u32 = 0x7FFF_FFFF;
/// Allocate the next locally-initiated stream identifier given the current
/// `last_stream_id` watermark, returning `(issued_id, next_last_stream_id)`
/// or `None` when the 31-bit space is exhausted.
///
/// RFC 9113 §5.1.1 reserves odd identifiers for clients and even identifiers
/// for servers. Sōzu never server-pushes, so in practice this helper is
/// called on the backend (client) side via [`ConnectionH2::new_stream_id`].
/// The server branch is kept symmetrical so the behaviour is exercised by
/// the unit tests and remains correct if push is ever enabled.
///
/// `last_stream_id` tracks the even "watermark" (2, 4, 6, ...). A client call
/// issues `watermark - 1` (odd), a server call issues `watermark - 2` (even).
/// The helper enforces two invariants:
/// - the issued identifier never exceeds `STREAM_ID_MAX` (2³¹ - 1); and
/// - the returned watermark is a valid starting point for the next call.
///
/// Exhaustion is reported with `None` to the caller, which must emit
/// GOAWAY(NO_ERROR) and stop issuing new streams on this connection
/// (see `start_stream` for the client-side drain path).
pub(super) fn next_stream_id(
last_stream_id: StreamId,
is_client: bool,
) -> Option<(StreamId, StreamId)> {
let next = last_stream_id.checked_add(2)?;
let issued = if is_client {
next.checked_sub(1)?
} else {
next.checked_sub(2)?
};
// RFC 9113 §5.1.1: stream identifiers are 31-bit. Reject any allocation
// whose issued value would exceed `STREAM_ID_MAX`; the watermark itself
// is allowed to sit at `STREAM_ID_MAX + 1` (the sentinel that fails the
// next call).
if issued > STREAM_ID_MAX {
return None;
}
// Post-conditions (RFC 9113 §5.1.1):
// - the issued id fits the 31-bit space;
// - the returned watermark is strictly greater than the id we issued, so a
// subsequent call cannot re-issue or regress;
// - role-parity: client ids are odd, server ids even. This holds ONLY when
// `last_stream_id` is an even watermark, which is the helper's documented
// contract and what production always maintains (`create_stream` rounds to
// `(stream_id + 2) & !1`; the connection initialises it to 0). The unit
// tests deliberately feed odd `last` values at the saturation boundary, so
// the parity check is gated on the watermark being even — a parity slip
// from an *even* watermark would let two roles collide on one id.
debug_assert!(
issued <= STREAM_ID_MAX,
"issued stream id must fit the 31-bit space"
);
debug_assert!(
next > issued,
"the next watermark must advance strictly past the issued id"
);
debug_assert!(
last_stream_id & 1 != 0 || (issued & 1 == 1) == is_client,
"from an even watermark, client ids must be odd and server ids even (RFC 9113 §5.1.1)"
);
Some((issued, next))
}
/// Connection receive window (16 MiB), advertised and not enforced (see
/// `lib/src/protocol/mux/h2_flow_control.rs`). Credit returns in one stream-0 WINDOW_UPDATE per
/// half window received, one per 8 MiB, well under peers' stream-0 flood limits (Chromium uses
/// 15 MB, Envoy 24 MiB). The buffer pool bounds memory: DATA is read only when its buffer has room.
const ENLARGED_CONNECTION_WINDOW: u32 = 16 * 1024 * 1024;
/// H2 client connection preface size: 24-byte magic + 9-byte SETTINGS frame header
pub const CLIENT_PREFACE_SIZE: usize = 24 + parser::FRAME_HEADER_SIZE;
// ── Flood Detection Thresholds (CVE mitigations) ────────────────────────────
//
// The CVE-tagged threshold defaults, `H2FloodConfig`, `H2FloodViolation` and
// `H2FloodDetector` itself moved into `h2_flood_detector.rs`, behind the same
// closed-API shape `hpack_state.rs`, `h2_flow_control.rs` and
// `h2_stream_table.rs` established in the three prior extraction steps.
// `MAX_HEADER_LIST_SIZE` stays here: `converter.rs` and `pkawa.rs` reference
// it directly (`h2::MAX_HEADER_LIST_SIZE`) as a general HPACK encode/decode
// safety ceiling, independent of any one connection's configured
// `H2FloodConfig::max_header_list_size` — moving it would widen this
// extraction into two unrelated modules for no benefit.
/// Maximum accumulated header block size across CONTINUATION frames (64KB).
/// Also the compile-time default for `H2FloodConfig::max_header_list_size`
/// (`h2_flood_detector.rs`).
pub(super) const MAX_HEADER_LIST_SIZE: usize = 65536;
/// Cumulative outbound progress (bytes) a window-stalled stream must drain to
/// clear its flow-control-stall deadline (M2 cumulative-stall budget). Below
/// this, a `WINDOW_UPDATE(+1)` drip that trickles a few bytes per idle period
/// cannot keep the slot alive: the deadline ages out and the reaper
/// RST(CANCEL)s the stream. Chosen as one max H2 DATA frame payload (16 KiB) —
/// a legitimate slow-but-steady transfer drains at least one frame per idle
/// period at any realistic bandwidth, while a drip attacker grants far less. A
/// `const`, not a config knob: `h2_stream_idle_timeout_seconds` is already the
/// operator dial for slow-link tolerance, and coupling a second knob invites
/// misconfiguration (high floor + low deadline = mass false reaps).
const FC_STALL_CLEAR_FLOOR: usize = 16 * 1024;
/// RFC 9113 §6.5.2: the size accounted against `SETTINGS_MAX_HEADER_LIST_SIZE`
/// is the uncompressed name + value octets PLUS a 32-octet overhead per field.
/// The per-field overhead is what bounds the field count under a fixed byte
/// budget — omitting it lets a peer materialize ~33× more fields than intended.
pub(super) const HEADER_FIELD_SIZE_OVERHEAD: usize = 32;
/// RFC 9113 §5.1.2: threshold of `REFUSED_STREAM` emissions per
/// [`BACKPRESSURE_WINDOW_DURATION`] that triggers back-pressure — at this
/// point we halve the advertised `SETTINGS_MAX_CONCURRENT_STREAMS` so the
/// peer throttles its request rate instead of paying the RST round-trip for
/// every new stream.
const BACKPRESSURE_REFUSAL_THRESHOLD: u32 = 1000;
/// Sliding window used to detect refusal bursts for SETTINGS back-pressure.
const BACKPRESSURE_WINDOW_DURATION: std::time::Duration = std::time::Duration::from_secs(60);
/// Default stream Vec shrink ratio: shrink when total > active * ratio.
const DEFAULT_STREAM_SHRINK_RATIO: u32 = 2;
/// Configurable H2 connection tuning parameters.
///
/// All values have safe defaults. When configured via listener config,
/// absent values fall back to compile-time defaults.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct H2ConnectionConfig {
/// Connection-level receive window size in bytes (RFC 9113 §6.9.2). Advertised, not enforced — see `h2_flow_control`.
pub initial_connection_window: u32,
/// Maximum concurrent streams (SETTINGS_MAX_CONCURRENT_STREAMS).
pub max_concurrent_streams: u32,
/// Shrink threshold ratio for recycled stream slots.
pub stream_shrink_ratio: u32,
}
impl Default for H2ConnectionConfig {
fn default() -> Self {
Self {
initial_connection_window: ENLARGED_CONNECTION_WINDOW,
max_concurrent_streams: DEFAULT_MAX_CONCURRENT_STREAMS,
stream_shrink_ratio: DEFAULT_STREAM_SHRINK_RATIO,
}
}
}
impl H2ConnectionConfig {
/// Create a validated config, clamping to safe bounds.
///
/// - `initial_connection_window`: clamped to \[65535, 2^31-1\] per RFC 9113 §6.9
/// - `max_concurrent_streams`: minimum 1
/// - `stream_shrink_ratio`: minimum 2 (1 would defeat slot recycling)
pub fn new(
initial_connection_window: u32,
max_concurrent_streams: u32,
stream_shrink_ratio: u32,
) -> Self {
let clamped_window =
initial_connection_window.clamp(DEFAULT_INITIAL_WINDOW_SIZE, FLOW_CONTROL_MAX_WINDOW);
if clamped_window != initial_connection_window {
warn!(
"{} h2_initial_connection_window {} clamped to [{}, {}]",
log_module_context!(),
initial_connection_window,
DEFAULT_INITIAL_WINDOW_SIZE,
FLOW_CONTROL_MAX_WINDOW
);
}
const MAX_SAFE_CONCURRENT_STREAMS: u32 = 10_000;
let clamped_streams = max_concurrent_streams.clamp(1, MAX_SAFE_CONCURRENT_STREAMS);
if max_concurrent_streams > MAX_SAFE_CONCURRENT_STREAMS {
error!(
"{} h2_max_concurrent_streams={} exceeds safe limit, clamped to {}",
log_module_context!(),
max_concurrent_streams,
MAX_SAFE_CONCURRENT_STREAMS
);
}
if clamped_streams != max_concurrent_streams
&& max_concurrent_streams <= MAX_SAFE_CONCURRENT_STREAMS
{
warn!(
"{} h2_max_concurrent_streams {} clamped to minimum 1",
log_module_context!(),
max_concurrent_streams
);
}
let clamped_ratio = stream_shrink_ratio.max(2);
if clamped_ratio != stream_shrink_ratio {
warn!(
"{} h2_stream_shrink_ratio {} clamped to minimum 2",
log_module_context!(),
stream_shrink_ratio
);
}
let config = Self {
initial_connection_window: clamped_window,
max_concurrent_streams: clamped_streams,
stream_shrink_ratio: clamped_ratio,
};
// Post-conditions matching the documented clamp ranges. The window must
// stay within RFC 9113 §6.9's [65535, 2^31-1] (a window outside this
// band desynchronises flow control with the peer); max_concurrent_streams
// must be >= 1 (zero would refuse every stream); shrink_ratio must be
// >= 2 (1 defeats slot recycling, the whole point of the knob).
debug_assert!(
(DEFAULT_INITIAL_WINDOW_SIZE..=FLOW_CONTROL_MAX_WINDOW)
.contains(&config.initial_connection_window),
"clamped connection window must lie within RFC 9113 §6.9 bounds"
);
debug_assert!(
config.max_concurrent_streams >= 1,
"clamped max_concurrent_streams must be >= 1"
);
debug_assert!(
config.stream_shrink_ratio >= 2,
"clamped stream_shrink_ratio must be >= 2 to keep slot recycling effective"
);
config
}
/// Create from optional config values, falling back to compile-time defaults.
/// Combines unwrap-or-default with validation clamping.
pub fn from_optional(
window: Option<u32>,
max_streams: Option<u32>,
shrink_ratio: Option<u32>,
) -> Self {
let defaults = Self::default();
Self::new(
window.unwrap_or(defaults.initial_connection_window),
max_streams.unwrap_or(defaults.max_concurrent_streams),
shrink_ratio.unwrap_or(defaults.stream_shrink_ratio),
)
}
}
/// Default pending WINDOW_UPDATE capacity (used in tests).
/// The actual per-connection cap is computed from `connection_config.max_concurrent_streams`.
#[cfg(test)]
const DEFAULT_MAX_PENDING_WINDOW_UPDATES: usize = 1 + DEFAULT_MAX_CONCURRENT_STREAMS as usize * 4;
/// RFC 9113 §6.5: maximum time (in seconds) to wait for SETTINGS ACK before
/// sending GOAWAY with SETTINGS_TIMEOUT error code.
const SETTINGS_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// Wire size of the SETTINGS frame `serializer::gen_settings` writes.
const SETTINGS_FRAME_SIZE: usize =
parser::FRAME_HEADER_SIZE + (parser::SETTINGS_ENTRY_SIZE * parser::SETTINGS_COUNT) as usize;
/// Wire size of one WINDOW_UPDATE frame.
const WINDOW_UPDATE_FRAME_SIZE: usize =
parser::FRAME_HEADER_SIZE + parser::WINDOW_UPDATE_PAYLOAD_SIZE as usize;
/// Wire size of one RST_STREAM frame.
const RST_STREAM_FRAME_SIZE: usize =
parser::FRAME_HEADER_SIZE + parser::RST_STREAM_PAYLOAD_SIZE as usize;
/// Wire size of one GOAWAY frame without debug data.
const GOAWAY_FRAME_SIZE: usize = parser::FRAME_HEADER_SIZE + parser::GOAWAY_PAYLOAD_SIZE as usize;
#[inline(always)]
fn error_nom_to_h2(error: nom::Err<parser::ParserError>) -> H2Error {
match error {
nom::Err::Error(parser::ParserError {
kind: parser::ParserErrorKind::H2(e),
..
}) => e,
nom::Err::Failure(parser::ParserError {
kind: parser::ParserErrorKind::H2(e),
..
}) => e,
_ => H2Error::ProtocolError,
}
}
/// Distribute connection-level byte overhead proportionally to a single stream.
///
/// Overhead is distributed in proportion to the bytes this stream transferred
/// relative to the total across all active streams. A stream that transferred
/// 60% of total bytes gets 60% of the overhead.
///
/// `stream_bytes` and `total_bytes` are `(bytes_in, bytes_out)` tuples.
/// Falls back to even distribution (1/active_streams) when no stream has
/// transferred any bytes yet (total is zero).
///
/// A free function rather than a method: the seven `test_distribute_overhead_*`
/// cases drive this arithmetic directly, with no `ConnectionH2` fixture, the
/// same reason [`any_stream_id_matches`] is split out.
/// [`ConnectionH2::distribute_overhead`] is the `&mut self` wrapper the reset
/// paths use. [`ConnectionH2::try_recycle_server_stream`] calls this function
/// directly instead, which is a spelling choice and not a constraint: the
/// wrapper would credit the same shares at that site, because it reads
/// `self.stream_table.streams().len()` at call time — which is the same count,
/// the pass retiring nothing until after its loop — and its `len() <= 1`
/// cannot differ from the `len() == 1` spelled out there while the stream
/// being retired is still in the map.
fn distribute_overhead(
metrics: &mut SessionMetrics,
overhead_bin: &mut usize,
overhead_bout: &mut usize,
stream_bytes: (usize, usize),
total_bytes: (usize, usize),
active_streams: usize,
is_last_stream: bool,
) {
let share_in = if is_last_stream {
// Last stream gets all remaining overhead to avoid losing remainder bytes
// from integer division across earlier streams.
*overhead_bin
} else if let Some(share) = (*overhead_bin * stream_bytes.0).checked_div(total_bytes.0) {
// Clamp to remaining overhead — integer division rounding across multiple
// streams can cause accumulated shares to exceed the total.
share.min(*overhead_bin)
} else {
// No stream has transferred any inbound bytes — fall back to even split.
*overhead_bin / active_streams.max(1)
};
let share_out = if is_last_stream {
*overhead_bout
} else if let Some(share) = (*overhead_bout * stream_bytes.1).checked_div(total_bytes.1) {
share.min(*overhead_bout)
} else {
// No stream has transferred any outbound bytes — fall back to even split.
*overhead_bout / active_streams.max(1)
};
// Pre-condition: a stream can never be credited more overhead than remains
// in the pool — otherwise the `*overhead_b* -= share_*` below underflows
// (usize wraps to a huge value, corrupting connection-overhead accounting).
// Every branch above either takes the whole pool (last stream) or `.min`s
// against it, so this must hold.
debug_assert!(
share_in <= *overhead_bin,
"overhead-in share must not exceed the remaining overhead pool"
);
debug_assert!(
share_out <= *overhead_bout,
"overhead-out share must not exceed the remaining overhead pool"
);
let before_bin = *overhead_bin;
let before_bout = *overhead_bout;
metrics.bin += share_in;
metrics.bout += share_out;
*overhead_bin -= share_in;
*overhead_bout -= share_out;
// Post-condition: the pool shrinks by exactly the credited share (overhead
// is conserved, neither created nor lost). The last stream drains it to 0.
debug_assert_eq!(
*overhead_bin,
before_bin - share_in,
"overhead-in pool must decrease by exactly the credited share"
);
debug_assert_eq!(
*overhead_bout,
before_bout - share_out,
"overhead-out pool must decrease by exactly the credited share"
);
debug_assert!(
!is_last_stream || (*overhead_bin == 0 && *overhead_bout == 0),
"the last stream must drain the overhead pool to zero (no lost remainder)"
);
}
/// LIFECYCLE §9 invariant 16 probe: returns `true` if any open stream still
/// has outbound kawa bytes queued (`back.out` non-empty or `back.blocks`
/// non-drained).
///
/// Used by `finalize_write` to preserve `Ready::WRITABLE` across a voluntary
/// scheduler yield, and by `has_pending_write_full` to block shutdown-drain
/// while bytes are still owed to the frontend.
///
/// `.get()` rather than direct indexing: an unknown `GlobalStreamId` is
/// treated as "no pending bytes" rather than panicking — defence-in-depth
/// against a stream-removal race during shutdown.
fn any_stream_has_pending_back(
streams: &BTreeMap<StreamId, GlobalStreamId>,
context_streams: &[Stream],
) -> bool {
any_stream_id_matches(streams, |gid| {
context_streams
.get(gid)
.is_some_and(|s| !s.back.out.is_empty() || !s.back.blocks.is_empty())
})
}
/// Iteration core of [`any_stream_has_pending_back`], split out so the
/// invariant-16 dispatch is unit-testable without a full [`Stream`] fixture
/// (the existing test module only covers `H2FloodDetector`).
fn any_stream_id_matches<F>(streams: &BTreeMap<StreamId, GlobalStreamId>, mut probe: F) -> bool
where
F: FnMut(GlobalStreamId) -> bool,
{
streams.values().any(|gid| probe(*gid))
}
/// The send credit `kawa.out` still holds in whole, unsent DATA frames
/// ([`h2_transmit::queued_data_payload`]), as the `i32` both send windows
/// count in. Every one of those octets was debited from a window that never
/// exceeds 2^31-1, so the conversion cannot fail; if it ever did, answering
/// zero gives back too little rather than too much.
fn parked_data_credit(kawa: &GenericHttpStream) -> i32 {
let queued = h2_transmit::queued_data_payload(kawa);
let credit = i32::try_from(queued).unwrap_or(0);
debug_assert_eq!(
usize::try_from(credit).ok(),
Some(queued),
"queued DATA fits the window it was debited from"
);
credit
}
/// True when a stream still has response/upload bytes that could be put on the
/// wire — headers/body in flight, or a terminated-but-not-fully-flushed buffer.
/// Deliberately EXCLUDES `is_error()`/`rst_sent`: that disjunct is specific to
/// the priority-eligibility and write-loop gates (`write_streams`) and must stay
/// inline there; this 2-clause helper backs ONLY the window-stall arm.
fn has_sendable_response(kawa: &GenericHttpStream) -> bool {
kawa.is_main_phase() || (kawa.is_terminated() && !kawa.is_completed())
}
/// Outcome of the M2 cumulative-stall budget decision for one `write_streams`
/// pass on a window-stalled stream. Extracted from the `write_streams` arm so
/// the budget logic is unit-testable without a full `ConnectionH2` fixture
/// (mirrors the [`h2_stream_table::H2StreamTable::collect_timed_out`] extraction).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FcStallAction {
/// Clear both the deadline (`stream_fc_stalled_since`) and the progress
/// accumulator (`stream_fc_stalled_progress`) for this stream.
Clear,
/// Ensure the deadline is armed (WITHOUT refreshing an existing `Instant`)
/// and set the progress accumulator to `progress`.
Arm { progress: usize },
}
/// Decide what to do with a stream's flow-control-stall deadline + cumulative
/// progress accumulator on one write pass (M2 cumulative-stall budget).
///
/// - A genuinely open send window (`!outbound_window_blocked`) is a real
/// un-stall → [`FcStallAction::Clear`].
/// - While the window stays blocked, accumulate this pass's outbound drain
/// (`consumed`, clamped to `>= 0`) onto `prior_progress`. Once the cumulative
/// total reaches [`FC_STALL_CLEAR_FLOOR`] (a full DATA frame of real delivery)
/// → `Clear`; otherwise `Arm` with the running total. A `WINDOW_UPDATE(+1)`
/// drip adds ~1 byte/pass and never reaches the floor, so the deadline keeps
/// aging and the reaper eventually fires.
fn fc_stall_budget_decision(
outbound_window_blocked: bool,
consumed: i32,
prior_progress: Option<usize>,
) -> FcStallAction {
if !outbound_window_blocked {
return FcStallAction::Clear;
}
let progressed = prior_progress
.unwrap_or(0)
.saturating_add(consumed.max(0) as usize);
if progressed >= FC_STALL_CLEAR_FLOOR {
FcStallAction::Clear
} else {
FcStallAction::Arm {
progress: progressed,
}
}
}
#[derive(Debug)]
pub enum H2State {
ClientPreface,
ClientSettings,
ServerSettings,
Header,
Frame(FrameHeader),
ContinuationHeader(Headers),
ContinuationFrame(Headers),
GoAway,
Error,
Discard,
}
#[derive(Debug, Clone, Copy)]
pub struct H2Settings {
pub settings_header_table_size: u32,
pub settings_enable_push: bool,
pub settings_max_concurrent_streams: u32,
pub settings_initial_window_size: u32,
pub settings_max_frame_size: u32,
pub settings_max_header_list_size: u32,
/// RFC 8441
pub settings_enable_connect_protocol: bool,
/// RFC 9218
pub settings_no_rfc7540_priorities: bool,
}
impl Default for H2Settings {
fn default() -> Self {
Self {
settings_header_table_size: DEFAULT_HEADER_TABLE_SIZE,
settings_enable_push: false,
settings_max_concurrent_streams: DEFAULT_MAX_CONCURRENT_STREAMS,
settings_initial_window_size: DEFAULT_INITIAL_WINDOW_SIZE,
settings_max_frame_size: DEFAULT_MAX_FRAME_SIZE,
settings_max_header_list_size: MAX_HEADER_LIST_SIZE as u32,
settings_enable_connect_protocol: false,
settings_no_rfc7540_priorities: true,
}
}
}
/// Byte accounting for connection overhead attribution.
pub struct H2ByteAccounting {
/// Bytes read on the zero stream not yet attributed to a stream.
pub zero_bytes_read: usize,
/// Overhead bytes received (connection-level frames).
pub overhead_bin: usize,
/// Overhead bytes sent (connection-level frames).
pub overhead_bout: usize,
}
/// What `ConnectionH2::handle_data_frame` does with the payload of a DATA
/// frame of a backend response (`ConnectionH2::backend_response_content`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BackendResponseContent {
/// The response may carry content: the payload is forwarded.
Forwarded,
/// A response to HEAD, which SHOULD NOT carry content (RFC 9110
/// §9.3.2): the payload is discarded.
Discarded,
/// A 204 or a 304, which cannot carry content (RFC 9110 §15.3.5,
/// §15.4.5): a payload resets the backend stream.
Forbidden,
}
pub struct ConnectionH2 {
/// Connection/session ULID propagated from the parent [`super::Mux`]. Used to
/// stamp the session slot of the `[session req cluster backend]` log
/// prefix emitted by this module's `log_context!` / `log_context_stream!`
/// macros.
pub session_ulid: Ulid,
/// HPACK decoder/encoder pair and their reusable scratch buffers
/// (`converter_buf`, `lowercase_buf`, `cookie_buf`), encapsulated so
/// nothing outside `hpack_state.rs` can reach the raw fields — see
/// [`hpack_state::HpackState`]. The pass-ordering buffer that used to sit
/// beside them belongs to [`h2_scheduler::H2Scheduler`].
hpack: hpack_state::HpackState,
pub last_stream_id: StreamId,
pub local_settings: H2Settings,
pub peer_settings: H2Settings,
pub position: Position,
/// RFC 9218 priority state and the per-write-pass scheduling decision
/// (stream order, the same-urgency ready-incremental census, the
/// round-robin cursor), encapsulated so nothing outside
/// `h2_scheduler.rs` can reach the raw fields — see
/// [`h2_scheduler::H2Scheduler`].
scheduler: h2_scheduler::H2Scheduler,
pub readiness: Readiness,
/// Peer address of this connection, captured once at construction from
/// [`SocketHandler::peer_addr`](crate::socket::SocketHandler::peer_addr)
/// and never re-read.
///
/// This is the `peer=` slot of every line `log_context!` /
/// `log_context_stream!` render, and those two macros expand at 113
/// production callsites in this file — so sourcing the slot from
/// `self.socket` made *every logging method* socket-coupled, including
/// the ones that touch no I/O at all.
///
/// Both production handlers *prefer* a cached address and fall back to
/// a live `getpeername(2)` when they have none — `SessionTcpStream` and
/// `FrontRustls` each answer
/// `self.configured_peer.or_else(|| self.stream.peer_addr().ok())`
/// (`socket.rs`). **That fallback arm is reachable**, it is pinned by its
/// own tests, and `FrontRustls`' arm carries a comment saying so and
/// naming the test that fails without it. So this snapshot is not
/// unconditionally the same value a per-line call would have produced,
/// and nothing here may be read as licence to delete those arms.
///
/// It is at least as good on every path, which is the actual argument.
/// The snapshot is taken after the handshake, so wherever the fallback
/// would have answered, it answers here too — and it then survives the
/// peer's reset, where a later live lookup returns `ENOTCONN` and renders
/// `peer=None` on exactly the error lines an operator reads during an
/// incident. On the direct-HTTPS frontend route, where `HttpsSession::new`
/// seeds `peer_address` with a best-effort `peer_addr().ok()`, a line
/// emitted after a reset now renders `peer=Some(addr)` where it used to
/// render `peer=None`. That is a rendered-line change, and it is
/// `df52d83d`'s intent applied one level further in.
///
/// No production caller reaches [`SocketHandler::peer_addr`] on a bare
/// `mio::net::TcpStream`. The impl exists and `MioTcpStream` is
/// instantiated in production (`TcpStateMachine`, `tcp.rs`), but the TCP
/// proxy reads its peer through mio's inherent method; only tests call
/// the trait method on a bare stream.
pub(super) peer_address: Option<std::net::SocketAddr>,
pub state: H2State,
/// Wire `StreamId -> GlobalStreamId` map, `expect_read`/`expect_write`,
/// `highest_peer_stream_id`, `rst_sent`, and the per-stream
/// activity/flow-control-stall caches, encapsulated so nothing outside
/// `h2_stream_table.rs` can reach the raw fields — see
/// [`h2_stream_table::H2StreamTable`].
stream_table: h2_stream_table::H2StreamTable,
/// Configured idle timeout for this connection. The core never arms a
/// wheel entry itself: it publishes the next instant it wants to be called
/// back at through `ConnectionH2::poll_timeout`, and the embedder — the
/// `Mux` adapter — owns the `TimeoutContainer` that reflects it onto the
/// real timer. See `LIFECYCLE.md` §7.7.
pub timeout_duration: Duration,
/// Next instant this connection wants `timeout()` called at, or `None`
/// when it wants no timer at all. Always derived from
/// [`ConnectionH2::now`], never from a fresh clock read (invariant 20).
pub(super) timeout_deadline: Option<Instant>,
/// Connection-level flow control state (send window, receive tracking,
/// pending updates), encapsulated so nothing outside `h2_flow_control.rs`
/// can reach the raw fields — see [`h2_flow_control::H2FlowControl`].
flow_control: h2_flow_control::H2FlowControl,
/// RFC 7541 §4.2 / §6.3 pending dynamic-table-size-update signal.
///
/// `Some(new_size)` when a peer SETTINGS frame adjusted
/// `SETTINGS_HEADER_TABLE_SIZE` and we have not yet prepended the
/// matching `001xxxxx` HPACK directive to a header block. Consumed and
/// cleared by `H2BlockConverter::emit_pending_size_update_if_new_block`
/// on the next `Block::StatusLine` or `Block::Header` encoded for the
/// connection. Until then the peer's decoder still has its previous
/// (possibly larger) table cap, so emitting is a correctness
/// requirement, not a nicety — see the RFC 9113 encoder-decoder
/// synchronisation contract (§6.5.2).
pub pending_table_size_update: Option<u32>,
/// `true` when the stream parked in `expect_write` still holds, in its
/// `kawa.out`, a HEADERS or CONTINUATION frame the wire has not started.
///
/// That block was HPACK-encoded when it was prepared, so the encoder's
/// dynamic table already holds its insertions. Only the parked stream can
/// hold unsent frames — a pass flushes each stream it prepares before the
/// next one and stops at the first stall — so if the next write pass
/// finds the park gone (the stream was removed) or its `kawa.out` empty
/// (a default answer or a forced termination cleared it), that block was
/// dropped and never will reach the peer, and the pass resets the
/// encoder's table before any other block is encoded
/// ([`Self::reset_encoder_table`], sozu-proxy/sozu#1627). Set where a
/// write pass parks a stream, cleared by the next pass's
/// `H2WritePhase::Start`.
parked_header_block: bool,
/// The DATA payload octets the stream parked in `expect_write` still
/// holds, whole and unsent, in its `kawa.out`
/// ([`h2_transmit::queued_data_payload`]).
///
/// `H2WritePhase::Prepare` debits both send windows when it encodes DATA,
/// before any of it is written. The same "only the parked stream holds
/// unsent frames" argument as [`Self::parked_header_block`] makes this
/// the whole of the credit spent on DATA the wire has not taken. When
/// those frames are dropped instead of sent — the stream removed, or its
/// `kawa.out` cleared — the peer never receives them and never returns
/// their credit, so the connection gives it back itself
/// ([`Self::refund_parked_data`], sozu-proxy/sozu#1641): eagerly in
/// [`Self::remove_dead_stream`], otherwise at the next pass's
/// `H2WritePhase::Start`. Set with `parked_header_block`, taken by
/// whichever comes first.
parked_data: i32,
/// Whether the one-shot stream-0 WINDOW_UPDATE that enlarges the
/// connection receive window to `H2ConnectionConfig::initial_connection_window`
/// was sent. A backend may send SETTINGS more than once; granting the
/// surplus again each time would push its send window past 2^31-1
/// (RFC 9113 §6.9.1).
connection_window_enlarged: bool,
/// RFC 9113 §6.8 double-GOAWAY drain bookkeeping, encapsulated so
/// nothing outside `h2_drain.rs` can reach the raw fields — see
/// [`h2_drain::H2DrainState`].
pub(super) drain: h2_drain::H2DrainState,
/// The read landing zone for every frame header and every stream-0
/// frame's own payload bytes, for the duration of ONE frame's read. It is
/// input only: control frames are serialised into `Self::output`
/// (#1604), and a HEADERS+CONTINUATION block is reassembled in
/// `Self::header_reassembly`; see that field's doc and
/// `h2_header_reassembly.rs` for why, and LIFECYCLE.md invariant 24 for
/// the bugs the two splits close.
pub zero: GenericHttpStream,
/// The single ordered output queue: every control frame, and the unsent
/// rest of the one stream frame a partial write cut, each queued whole.
/// Stream transmits send it first. See `h2_output.rs`.
output: h2_output::H2Output,
/// READABLE interest was withdrawn because [`Self::output`] reached the
/// read cap (`Self::output_read_cap`), and is restored once it drains
/// below it — HAProxy's `H2_CF_DEM_MROOM`.
reads_wait_for_output: bool,
/// Owned accumulator for an in-progress HEADERS+CONTINUATION field
/// block (`H2State::ContinuationHeader`/`ContinuationFrame`). Separated
/// from [`Self::zero`] so a control-frame flush that clears and reuses
/// `zero.storage` has no way to name — and therefore cannot corrupt —
/// the bytes reassembled so far. See `h2_header_reassembly.rs` and
/// LIFECYCLE.md invariant 24.
header_reassembly: h2_header_reassembly::HeaderBlockAccumulator,
/// Byte accounting for connection overhead attribution.
pub bytes: H2ByteAccounting,
/// CVE-mitigation flood/abuse counters (Rapid Reset, MadeYouReset,
/// CONTINUATION, Ping, Settings floods), encapsulated so nothing outside
/// `h2_flood_detector.rs` can reach the raw fields — see
/// [`h2_flood_detector::H2FloodDetector`].
flood_detector: h2_flood_detector::H2FloodDetector,
/// RFC 9113 §6.5: timestamp when we sent SETTINGS and are awaiting ACK.
/// If the peer does not ACK within SETTINGS_ACK_TIMEOUT, we send GOAWAY
/// with SettingsTimeout error.
pub settings_sent_at: Option<Instant>,
/// Queued proxy-emitted RST_STREAM frames, their pending-queue bound and
/// the lifetime count the session log reports,
/// encapsulated so nothing outside `h2_control_tx.rs` can reach the raw
/// fields — see [`h2_control_tx::H2ControlTx`]. Frames are queued while
/// refusing streams during `readable()`; the write happens in the
/// writable preamble so it cannot conflict with `zero.storage`'s use for
/// frame-payload discard.
control_tx: h2_control_tx::H2ControlTx,
/// Set by [`Self::refuse_stream_and_discard`], consumed once by the
/// `H2State::Discard` arm of [`Self::handle_read`]. Carries enough of the
/// refused frame's shape to still hand the connection-level HPACK
/// decoder a complete field block before its bytes are dropped — RFC
/// 9113 §4.3: field-compression state is scoped to the connection, not
/// the stream. See `LIFECYCLE.md`'s Discard section.
discarded_field_block: Option<(StreamId, DiscardedFieldBlock)>,
/// The stream and field-block bytes of a discarded header block whose
/// frame did not carry END_HEADERS: set by the `H2State::Discard` arm,
/// consumed by `handle_header_state`, where only a CONTINUATION frame
/// on that stream may come next (RFC 9113 §6.10). The block is decoded
/// once whole, to keep the HPACK dynamic table in sync (§4.3).
pending_discarded_block: Option<(StreamId, Vec<u8>)>,
/// True once we've asked rustls to emit TLS close_notify for this frontend.
close_notify_sent: bool,
/// The last socket write of the current [`H2Shell::writable`] pass
/// answered `WouldBlock`: the kernel refused it, so it owes this socket
/// its next WRITABLE edge, and [`Self::ensure_tls_flushed`] must not
/// synthesize one. Recorded by [`Self::note_write_status`] after every
/// write, and cleared when a pass starts, so a pass that wrote nothing
/// re-arms as before (sozu-proxy/sozu#1788).
kernel_refused_write: bool,
/// Per-listener H2 connection tuning (window size, max streams, shrink ratio).
pub connection_config: H2ConnectionConfig,
/// Maximum pending WINDOW_UPDATE entries before dropping.
/// Derived from `connection_config.max_concurrent_streams` at construction.
max_pending_window_updates: usize,
/// Ready incremental streams observed on the last completed write pass,
/// summed across urgency buckets (RFC 9218 §4). Sampled at the END of
/// `write_streams` from [`h2_scheduler::ReadyIncrementalCensus::ready_total`],
/// where the pass census is final: the entry call to
/// [`Self::gauge_connection_state`] runs *before* that census exists, so
/// sampling there would publish the previous pass's value.
///
/// Carried as connection state rather than emitted inline because the
/// aggregate it feeds must be a signed delta, not an absolute set — see
/// [`Self::gauge_connection_state`].
ready_incremental_streams: usize,
/// Last `(connection_window, active_streams, pending_window_updates,
/// ready_incremental_streams)` snapshot emitted by
/// [`Self::gauge_connection_state`]. The snapshot represents this
/// connection's *contribution* to the four aggregate gauges; each call
/// pushes the signed delta against this snapshot onto
/// [`Self::metric_events`] so the gauges sum across connections.
///
/// Stays `None` until the first emission.
/// [`Self::release_connection_gauges`] applies the negative of this
/// snapshot so the connection's contribution is always rebalanced to zero
/// on teardown — independent of which close path runs. `H2Shell`'s
/// [`Drop`] is what calls it.
last_gauge_snapshot: Option<(usize, usize, usize, usize)>,
/// Metric events this core produced and has not yet handed to its shell.
///
/// The core owns the *arithmetic* — which metric, and by how much — and
/// nothing else: writing to the worker-local `METRICS` aggregator is a
/// reach for ambient process state that a byte-in / byte-out core cannot
/// make, and that a deterministic simulator cannot observe. So the events
/// queue here and [`H2Shell`] drains them through
/// [`Self::drain_metric_events`], exactly as `protocol/udp/`'s core
/// queues `Output::Metric` for `crate::udp` to drain.
///
/// Nothing is lost if a pass forgets to drain: the queue is FIFO and the
/// values are signed deltas, so a later drain records the same sum, and
/// `H2Shell`'s [`Drop`] drains whatever is left. See
/// [`H2Shell::record_pending_metrics`].
metric_events: Vec<MetricEvent>,
/// Per-stream idle cap. Streams with no activity for longer than this are
/// RST_STREAM(CANCEL)'d by [`Self::cancel_timed_out_streams`]. Compared
/// against `stream_table`'s per-stream activity/flow-control-stall
/// caches — see `h2_stream_table::H2StreamTable::collect_timed_out`.
pub stream_idle_timeout: std::time::Duration,
/// RFC 9113 §5.1.2 back-pressure: count of stream refusals
/// (REFUSED_STREAM emitted via [`Self::refuse_stream_and_discard`]) within
/// the current back-pressure window. When the count exceeds
/// [`BACKPRESSURE_REFUSAL_THRESHOLD`] inside one
/// [`BACKPRESSURE_WINDOW_DURATION`] we halve the advertised
/// `SETTINGS_MAX_CONCURRENT_STREAMS` to signal the peer to slow down.
refuse_count_window: u32,
/// Start timestamp for the current back-pressure window.
refuse_window_start: Instant,
/// Set once we have halved `local_settings.settings_max_concurrent_streams`
/// in response to a refusal burst. Prevents the cap from collapsing to 0
/// on sustained abuse — a single halving per connection is sufficient to
/// signal back-pressure; further bursts trigger `EnhanceYourCalm`.
mcs_backpressure_applied: bool,
/// First and last client stream ids of the latest run of streams refused
/// under flood pressure (`H2FloodDetector::refuses_new_streams`,
/// `lib/src/protocol/mux/h2_flood_detector.rs`). Every new client stream
/// is refused while the soft state holds, so one run is a contiguous
/// range; the next accepted stream ends it, and the next refusal starts
/// a new run. Only that latest run is tracked: ids refused in an earlier
/// run are forgotten.
///
/// A RST_STREAM the client sends for one of these ids resets a stream
/// that never got a response, so `Self::handle_rst_stream_frame` counts
/// it as a pre-response reset. Without that, a client that ignores the
/// refusals and keeps opening and resetting streams would stop moving
/// toward the pre-response cap as soon as the soft state engages. A
/// reset of an id from an earlier run takes the untracked-stream arm
/// there instead and counts as a reset after a response, as it would
/// without the soft state.
flood_refused_streams: Option<(StreamId, StreamId)>,
/// Set once the client resets a stream of [`Self::flood_refused_streams`]:
/// it does not honour `REFUSED_STREAM`, so no further stream is refused
/// under flood pressure on this connection and it meets the flood limits
/// unchanged. Refusing it further would only add one RST_STREAM per
/// stream it opens ahead of the pre-response cap's GOAWAY.
///
/// A benign race sets it too: a client that cancels a stream just as
/// Sōzu refuses it sends a RST_STREAM that crosses the `REFUSED_STREAM`.
/// The flag is never cleared, so such a connection behaves for the rest
/// of its life as it would without the soft state.
flood_refusal_ignored: bool,
/// Clock snapshot for the pass currently executing — this connection's
/// mirror of [`Context::now`](super::Context::now).
///
/// Every time-based decision in this module reads this field. The only
/// code below [`Mux`](super::Mux) that samples the real clock is
/// [`Self::new`], which takes one sample to seed this field,
/// `refuse_window_start` and the flood detector's window — a connection
/// is constructed outside any pass, so there is no snapshot to inherit.
/// `h2_flood_detector::H2FloodDetector` carries no clock-sampling
/// exception of its own any more: its former test-only `impl Default`
/// was removed in the same changeset that extracted it, so
/// [`Self::new`] is the sole place under `ConnectionH2` that reads
/// the system clock.
///
/// Assigned from `context.now` at each public entry point
/// ([`H2Shell::readable`], [`H2Shell::writable`],
/// [`Self::cancel_timed_out_streams`], [`Self::start_stream`]) and from
/// the `now` parameter of [`Self::graceful_goaway`]. `Mux::shutting_down`
/// reaches it indirectly: it refreshes `Context::now`, and the
/// `readable()` call inside `drive_frontend_shutdown_io` mirrors that into
/// this field before the forced-close deadline is evaluated.
///
/// A field rather than a threaded parameter because the read sites are
/// unreachable from a `context`: [`Self::handle_ping_frame`] takes no
/// context at all, and the ten `check_flood_or_return!` sites are spread
/// across six frame handlers. The macro reads `$self.now`, so those ten
/// call sites stay as they were.
pub(super) now: Instant,
/// A [`Self::force_disconnect`] raised at a site with no socket in reach,
/// waiting for the shell to read `tls_wants_write` and settle it through
/// [`Self::force_disconnect_after_query`].
///
/// `None` is the resting state and the only state a connection is in
/// between passes: every entry point that can raise one settles before
/// returning, so this never survives a pass. See
/// [`H2ForceDisconnectTarget`] for why the target is parked here rather
/// than returned.
pending_force_disconnect: Option<H2ForceDisconnectTarget>,
/// How long the lingering close after a final GOAWAY may drain: the
/// listener's `request_timeout`, which a frontend connection is built
/// with. Unused on a backend connection.
linger_timeout: Duration,
/// Lingering-close state, set by [`H2Shell::writable`] when the
/// connection closes after its final GOAWAY. See
/// [`super::shared::Linger`].
pub(super) linger: Linger,
/// Whether the final GOAWAY this connection queued carried `NO_ERROR`.
/// Selects the lingering close's bounds ([`Self::linger_budget`]): one
/// that answers a protocol error or a flood lingers only briefly.
pub(super) graceful_goaway: bool,
}
/// Renders the peer address this connection snapshotted at construction,
/// where it used to render the socket behind it.
///
/// `socket_ref()` handed `mio::net::TcpStream`'s own `Debug` a concrete OS
/// handle — local address, peer address and file descriptor — which is more
/// than a reader of this struct needs and is a value a byte-in / byte-out core
/// cannot produce. `peer_address` is what every `log_context!` line already
/// renders, it survives the peer's reset where a live `getpeername(2)` answers
/// `ENOTCONN`, and it carries no descriptor. In the H2 simulator it is a fixed
/// address by construction, so no ephemeral port reaches a trace through here
/// any more.
///
/// No production body in this file reaches `SocketHandler::socket_ref` any
/// more, and `socket_mut` never had one. `ConnectionH2::snapshot_rtts` held
/// the last of them until issue #1339 Q11 moved the frontend RTT read out to
/// the shell (`ShellEndpoint::local_rtt` since #1590); the remaining spellings
/// are all in `mod tests`.
impl std::fmt::Debug for ConnectionH2 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConnectionH2")
.field("position", &self.position)
.field("state", &self.state)
.field("expect", &self.stream_table.expect_read())
.field("readiness", &self.readiness)
.field("local_settings", &self.local_settings)
.field("peer_settings", &self.peer_settings)
.field("peer_address", &self.peer_address)
.field("streams", self.stream_table.streams())
.field("zero", &self.zero.storage.meter(20))
.field(
"header_reassembly_in_progress",
&self.header_reassembly.is_in_progress(),
)
.field("header_reassembly_len", &self.header_reassembly.len())
.field("window", &self.flow_control.window())
.field(
"total_rst_streams_queued",
&self.control_tx.lifetime_queued(),
)
.finish()
}
}
/// One metric the H2 core asks its shell to record.
///
/// The metric macros of [`crate::metrics`] borrow the worker-local `METRICS`
/// aggregator through a `thread_local!`, which is exactly the ambient process
/// reach a byte-in / byte-out core gave up — and which a deterministic
/// simulator has no way to observe, so a core that wrote there could not be
/// driven by one. [`ConnectionH2`] therefore returns the metric instead of
/// emitting it, and [`H2Shell`] — the layer that already owns the socket, and
/// the layer `METRICS` legitimately belongs to — records it through
/// [`record_metric`].
///
/// The precedent is in-repo and predates this type: `protocol/udp/`'s core has
/// zero metric-macro sites and emits `Output::Metric(MetricEvent)`, which
/// `crate::udp`'s `Udp::record_metric` folds into the aggregator and
/// `sim/tests/udp_simulation.rs` folds into a shadow model instead.
///
/// Variants name what happened in the core's own terms; mapping them onto
/// metric keys is [`record_metric`]'s job and happens shell-side. The four
/// below are the H2 connection-level aggregate gauges, each carrying the
/// *signed delta* this connection contributes — never an absolute value. The
/// delta is what makes them summable across every live H2 connection; an
/// absolute `gauge!` would have each connection overwrite the last one's
/// value, which is the defect [`ConnectionH2::gauge_connection_state`]'s
/// snapshot arithmetic was introduced to fix.
///
/// The migration is incremental: a site that still calls a metric macro from
/// the core has not moved yet, and the two mechanisms compose — both end in
/// the same aggregator, so a half-migrated counter is not a wrong counter.
/// The type lives here rather than in a `protocol/mux/` module of its own
/// because every variant is H2-connection state; the first non-H2 core type
/// that needs one is what should promote it.
///
/// **`protocol::udp::MetricEvent` is a different type with the same name, and
/// deliberately so.** It is the UDP core's own vocabulary — flows admitted,
/// datagrams forwarded — and shares no variant with this one, nor any prospect
/// of sharing one. Converging them would force `crate::udp` and
/// `sim/tests/udp_simulation.rs` to match on H2 connection gauges, because
/// `protocol::udp::Output::Metric` embeds the type. The shared name is this
/// tree's existing idiom for "same role at the same boundary, different
/// protocol": `protocol::tcp_preread::Output` and `protocol::udp::Output`
/// already do exactly this, and `RejectReason`, `BackendStatus`, `Router` and
/// `SessionStatus` are each declared twice under `lib/src/` for the same
/// reason.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) enum MetricEvent {
/// Signed delta on [`names::h2::CONNECTION_WINDOW_BYTES`] — this
/// connection's available connection-level send window, clamped at 0 so
/// the aggregate represents capacity rather than deficit.
ConnectionWindowBytes(i64),
/// Signed delta on [`names::h2::CONNECTION_ACTIVE_STREAMS`] — this
/// connection's in-flight stream count.
ConnectionActiveStreams(i64),
/// Signed delta on [`names::h2::CONNECTION_PENDING_WINDOW_UPDATES`] —
/// this connection's queued, un-flushed per-stream WINDOW_UPDATE entries.
ConnectionPendingWindowUpdates(i64),
/// Signed delta on [`names::h2::STREAMS_READY_INCREMENTAL_BY_URGENCY`] —
/// this connection's streams that were ready to emit AND marked
/// incremental (RFC 9218 §4) on its last completed write pass.
StreamsReadyIncrementalByUrgency(i64),
// ── Frames written to the peer ──────────────────────────────────────
/// A SETTINGS frame was queued in the connection's output.
SettingsFrameSent,
/// A PING ACK was queued in the connection's output.
PingAckFrameSent,
/// A GOAWAY frame was serialised. Deliberately **not** folded into
/// [`Self::GoAwaySent`]: the decision to go away is unconditional, while
/// this fires only once `serializer::gen_goaway` succeeded, and a
/// serialisation failure must not be recorded as a frame on the wire.
GoAwayFrameSent,
/// The connection decided to send GOAWAY with this error code, whether or
/// not the frame then serialised.
GoAwaySent(H2Error),
/// A RST_STREAM was serialised with this error code. One event for two
/// counters — the frame-type total and the per-code breakdown — because
/// both macro lines it replaces were unconditional and adjacent.
RstStreamSent(H2Error),
/// `n` queued WINDOW_UPDATE frames were flushed in one control pass.
WindowUpdateFramesSent(i64),
// ── Frames read from the peer ───────────────────────────────────────
/// A frame arrived and parsed. The shell maps the type onto the
/// `h2.frames.rx.*` breakdown.
FrameReceived(FrameType),
/// A RST_STREAM arrived carrying this **raw wire** error code. Raw rather
/// than [`H2Error`] because a code outside RFC 9113 §7 has its own
/// `…unknown_error` bucket, and that mapping is the shell's.
RstStreamReceived(u32),
/// The peer reset a stream before the backend had begun answering it.
RstStreamReceivedBeforeResponse,
/// A new client stream was refused with `REFUSED_STREAM` because the
/// connection is in the soft state below the pre-response RST_STREAM cap.
StreamRefusedUnderFlood,
/// A GOAWAY arrived carrying this raw wire error code.
GoAwayReceived(u32),
// ── Control-queue back-pressure ─────────────────────────────────────
/// A WINDOW_UPDATE was dropped because the control queue was full.
WindowUpdateDropped,
/// A proxy-emitted RST_STREAM was dropped for the same reason.
RstStreamDropped,
// ── Stream lifecycle ────────────────────────────────────────────────
/// A request completed end to end over H2.
EndToEndH2Request,
/// A stream was reaped because its flow-control window had stalled.
StreamReapedWindowStall,
/// A reaped stalled stream had also consumed cumulative stall budget. A
/// strict subset of [`Self::StreamReapedWindowStall`], emitted alongside
/// it rather than instead of it.
StreamReapedStallBudget,
/// A stream was reaped for exceeding the per-stream idle cap.
StreamReapedIdleTimeout,
/// HEADERS arrived that no stream could be allocated for.
HeadersWithoutStream,
/// A header block failed to parse on a backend (client-position)
/// connection.
BackendHeaderParseError,
/// A header block failed to parse on a frontend (server-position)
/// connection.
FrontendHeaderParseError,
// ── Readiness re-arm sites ──────────────────────────────────────────
/// Writable readiness was re-armed because the control queue still owes
/// the peer bytes.
WritableRearmedControlQueue,
/// …because peer DATA has to be forwarded.
WritableRearmedPeerData,
/// …because peer HEADERS have to be forwarded.
WritableRearmedPeerHeaders,
/// …because a PRIORITY_UPDATE changed the scheduler's order.
WritableRearmedPriorityUpdate,
// ── Flood mitigation ────────────────────────────────────────────────
/// A flood threshold tripped. Carries the key rather than a semantic
/// discriminant because `H2FloodViolation` already holds `metric_key`
/// beside `reason` in one field, chosen at the construction site
/// precisely so the log line and the counter cannot drift apart. A
/// semantic variant here would duplicate that mapping and reintroduce the
/// drift the single field exists to prevent — the key is domain data the
/// core already receives, not something synthesised at the metric site.
FloodViolation { metric_key: &'static str },
// ── Wire conversion (`converter::H2BlockConverter`) ─────────────────
/// A DATA frame was written for a stream.
DataFrameSent,
/// A HEADERS frame was written for a stream.
HeadersFrameSent,
/// A CONTINUATION frame was written (RFC 9113 §6.10 header-block split).
ContinuationFrameSent,
/// A stream could not emit because its flow-control window was exhausted.
FlowControlStall,
/// The same stall, on a connection writing toward a backend. Scoped by
/// `H2BlockConverter::position_is_client`, captured at converter
/// construction so the stall sites do not each need the position.
BackendFlowControlPaused,
/// A header block was refused for exceeding the peer's declared budget.
HeadersRejectedBudgetOverrun,
// ── Header decoding (`pkawa`) ───────────────────────────────────────
/// A header was rejected by H2 header policy. Carries the per-reason key
/// alongside the roll-up, for the same reason [`Self::FloodViolation`]
/// does: `reject_metric_key!` derives it from the `RejectReason` variant
/// at the rejection site, and re-deriving it shell-side would duplicate a
/// mapping that exists once.
HeaderRejected { reason_key: &'static str },
/// A trailer field that could spoof a client-identity header was elided.
TrailerSpoofVectorElided,
/// A request trailer field named in `TRAILER_FORBIDDEN_FIELDS` was elided.
TrailerForbiddenFieldElided,
// ── Shared leaves reached from the core ─────────────────────────────
/// Bytes read on a frontend connection.
FrontendBytesIn(i64),
/// Bytes read on a backend connection.
BackendBytesIn(i64),
/// Bytes written on a frontend connection.
FrontendBytesOut(i64),
/// Bytes written on a backend connection.
BackendBytesOut(i64),
/// A request started being served. Records the lifetime counter and the
/// in-flight gauge's `+1` together: `ConnectionH2::handle_headers_frame`
/// emits them on consecutive unconditional lines, which is the same
/// criterion that allowed [`Self::RstStreamSent`] to fold its pair and
/// that forbade folding the GOAWAY one.
RequestStarted,
/// A request finished being served — the in-flight gauge's `-1`. Its
/// `+1` counterpart is [`Self::RequestStarted`] on the H2 path and a
/// `gauge_add!` macro on the H1 path, because `ConnectionH1` is its own
/// shell and `METRICS` is legitimately its own. The pair is therefore
/// deliberately split across two mechanisms; what makes that safe is that
/// each PATH balances, not that the pair travels together.
ActiveRequestFinished,
/// An access log could not be written.
AccessLogUnsent,
}
/// Fold one core-returned [`MetricEvent`] into the worker-local `METRICS`
/// aggregator.
///
/// Shell-side by construction, and deliberately the only place in this module
/// that turns a [`MetricEvent`] into a metric macro. [`ConnectionH2`] never
/// calls it: it queues events and lets the layer that owns the socket decide
/// when they are recorded. A second translation site would be a second place
/// for the gauge arithmetic to drift.
pub(super) fn record_metric(event: MetricEvent) {
match event {
MetricEvent::ConnectionWindowBytes(delta) => {
gauge_add!(names::h2::CONNECTION_WINDOW_BYTES, delta)
}
MetricEvent::ConnectionActiveStreams(delta) => {
gauge_add!(names::h2::CONNECTION_ACTIVE_STREAMS, delta)
}
MetricEvent::ConnectionPendingWindowUpdates(delta) => {
gauge_add!(names::h2::CONNECTION_PENDING_WINDOW_UPDATES, delta)
}
MetricEvent::StreamsReadyIncrementalByUrgency(delta) => {
gauge_add!(names::h2::STREAMS_READY_INCREMENTAL_BY_URGENCY, delta)
}
MetricEvent::SettingsFrameSent => incr!(names::h2::FRAMES_TX_SETTINGS),
MetricEvent::PingAckFrameSent => incr!(names::h2::FRAMES_TX_PING_ACK),
MetricEvent::GoAwayFrameSent => incr!(names::h2::FRAMES_TX_GOAWAY),
MetricEvent::GoAwaySent(error) => count!(metric_for_goaway_sent(error), 1),
MetricEvent::RstStreamSent(error) => {
incr!(names::h2::FRAMES_TX_RST_STREAM);
count!(metric_for_rst_stream_sent(error), 1);
}
MetricEvent::WindowUpdateFramesSent(frames) => {
count!(names::h2::FRAMES_TX_WINDOW_UPDATE, frames)
}
MetricEvent::FrameReceived(frame_type) => {
count!(h2_frame_rx_metric_key(frame_type), 1)
}
MetricEvent::RstStreamReceived(error_code) => {
count!(metric_for_rst_stream_received(error_code), 1)
}
MetricEvent::RstStreamReceivedBeforeResponse => {
count!(names::h2::RST_STREAM_RECEIVED_PRE_RESPONSE_START, 1)
}
MetricEvent::StreamRefusedUnderFlood => {
count!(names::h2::FLOOD_STREAM_REFUSED, 1)
}
MetricEvent::GoAwayReceived(error_code) => {
count!(metric_for_goaway_received(error_code), 1)
}
MetricEvent::WindowUpdateDropped => incr!(names::h2::WINDOW_UPDATE_DROPPED),
MetricEvent::RstStreamDropped => incr!(names::h2::RST_STREAM_DROPPED),
MetricEvent::EndToEndH2Request => incr!(names::http::E2E_H2),
MetricEvent::StreamReapedWindowStall => {
count!(names::h2::STREAMS_REAPED_WINDOW_STALL, 1)
}
MetricEvent::StreamReapedStallBudget => {
count!(names::h2::STREAMS_REAPED_STALL_BUDGET, 1)
}
MetricEvent::StreamReapedIdleTimeout => {
count!(names::h2::STREAMS_REAPED_IDLE_TIMEOUT, 1)
}
MetricEvent::HeadersWithoutStream => incr!(names::h2::HEADERS_NO_STREAM_ERROR),
MetricEvent::BackendHeaderParseError => incr!(names::http::BACKEND_PARSE_ERRORS),
MetricEvent::FrontendHeaderParseError => incr!(names::http::FRONTEND_PARSE_ERRORS),
MetricEvent::WritableRearmedControlQueue => {
incr!(names::h2::SIGNAL_WRITABLE_REARMED_CONTROL_QUEUE)
}
MetricEvent::WritableRearmedPeerData => {
incr!(names::h2::SIGNAL_WRITABLE_REARMED_PEER_DATA)
}
MetricEvent::WritableRearmedPeerHeaders => {
incr!(names::h2::SIGNAL_WRITABLE_REARMED_PEER_HEADERS)
}
MetricEvent::WritableRearmedPriorityUpdate => {
incr!(names::h2::SIGNAL_WRITABLE_REARMED_PRIORITY_UPDATE)
}
MetricEvent::FloodViolation { metric_key } => count!(metric_key, 1),
MetricEvent::DataFrameSent => incr!(names::h2::FRAMES_TX_DATA),
MetricEvent::HeadersFrameSent => incr!(names::h2::FRAMES_TX_HEADERS),
MetricEvent::ContinuationFrameSent => incr!(names::h2::FRAMES_TX_CONTINUATION),
MetricEvent::FlowControlStall => incr!(names::h2::FLOW_CONTROL_STALL),
MetricEvent::BackendFlowControlPaused => {
incr!(names::backend::FLOW_CONTROL_PAUSED)
}
MetricEvent::HeadersRejectedBudgetOverrun => {
incr!(names::h2::HEADERS_REJECTED_BUDGET_OVERRUN)
}
MetricEvent::HeaderRejected { reason_key } => {
incr!(names::h2::HEADERS_REJECTED_TOTAL);
incr!(reason_key);
}
MetricEvent::TrailerSpoofVectorElided => {
incr!(names::h2::TRAILER_SPOOF_VECTOR_ELIDED)
}
MetricEvent::TrailerForbiddenFieldElided => {
incr!(names::h2::TRAILER_FORBIDDEN_FIELD_ELIDED)
}
MetricEvent::FrontendBytesIn(bytes) => count!(names::backend::BYTES_IN, bytes),
MetricEvent::BackendBytesIn(bytes) => count!(names::backend::BACK_BYTES_IN, bytes),
MetricEvent::FrontendBytesOut(bytes) => count!(names::backend::BYTES_OUT, bytes),
MetricEvent::BackendBytesOut(bytes) => {
count!(names::backend::BACK_BYTES_OUT, bytes)
}
MetricEvent::RequestStarted => {
incr!(names::http::REQUESTS);
gauge_add!(names::http::ACTIVE_REQUESTS, 1);
}
MetricEvent::ActiveRequestFinished => {
gauge_add!(names::http::ACTIVE_REQUESTS, -1)
}
MetricEvent::AccessLogUnsent => incr!(names::access_logs::UNSENT),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum H2StreamId {
Zero,
Other { id: StreamId, gid: GlobalStreamId },
}
/// What [`ConnectionH2::poll_read_target`] wants its caller to do before it
/// may call [`ConnectionH2::handle_read`].
///
/// The three variants are the three shapes one frontend read pass takes, and
/// [`Self::Skip`] is deliberately not folded into a `Fill { amount: 0 }`: that
/// would have the caller issue a zero-length read, and
/// `update_readiness_after_read` reads its `(0, SocketResult::Continue)`
/// answer as "nothing arrived, stop" — so every frame carrying no payload (an
/// empty SETTINGS, an empty DATA, a SETTINGS ACK) would stop being parsed at
/// all. The variant is what stops a caller from having to know that.
#[derive(Debug, Clone, Copy)]
pub enum H2ReadTarget {
/// The core ended the pass by itself: a SETTINGS-ACK timeout, nothing owed
/// by the peer, or no room left for what is owed. No read, and no
/// [`ConnectionH2::handle_read`] — this is the pass's result.
Done(MuxResult),
/// Every byte the core is waiting for already sits in `stream_id`'s
/// buffer, because the frame in flight carries a zero-length payload.
/// Perform no read and answer with [`H2ReadOutcome::Skipped`].
Skip(H2StreamId),
/// Read into the space `read_space` returns for `stream_id`, which is
/// exactly `amount` bytes and never less than one, then answer with
/// [`H2ReadOutcome::Filled`].
Fill {
stream_id: H2StreamId,
amount: usize,
},
}
/// What the caller of [`ConnectionH2::poll_read_target`] actually did, handed
/// back to [`ConnectionH2::handle_read`]. Each variant answers the
/// [`H2ReadTarget`] of the same name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum H2ReadOutcome {
/// Answers [`H2ReadTarget::Skip`]: no read was performed.
Skipped,
/// Answers [`H2ReadTarget::Fill`]: `amount` bytes of space were offered,
/// and the socket returned `size` bytes with `status`.
///
/// `amount` is echoed back rather than re-read from `expect_read` inside
/// [`ConnectionH2::handle_read`], so the byte debt the core settles is the
/// one it actually offered and not whatever the table happens to hold by
/// then.
Filled {
amount: usize,
size: usize,
status: SocketResult,
},
}
/// The buffer an [`H2ReadTarget`] names, resolved to the `Kawa` owning it.
///
/// [`H2StreamId::Zero`] is the connection-level scratch every control frame
/// and every header block is read into; [`H2StreamId::Other`] is one
/// application stream's *read* buffer, which [`Stream::split`] reports as the
/// front buffer at [`Position::Server`] and the back buffer at
/// [`Position::Client`].
///
/// Taking `zero` and `streams` apart instead of a whole `&mut ConnectionH2`
/// and `&mut Context` is what makes the split compile: `ConnectionH2::socket`
/// and `Context::debug` stay borrowable while the buffer this returns is live,
/// which a method returning a borrow of all of `*self` would forbid.
fn read_buffer<'a>(
zero: &'a mut GenericHttpStream,
streams: &'a mut [Stream],
position: &Position,
stream_id: H2StreamId,
) -> &'a mut GenericHttpStream {
match stream_id {
H2StreamId::Zero => zero,
H2StreamId::Other {
gid: global_stream_id,
..
} => streams[global_stream_id].split(position).rbuffer,
}
}
/// What [`ConnectionH2::poll_write_target`] wants its caller to do before it
/// may call [`ConnectionH2::handle_write`].
///
/// The write half of [`H2ReadTarget`]'s protocol, with ONE forced divergence:
/// a `readable()` pass performs exactly one `socket_read`, so the read side is
/// a two-call protocol that ENDS with `handle_read`. A write pass performs an
/// unbounded number of `socket_write_vectored` calls — the pre-image's
/// `while !kawa.out.is_empty()` inside a loop over the scheduler's order — so
/// the write side is a DRIVE LOOP. [`ConnectionH2::handle_write`] settles one
/// transmit and answers nothing; the pass's result comes from [`Self::Done`]
/// or from the `ConnectionH2::finalize_write` the caller runs for
/// [`Self::Finalize`].
#[derive(Debug, Clone, Copy)]
pub enum H2WriteTarget {
/// The core ended the pass by itself. No write, no
/// [`ConnectionH2::handle_write`], and no finalize — this is the pass's
/// result.
///
/// Two sites, and neither may become a [`Self::Finalize`]: the resume
/// path's stall and the MadeYouReset emitted-RST cap trip. Folding either
/// into `Finalize` would run
/// LIFECYCLE §9 invariant 16's readiness policy over a pass that must not
/// reach it.
Done(MuxResult),
/// Gather `stream_id`'s queued blocks with [`h2_transmit::gather`], hand
/// the descriptors to `socket_write_vectored`, discharge them with
/// [`h2_transmit::confirm`], then answer with
/// [`ConnectionH2::handle_write`].
///
/// `stream_id` is never [`H2StreamId::Zero`]: `self.zero` is input only,
/// the resume phase parks and matches [`H2StreamId::Other`] only, and the
/// scheduler loop walks real stream ids. The transmit sends the ordered
/// output queue first (`ConnectionH2::gather_transmit`), so the rest of a
/// frame an earlier write cut always leaves ahead of the next frame.
Transmit { stream_id: H2StreamId },
/// The pass is over and owes the readiness decision of LIFECYCLE §9
/// invariant 16. Read `H2Shell::tls_wants_write`, hand it to
/// `ConnectionH2::finalize_write` alongside `socket_write` and
/// `bytes_written`, and drive the `H2FinalizeTarget` that answers; the
/// end of that protocol is the pass's result.
///
/// `bytes_written` rides along with `socket_write` because
/// `finalize_write` reads it as `made_progress`, and that is what selects
/// `FinalizeAction::RetainPendingBack` over `Quiesce`. It is accumulated
/// stream by stream inside the loop, so only the pass knows it.
Finalize {
socket_write: bool,
bytes_written: usize,
},
}
/// What one [`H2WriteTarget::Transmit`] write did, split the way
/// [`ConnectionH2::handle_write`] settles it.
///
/// The transmit sends the ordered output queue first, then the stream's
/// frames (`h2_transmit::gather_after`), so the socket's count is split at
/// the queue's length; and when the write stopped inside a stream frame, the
/// rest of that frame is adopted by the queue (#1604).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct H2Written {
/// What the socket took, all of it.
pub size: usize,
/// The part of `size` that came from the output queue. The rest,
/// `size - from_output`, is the stream's, and the caller already
/// confirmed exactly that much on the stream's `kawa`.
pub from_output: usize,
/// The unsent rest of the frame (or header block) the write cut, as
/// `h2_transmit::frame_tail` measured it before the confirm: still at the
/// front of the stream's `kawa.out`, and moved to the output queue by
/// [`ConnectionH2::handle_write`].
pub tail: usize,
}
/// What [`ConnectionH2::finalize_write`] wants its caller to do before it may
/// call [`ConnectionH2::finalize_write_after_flush`].
///
/// [`H2WriteTarget::Finalize`]'s own two-call protocol, built the way
/// [`H2ReadTarget`] builds the read's: the core answers a step, the shell
/// performs the socket work, and the core is told what the socket then said.
/// The step here is the TLS flush triple — read
/// `H2Shell::tls_wants_write`, perform or skip
/// `H2Shell::flush_tls_records`, read it again — whose middle is the
/// only *action* of the three TLS seams and therefore the one place this pass
/// has to invert control rather than take a value in.
///
/// [`Self::Flush`] and [`Self::SkipFlush`] are two variants rather than one
/// boolean because `h2_close::finalize_action` already distinguishes them:
/// they differ only in the middle step and share the whole of what follows.
#[derive(Debug, Clone, Copy)]
pub enum H2FinalizeTarget {
/// The pre-flush decision ended the pass: the RFC 9113 §6.8 graceful
/// GOAWAY, or one of the four readiness answers taken while rustls holds
/// nothing. No flush and no [`ConnectionH2::finalize_write_after_flush`]
/// — this is the pass's result.
Done(MuxResult),
/// rustls holds records and this pass wrote nothing: perform
/// `H2Shell::flush_tls_records`, read
/// `H2Shell::tls_wants_write` again, and answer with
/// [`ConnectionH2::finalize_write_after_flush`].
Flush,
/// rustls holds records but `socket_write_vectored` already attempted this
/// pass's flush as a side effect: read `H2Shell::tls_wants_write`
/// again WITHOUT spending a second empty-buffer write on it, and answer
/// with [`ConnectionH2::finalize_write_after_flush`].
///
/// That second read is not a repeat of the first even on this branch: the
/// vectored writes the pass already made are what may have changed the
/// answer.
SkipFlush,
}
/// What [`ConnectionH2::flush_pending_control_frames`] wants its caller to do
/// with the pass it just ran a control-frame preamble over.
///
/// [`Self::Stalled`] is what replaces the TLS re-arm the stalled-drain tails
/// used to perform inline. An output flush that stalled has to be followed by
/// a FRESH `H2Shell::tls_wants_write` — that write is what changes the
/// answer, so no value read before it can stand in — and the shell that owns
/// the socket is the layer that reads it.
///
/// [`Self::Proceed`] is the former `None`: it carries no `MuxResult` because
/// the preamble did not end the pass, and `writable()` falls through to its
/// `(H2State, Position)` dispatch.
#[derive(Debug, Clone, Copy)]
pub enum H2ControlFlushTarget {
/// Nothing owed, nothing stalled: `writable()` proceeds into its state
/// dispatch.
Proceed,
/// The preamble ended the pass by itself — a SETTINGS-ACK timeout GOAWAY
/// or an RST_STREAM lifetime-cap GOAWAY. This is the pass's result, and no TLS query
/// follows it.
Done(MuxResult),
/// The output flush stalled. Read `tls_wants_write`, hand it to
/// [`ConnectionH2::ensure_tls_flushed`], and answer the pass with
/// [`MuxResult::Continue`].
Stalled,
/// The connection's ordered output queue holds bytes: the control frames
/// this walk just serialised behind whatever was already queued. Drain
/// [`ConnectionH2::output_pending`] to the socket and answer with
/// [`ConnectionH2::handle_control_flush`].
FlushOutput,
}
/// What a control-flush walk answers once the
/// [`H2ControlFlushTarget::FlushOutput`] it raised has been performed.
///
/// [`H2ControlFlushTarget`] minus that variant. A separate enum rather than
/// the same one, for the reason [`H2WritableStateTarget`] gives against
/// reusing [`H2FinalizeTarget`]: `H2Shell::drive_control_flush` consumes every
/// `FlushOutput` itself, so sharing the enum would force a named-impossible arm
/// into the write pass's match — a reader would have to work out that it
/// cannot happen, and a later edit could make it happen without the match
/// noticing.
#[derive(Debug, Clone, Copy)]
enum ControlFlushOutcome {
/// Nothing owed, nothing stalled: the pass proceeds into its state
/// dispatch.
Proceed,
/// The walk ended the pass by itself.
Done(MuxResult),
/// The output flush stalled.
Stalled,
}
/// What [`ConnectionH2::dispatch_writable_state`] wants its caller to do with
/// the `(H2State, Position)` arm it just took.
///
/// The connection-level write pass's own protocol, built the way
/// [`H2FinalizeTarget`] builds the stream-level one: the core answers a step,
/// the shell performs the socket work, and the core is told what the socket
/// then said. [`Self::Flush`] carries NO answer with it — the post-flush query
/// can only enter the core through
/// [`ConnectionH2::dispatch_writable_state_after_flush`]'s own parameter,
/// which is what stops "does rustls hold records" and "did the kernel take
/// them" from ever being read off one binding. `h2_close::TlsFlushPhase`
/// states why those are two questions and what conflating them costs.
///
/// Not [`H2FinalizeTarget`] reused. That enum's `SkipFlush` answers a question
/// no close arm asks: nothing writes to the socket between this dispatch and
/// its flush, so no vectored write can have attempted it as a side effect, and
/// sharing the enum would force a named-impossible arm into the shell's match.
/// It is the argument `h2_close` already makes against widening `CloseAction`
/// with `FinalizeAction`'s readiness answers, applied the other way round.
#[derive(Debug, Clone, Copy)]
pub enum H2WritableStateTarget {
/// The arm ended the pass by itself: this is the pass's result. Every arm
/// but the two below answers this, the `H2State::GoAway` arm's
/// departed-peer and nothing-pending answers included.
Done(MuxResult),
/// The `H2State::GoAway` arm asks for the second flush of this pass:
/// perform `H2Shell::flush_tls_records`, read
/// `H2Shell::tls_wants_write` again, and answer with
/// [`ConnectionH2::dispatch_writable_state_after_flush`].
///
/// The preamble already attempted one flush this pass; this one re-checks
/// because a close is about to be decided on the answer. Both are
/// deliberate — see `h2_close`'s module doc.
Flush,
/// A proxying state: run `H2Shell::write_streams`, whose result is
/// the pass's. It is the caller's to run for the same reason the flush is,
/// and it is why the dispatch takes neither `Context` nor `Endpoint`.
WriteStreams,
}
/// The half of [`ConnectionH2::force_disconnect`] that still owes the socket a
/// question, parked until the shell can ask it.
///
/// The fourth member of the poll/handle family, and the only one that cannot
/// be *returned* by the function that raises it. [`H2FinalizeTarget`],
/// [`H2ControlFlushTarget`] and [`H2WritableStateTarget`] are answers a core
/// step hands straight back to its caller; this one is raised at seventeen
/// sites, seven of them inside [`ConnectionH2::handle_read`], whose `pub`
/// signature returns [`MuxResult`] and does not change. A target that cannot
/// ride the return value has to be parked on the connection instead, which is
/// why this is a field rather than a return type. The pairing is otherwise
/// identical: [`ConnectionH2::poll_force_disconnect`] takes it, the shell
/// reads [`SocketHandler::socket_wants_write`](crate::socket::SocketHandler::socket_wants_write),
/// and [`ConnectionH2::force_disconnect_after_query`] receives that answer as
/// its own parameter.
///
/// Both variants carry everything the settlement needs that the socket cannot
/// supply, so nothing is re-derived after the fact. `peer_gone` in particular
/// is computed at the raising site and carried: it reads `stream_table` and
/// `zero.storage`, and `ConnectionH2::remove_dead_stream` runs between the
/// raise and the settlement on the `ConnectionH2::reset_stream` paths, so a
/// value re-read at settlement time would not be the one the decision was
/// entitled to.
///
/// `Position::Client` is a variant rather than a settled answer because its
/// arm's `debug!` renders `wants_write=` too, and that line must report what
/// the socket says rather than a literal — see `ConnectionH2::peer_address`'s
/// neighbouring note on the same rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum H2ForceDisconnectTarget {
/// A `Position::Client` connection. The settlement logs and answers
/// [`MuxResult::Continue`]; the backend status and the `HUP` readiness
/// event were already written at the raising site, because neither needs
/// the socket.
Client,
/// A `Position::Server` connection, with `peer_gone_after_final_goaway()`
/// as the raising site read it. The settlement runs
/// `h2_close::force_disconnect_action` on the pair and performs the
/// `ReArmAndContinue` readiness write when that is the answer.
Server { peer_gone: bool },
}
/// The buffer an [`H2WriteTarget::Transmit`] names, resolved to the `Kawa`
/// owning it — the exact mirror of [`read_buffer`], and taking `zero` and
/// `streams` apart for the same reason: `ConnectionH2::socket` and
/// `Context::debug` must stay borrowable while the buffer this returns is
/// live, which a method returning a borrow of all of `*self` would forbid.
/// The write shell needs precisely that, since it holds the gathered
/// descriptors across its `socket_write_vectored`.
///
/// [`H2StreamId::Zero`] resolves to the connection-level scratch for
/// totality; no [`H2WriteTarget::Transmit`] names it.
fn write_buffer<'a>(
zero: &'a mut GenericHttpStream,
streams: &'a mut [Stream],
position: &Position,
stream_id: H2StreamId,
) -> &'a mut GenericHttpStream {
match stream_id {
H2StreamId::Zero => zero,
H2StreamId::Other {
gid: global_stream_id,
..
} => streams[global_stream_id].split(position).wbuffer,
}
}
/// The exact space [`H2ReadTarget::Fill`] offers its caller: `amount` bytes of
/// [`read_buffer`]'s free storage, never the whole of it.
///
/// The cap is load-bearing. `expect_read` is a byte debt for *one* frame, and
/// [`ConnectionH2::handle_read`] parses everything the read appended as that
/// frame's body — so a caller reading past `amount` would fold the next
/// frame's header into this frame's payload.
fn read_space<'a>(
zero: &'a mut GenericHttpStream,
streams: &'a mut [Stream],
position: &Position,
stream_id: H2StreamId,
amount: usize,
) -> &'a mut [u8] {
&mut read_buffer(zero, streams, position, stream_id)
.storage
.space()[..amount]
}
/// What [`ConnectionH2::refuse_stream_and_discard`] hands the `H2State::Discard`
/// arm of [`ConnectionH2::handle_read`] to locate a refused stream's HPACK field
/// block, so the connection decoder can still be advanced before the bytes are
/// dropped (RFC 9113 §4.3).
///
/// RFC 9113 §6.10: only a HEADERS frame carries PADDED/PRIORITY — a
/// CONTINUATION never does. `New` therefore keeps the whole flag byte so the
/// fragment can be located inside the raw payload the same way
/// [`parser::headers_frame`] would. `Continuation` already knows everything
/// accumulated from every *prior* frame in the block (copied out eagerly,
/// see the call site in `handle_continuation_header_state` for why it cannot
/// be a byte offset into `zero.storage` instead) and only needs this frame's
/// own END_HEADERS bit.
#[derive(Debug)]
enum DiscardedFieldBlock {
/// A brand-new stream's initiating HEADERS frame was refused whole
/// (drain, MAX_CONCURRENT_STREAMS, or buffer-pool exhaustion).
New { flags: u8 },
/// A later CONTINUATION frame in an in-progress header block was refused
/// (CVE-2024-27316 flood mitigation). `prior_fragment` is every field-block
/// byte accumulated before this (about to be refused) frame's own payload,
/// which is still unread at capture time and gets appended once it lands
/// in `zero.storage` at Discard time.
Continuation {
prior_fragment: Vec<u8>,
end_headers: bool,
},
}
/// Decode a refused stream's HPACK field block into `decoder` so the
/// connection-level dynamic table stays in sync with the peer's encoder (RFC
/// 9113 §4.3) — no header pair is kept, the stream itself was already
/// refused and its headers are never used. `payload` is the just-read bytes
/// sitting in `zero.storage` at the moment `H2State::Discard` is reached
/// (this frame's own payload only — see [`DiscardedFieldBlock::Continuation`]
/// for how any earlier frames' bytes are threaded in).
///
/// A free function rather than a `ConnectionH2` method: the caller in the
/// `H2State::Discard` arm of [`ConnectionH2::handle_read`] already holds
/// `zero.storage` borrowed as `kawa`, and a method needing the whole
/// `&mut self` would conflict with that borrow. Taking `decoder` and
/// `payload` as disjoint parameters keeps the borrow legal.
fn decode_discarded_field_block(
decoder: &mut crate::protocol::mux::hpack::Decoder,
payload: &[u8],
discarded: DiscardedFieldBlock,
) -> Result<Option<Vec<u8>>, H2Error> {
match discarded {
DiscardedFieldBlock::New { flags } => {
// Reparse using the same grammar as an accepted HEADERS frame
// (RFC 9113 §6.2): [Pad Length][Stream Dependency+Weight][field
// block fragment][padding]. `payload` is exactly this frame's
// whole wire payload — `refuse_stream_and_discard` was handed
// `header.payload_len` before any padding/priority stripping —
// so the field block has to be located the same way
// `parser::headers_frame` locates it for an accepted stream.
let header = FrameHeader {
payload_len: payload.len() as u32,
frame_type: FrameType::Headers,
flags,
stream_id: 0,
};
let (_, frame) =
parser::headers_frame(payload, &header).map_err(|_| H2Error::ProtocolError)?;
let Frame::Headers(headers) = frame else {
unreachable!("parser::headers_frame always yields Frame::Headers")
};
let fragment = headers
.header_block_fragment
.data_opt(payload)
.ok_or(H2Error::InternalError)?;
// Without END_HEADERS the block continues on CONTINUATION frames
// (RFC 9113 §6.10): hand its fragment back, to be completed by
// them (`ConnectionH2::pending_discarded_block`) and decoded
// whole, since a fragment may end mid-integer or
// mid-Huffman-string.
if !headers.end_headers {
return Ok(Some(fragment.to_vec()));
}
decoder
.decode_with_cb(fragment, |_, _| {})
.map(|_| None)
.map_err(|_| H2Error::CompressionError)
}
DiscardedFieldBlock::Continuation {
mut prior_fragment,
end_headers,
} => {
// Same END_HEADERS gate as above, evaluated on this trailing
// CONTINUATION frame's own flags rather than the (still false)
// flag the originating HEADERS frame carried.
prior_fragment.extend_from_slice(payload);
if !end_headers {
return Ok(Some(prior_fragment));
}
decoder
.decode_with_cb(&prior_fragment, |_, _| {})
.map(|_| None)
.map_err(|_| H2Error::CompressionError)
}
}
}
impl ConnectionH2 {
fn frontend_hung_up_while_draining(&self) -> bool {
matches!(self.position, Position::Server)
&& self.drain.draining()
&& (self.readiness.event.is_hup() || self.readiness.event.is_error())
}
/// Once the final GOAWAY has been queued and all streams/control frames are
/// gone, a peer-side HUP/ERR means any remaining rustls backlog is no
/// longer deliverable. Waiting on `socket_wants_write()` in that state can
/// deadlock shutdown forever because GOAWAY disables further frame reads.
fn peer_gone_after_final_goaway(&self) -> bool {
self.frontend_hung_up_while_draining()
&& matches!(self.state, H2State::GoAway | H2State::Error)
&& self.stream_table.streams().is_empty()
&& self.stream_table.expect_write().is_none()
&& self.output.is_empty()
}
/// The next instant this connection wants its embedder to call `timeout()`
/// at, or `None` for "no timer". The adapter reflects this onto the real
/// wheel; nothing in this module touches `crate::timer`.
pub fn poll_timeout(&self) -> Option<Instant> {
self.timeout_deadline
}
/// Push the deadline one full [`Self::timeout_duration`] out from the
/// connection's clock snapshot. Replaces the old
/// `TimeoutContainer::reset()` at the same three call sites, and reads
/// `self.now` rather than the real clock (invariant 20).
///
/// A no-op while lingering: the linger deadline bounds the whole drain.
pub fn arm_timeout(&mut self) {
if self.is_lingering() {
return;
}
self.timeout_deadline = self.now.checked_add(self.timeout_duration);
}
/// Ask for no timer at all. Replaces `TimeoutContainer::cancel()`.
pub(super) fn clear_timeout(&mut self) {
self.timeout_deadline = None;
}
/// Adopt a new configured duration and re-arm from `now`.
///
/// Mirrors `TimeoutContainer::set_duration`, which likewise cancelled and
/// re-armed rather than keeping the old deadline. `now` is the adapter's
/// snapshot: this is called from `Mux::ready`, outside the entry points
/// that mirror it into `self.now`.
pub fn set_timeout_duration(&mut self, duration: Duration, now: Instant) {
self.timeout_duration = duration;
if !self.is_lingering() {
self.timeout_deadline = now.checked_add(duration);
}
}
/// Whether the connection is draining what its client still sends after
/// its final GOAWAY (see [`super::shared::Linger`]).
pub(super) fn is_lingering(&self) -> bool {
matches!(self.linger, Linger::Draining { .. })
}
/// Whether a close the next writable pass decides should linger instead:
/// a frontend that has queued its final GOAWAY, whose client has not hung
/// up, and which does not drain already — or one whose linger is already
/// [`Linger::Pending`] behind a TLS `close_notify`. The client may still
/// be sending frames it wrote before it read the GOAWAY (RFC 9113 §6.8);
/// closing with them unread, or receiving more, makes the kernel reset
/// the connection and discard the bytes still queued, the GOAWAY itself
/// included (sozu-proxy/sozu#1861). Whatever the GOAWAY's error code;
/// [`Self::linger_budget`] decides how long and how much.
fn lingers_instead_of_closing(&self) -> bool {
if matches!(self.linger, Linger::Pending { .. }) {
return true;
}
self.position.is_server()
&& matches!(self.state, H2State::GoAway)
&& self.drain.draining()
&& !self.frontend_hung_up_while_draining()
&& matches!(self.linger, Linger::Off)
}
/// The lingering close's wall-clock duration and byte budget. After a
/// final GOAWAY(NO_ERROR) the client may still be sending a request it is
/// owed an answer to, so the linger takes the listener's
/// `request_timeout` and [`LINGER_MAX_BYTES`]. After a GOAWAY carrying an
/// error code it only has to deliver that GOAWAY:
/// [`ERROR_GOAWAY_LINGER_TIMEOUT`] (never more than `request_timeout`)
/// and [`ERROR_GOAWAY_LINGER_MAX_BYTES`].
fn linger_budget(&self) -> (Duration, usize) {
let budget = if self.graceful_goaway {
(self.linger_timeout, LINGER_MAX_BYTES)
} else {
(
self.linger_timeout.min(ERROR_GOAWAY_LINGER_TIMEOUT),
ERROR_GOAWAY_LINGER_MAX_BYTES,
)
};
debug_assert!(
budget.0 <= self.linger_timeout && budget.1 <= LINGER_MAX_BYTES,
"no linger outlasts or outreads the graceful one"
);
budget
}
/// Shared constructor for both server and client H2 connections.
///
/// Differences between server and client are captured by the caller-provided
/// `position`, `expect_read`, and `readiness_interest` parameters.
#[allow(clippy::too_many_arguments)]
pub fn new(
session_ulid: Ulid,
peer_address: Option<std::net::SocketAddr>,
position: super::Position,
buffers: &mut dyn super::buffer_source::BufferSource,
flood_config: H2FloodConfig,
connection_config: H2ConnectionConfig,
stream_idle_timeout: std::time::Duration,
graceful_shutdown_deadline: Option<std::time::Duration>,
timeout_duration: std::time::Duration,
expect_read: Option<(H2StreamId, usize)>,
readiness_interest: sozu_command::ready::Ready,
) -> Option<Self> {
let buffer = buffers.checkout()?;
// The one clock sample this module takes outside `Mux`'s sampling
// points. A connection is constructed at accept / backend-connect
// time, outside any `ready()` pass, so there is no snapshot to
// inherit; everything downstream of here reads `self.now`, which
// `Mux` refreshes from its next pass onward.
let now = Instant::now();
let local_settings = H2Settings {
settings_max_concurrent_streams: connection_config.max_concurrent_streams,
..H2Settings::default()
};
// RFC 7541 §4.2: enforce SETTINGS_HEADER_TABLE_SIZE as the upper bound
// for dynamic table size updates from the peer
let hpack = hpack_state::HpackState::new(
buffers,
local_settings.settings_header_table_size as usize,
)?;
Some(ConnectionH2 {
session_ulid,
hpack,
stream_table: h2_stream_table::H2StreamTable::new(expect_read),
last_stream_id: 0,
local_settings,
peer_settings: H2Settings::default(),
position,
scheduler: h2_scheduler::H2Scheduler::default(),
readiness: crate::Readiness {
interest: readiness_interest,
event: Ready::EMPTY,
},
peer_address,
state: H2State::ClientPreface,
timeout_duration,
// Armed from construction, exactly as the old `TimeoutContainer`
// was: the frontend arrived already armed from the handshake state,
// and `Router::plan_connect` armed a fresh backend with the connect
// timeout right after registering its socket. The adapter reflects
// this onto the wheel on its next reschedule.
timeout_deadline: now.checked_add(timeout_duration),
linger_timeout: timeout_duration,
linger: Linger::Off,
graceful_goaway: false,
flow_control: h2_flow_control::H2FlowControl::new(DEFAULT_INITIAL_WINDOW_SIZE as i32),
pending_table_size_update: None,
parked_header_block: false,
parked_data: 0,
connection_window_enlarged: false,
drain: h2_drain::H2DrainState::new(graceful_shutdown_deadline),
zero: kawa::Kawa::new(kawa::Kind::Request, kawa::Buffer::new(buffer)),
output: h2_output::H2Output::default(),
reads_wait_for_output: false,
header_reassembly: h2_header_reassembly::HeaderBlockAccumulator::new(),
bytes: H2ByteAccounting {
zero_bytes_read: 0,
overhead_bin: 0,
overhead_bout: 0,
},
flood_detector: h2_flood_detector::H2FloodDetector::new(flood_config, now),
settings_sent_at: None,
control_tx: h2_control_tx::H2ControlTx::new(connection_config.max_concurrent_streams),
discarded_field_block: None,
pending_discarded_block: None,
close_notify_sent: false,
kernel_refused_write: false,
max_pending_window_updates: 1 + connection_config.max_concurrent_streams as usize * 4,
connection_config,
ready_incremental_streams: 0,
last_gauge_snapshot: None,
metric_events: Vec::new(),
stream_idle_timeout,
refuse_count_window: 0,
refuse_window_start: now,
mcs_backpressure_applied: false,
flood_refused_streams: None,
flood_refusal_ignored: false,
now,
pending_force_disconnect: None,
})
}
fn expect_header(&mut self) {
self.state = H2State::Header;
self.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
}
/// Process the `H2State::Header` state: parse a 9-byte frame header from
/// `self.zero`, validate the stream, create new streams if needed, and
/// transition to `H2State::Frame` for the payload.
///
/// Returns `MuxResult` — the caller should propagate the result directly.
fn handle_header_state<E, L>(&mut self, context: &mut Context<L>, endpoint: E) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
let i = self.zero.storage.data();
trace!("{} header: {:?}", log_context!(self), i);
match parser::frame_header(i, self.local_settings.settings_max_frame_size) {
Ok((_, header)) => {
trace!("{} {:#?}", log_context!(self), header);
self.zero.storage.clear();
let stream_id = header.stream_id;
// A discarded header block without END_HEADERS continues
// here: only a CONTINUATION frame on its stream may follow
// (RFC 9113 §6.10), and it is discarded the same way, its
// fragment completing the block for HPACK (§4.3).
if let Some((block_stream_id, fragment)) = self.pending_discarded_block.take() {
if header.frame_type != FrameType::Continuation || stream_id != block_stream_id
{
error!(
"{} {:?} on stream {} interrupts the header block of stream {}",
log_context!(self),
header.frame_type,
stream_id,
block_stream_id
);
return self.goaway(H2Error::ProtocolError);
}
// CVE-2024-27316: the same per-block CONTINUATION count and
// size limits as an accepted block.
self.flood_detector
.record_continuation_frame(header.payload_len);
check_flood_or_return!(self);
if self.flood_detector.accumulated_header_size()
> self.flood_detector.config().max_header_list_size()
|| (header.payload_len as usize) > self.zero.storage.available_space()
{
error!(
"{} discarded header block of stream {} exceeds its limits",
log_context!(self),
stream_id
);
return self.goaway(H2Error::EnhanceYourCalm);
}
return self.discard_field_block(
stream_id,
header.payload_len,
DiscardedFieldBlock::Continuation {
prior_fragment: fragment,
end_headers: header.flags & parser::FLAG_END_HEADERS != 0,
},
);
}
// RFC 9113 §6.10: CONTINUATION frames MUST be preceded by a
// HEADERS or PUSH_PROMISE frame without END_HEADERS. When we
// reach `handle_header_state`, we are between frames and no
// header block is in progress (otherwise the state would be
// `H2State::ContinuationHeader`). A CONTINUATION frame arriving
// here is therefore standalone and MUST be treated as a
// connection error of type PROTOCOL_ERROR.
if header.frame_type == FrameType::Continuation {
error!(
"{} standalone CONTINUATION frame on stream {} without preceding HEADERS",
log_context!(self),
stream_id
);
return self.goaway(H2Error::ProtocolError);
}
// RFC 9113 §5.5: unknown frame types MUST be ignored and discarded.
// Route unknown frames (and any stream_id == 0 control frame)
// through stream 0 (the connection-level buffer) so
// `handle_frame` can drop them without touching stream state.
let read_stream = if stream_id == 0
|| matches!(header.frame_type, FrameType::Unknown(_))
{
H2StreamId::Zero
} else if (header.frame_type == FrameType::Data
|| header.frame_type == FrameType::Headers)
&& self.stream_table.was_reset_locally(stream_id)
{
// RFC 9113 §5.1: after sending RST_STREAM, an endpoint
// MUST ignore frames it receives on that stream, which
// the peer may have sent before reading the reset,
// whether the stream is still tracked or already retired.
// `discard_frame_payload` still processes them minimally:
// a HEADERS block, and the CONTINUATION frames completing
// it, are decoded so the HPACK dynamic table stays in
// sync (§4.3); a DATA payload is credited to connection
// flow control (§6.9). Every other frame type on such a
// stream keeps its own handling.
debug!(
"{} Ignoring {:?} on stream {} after sending RST_STREAM",
log_context!(self),
header.frame_type,
stream_id
);
// What the peer had in flight when it read the reset is
// no abuse: at most two header blocks (a trailer section,
// or an interim and a final response), or DATA within the
// stream's receive window, which Sōzu credits back as it
// reads DATA, so it stays the initial window it
// advertised. More header blocks, and DATA beyond it,
// count as a glitch.
// CVE-2019-9518: an empty DATA frame without END_STREAM
// counts toward the empty-DATA flood limit here as on a
// live stream (`handle_data_frame`), since it carries no
// payload to charge against the stream's window.
if header.frame_type == FrameType::Data
&& header.payload_len == 0
&& header.flags & parser::FLAG_END_STREAM == 0
{
self.flood_detector.record_empty_data_frame();
check_flood_or_return!(self);
}
let in_flight = if header.frame_type == FrameType::Headers {
self.stream_table
.charge_reset_stream_header_block(stream_id)
} else {
self.stream_table.charge_reset_stream_data(
stream_id,
header.payload_len,
self.local_settings.settings_initial_window_size,
)
};
if !in_flight {
self.flood_detector.record_glitch();
check_flood_or_return!(self);
}
return self.discard_frame_payload(&header);
} else if let Some(global_stream_id) =
self.stream_table.streams().get(&stream_id).copied()
{
let allowed_on_half_closed = header.frame_type == FrameType::WindowUpdate
|| header.frame_type == FrameType::Priority
|| header.frame_type == FrameType::RstStream;
let carries_message = header.frame_type == FrameType::Data
|| header.frame_type == FrameType::Headers;
let stream = &context.streams[global_stream_id];
// Use the position-aware end_of_stream flag:
// - Server reads from front (client requests)
// - Client reads from back (backend responses)
let received_eos = if self.position.is_server() {
stream.front_received_end_of_stream
} else {
stream.back_received_end_of_stream
};
trace!(
"{} REQUESTING EXISTING STREAM {}: {}/{:?}",
log_context!(self),
stream_id,
received_eos,
stream.state
);
// RFC 9113 §5.1: DATA or HEADERS on a half-closed (remote)
// stream is a stream error of type STREAM_CLOSED. Only that
// stream is reset; the frame is still minimally processed.
if carries_message && received_eos && stream.state.is_open() {
debug!(
"{} {:?} on half-closed (remote) stream {}, sending RST_STREAM(STREAM_CLOSED)",
log_context!(self),
header.frame_type,
stream_id
);
return self.reset_stream_and_discard_frame(
&header,
global_stream_id,
H2Error::StreamClosed,
context,
endpoint,
);
}
if !allowed_on_half_closed && (received_eos || !stream.state.is_open()) {
error!(
"{} CANNOT RECEIVE {:?} ON THIS STREAM {:?}",
log_context!(self),
header.frame_type,
stream.state
);
return self.goaway(H2Error::StreamClosed);
}
// RFC 9113 §8.1: a HEADERS frame received in the body
// phase is a trailer block and MUST carry END_STREAM. This
// closes the request-smuggling primitive where a peer sends
// HEADERS, DATA, HEADERS (no END_STREAM) to chain header
// blocks on the same stream ID.
//
// Discriminate from the read-side Kawa parsing phase rather
// than stream existence: on Position::Client the stream is
// created when we send the request to the backend, so the
// initial backend response HEADERS legitimately arrives on
// an existing stream. Similarly, 1xx→final transitions on
// either side may yield multiple HEADERS frames before the
// body begins (kawa clears back to initial / terminated on
// 1xx; neither is main_phase). Only HEADERS arriving once
// the read side has transitioned to Body/Chunks parsing —
// i.e. after headers were fully consumed and body framing
// is in progress — may be a trailer.
let read_in_body = if self.position.is_server() {
stream.front.is_main_phase()
} else {
stream.back.is_main_phase()
};
//
// RFC 9113 §8.1.1: such a message is malformed, a stream
// error of type PROTOCOL_ERROR: only this stream is reset.
if header.frame_type == FrameType::Headers
&& read_in_body
&& header.flags & parser::FLAG_END_STREAM == 0
{
debug!(
"{} HEADERS without END_STREAM on open stream {} in body phase: trailers MUST carry END_STREAM",
log_context!(self),
stream_id
);
return self.reset_stream_and_discard_frame(
&header,
global_stream_id,
H2Error::ProtocolError,
context,
endpoint,
);
}
if header.frame_type == FrameType::Data {
H2StreamId::Other {
id: stream_id,
gid: global_stream_id,
}
} else {
H2StreamId::Zero
}
} else {
// RFC 9113 §5.1.1: stream identifiers MUST be strictly
// increasing. Tightened from `>=` to `>` so that a peer
// cannot re-use `self.last_stream_id` (which would
// conflict with our own server-pushed streams if we
// ever enable push in the future). For the first
// request on a fresh connection `last_stream_id == 0`
// and any client-initiated odd stream still passes.
// A refused stream moves `highest_peer_stream_id` but
// not `last_stream_id`, which only an accepted stream
// moves, so a new stream must be past both.
let opens_new_client_stream = header.frame_type == FrameType::Headers
&& self.position.is_server()
&& stream_id & 1 == 1
&& stream_id > self.last_stream_id;
if opens_new_client_stream
&& stream_id <= self.stream_table.highest_peer_stream_id()
{
// An id between the last accepted stream and the
// highest one the client used was refused or skipped,
// never opened: it is no new stream. Streams still
// tracked, and streams Sōzu reset or refused
// (`H2StreamTable::was_reset_locally`), were handled
// above: the request trailers of a refused stream,
// sent before the client read its REFUSED_STREAM, are
// ignored there (RFC 9113 §5.1). What lands here is an
// id the client skipped, or a refused one the bounded
// recently-reset set already forgot, and HEADERS on it
// is a connection error of type PROTOCOL_ERROR
// (§5.1.1).
error!(
"{} HEADERS on stream {} at or below the highest client stream id {}, sending GOAWAY(PROTOCOL_ERROR)",
log_context!(self),
stream_id,
self.stream_table.highest_peer_stream_id()
);
return self.goaway(H2Error::ProtocolError);
}
if opens_new_client_stream {
// RFC 9113 §6.8: after sending a GOAWAY, the proxy
// MUST NOT accept new streams.
// `graceful_goaway` marks the connection draining
// through `H2DrainState::begin_graceful_drain`, then
// sends an initial GOAWAY with last_stream_id =
// STREAM_ID_MAX (so in-flight requests are still
// accepted); *new* peer streams must still be refused.
// Without this check, a peer racing the drain
// window could open arbitrary new streams between
// the initial and final GOAWAY emission.
if self.drain.draining() {
// A refused stream costs a glitch, as below.
if self.settings_sent_at.is_none() {
self.flood_detector.record_glitch();
check_flood_or_return!(self);
}
self.stream_table.observe_peer_stream_id(stream_id);
return self.refuse_stream_and_discard(
stream_id,
H2Error::RefusedStream,
RstOrigin::Local,
header.payload_len,
DiscardedFieldBlock::New {
flags: header.flags,
},
);
}
// Soft state below the pre-response RST_STREAM cap:
// refuse the new stream, keep serving the open ones.
// The refusal is retryable (RFC 9113 §8.7). Like the
// refusals here and below, it costs a glitch once the
// peer acknowledged our SETTINGS: the glitch budget is
// what bounds a peer that keeps opening streams it is
// refused. It feeds no other flood counter —
// `RstOrigin::Local`, and no SETTINGS back-pressure,
// which would halve MAX_CONCURRENT_STREAMS for the
// rest of the connection over a state that decays. A
// client that reset a refused stream gets no more
// refusals (`Self::flood_refusal_ignored`).
if !self.flood_refusal_ignored
&& self.flood_detector.refuses_new_streams(self.now)
{
if self.settings_sent_at.is_none() {
self.flood_detector.record_glitch();
check_flood_or_return!(self);
}
debug!(
"{} refusing stream {} under flood pressure",
log_context!(self),
stream_id
);
self.metric_events
.push(MetricEvent::StreamRefusedUnderFlood);
self.stream_table.observe_peer_stream_id(stream_id);
self.note_flood_refused_stream(stream_id);
if let Some(result) = self.enqueue_rst(
stream_id,
H2Error::RefusedStream,
RstOrigin::Local,
) {
return result;
}
self.stream_table.remember_refused(stream_id);
return self.discard_field_block(
stream_id,
header.payload_len,
DiscardedFieldBlock::New {
flags: header.flags,
},
);
}
if self.stream_table.len()
>= self.local_settings.settings_max_concurrent_streams as usize
{
error!(
"{} MAX CONCURRENT STREAMS: limit={}, current={}",
log_context!(self),
self.local_settings.settings_max_concurrent_streams,
self.stream_table.len()
);
// Every refusal counts as a glitch once the peer
// has acknowledged our SETTINGS (before that it
// may legitimately exceed a limit it has not
// seen, RFC 9113 §6.5.3). This includes the band
// back-pressure opens below the advertised limit:
// the glitch budget, not the refusal, is what
// bounds a peer that keeps opening streams it is
// refused. The reset itself stays
// `RstOrigin::Local` and does not feed the
// MadeYouReset cap.
if self.settings_sent_at.is_none() {
self.flood_detector.record_glitch();
check_flood_or_return!(self);
}
// RFC 9113 §6.8: update highest_peer_stream_id BEFORE
// queueing RST_STREAM so GOAWAY reports the correct
// last_stream_id if the connection closes later.
self.stream_table.observe_peer_stream_id(stream_id);
return self.refuse_stream_and_discard(
stream_id,
H2Error::RefusedStream,
RstOrigin::Local,
header.payload_len,
DiscardedFieldBlock::New {
flags: header.flags,
},
);
}
match self.create_stream(stream_id, context) {
Some(_) => {}
None => {
// Buffer pool exhaustion is transient — refuse
// this stream but keep the connection alive so
// existing streams can complete and free buffers.
error!(
"{} Could not create stream {}: buffer pool exhausted",
log_context!(self),
stream_id
);
// RFC 9113 §6.8: update highest_peer_stream_id BEFORE
// queueing RST_STREAM so GOAWAY reports the correct
// last_stream_id if the connection closes later.
self.stream_table.observe_peer_stream_id(stream_id);
return self.refuse_stream_and_discard(
stream_id,
H2Error::RefusedStream,
RstOrigin::Local,
header.payload_len,
DiscardedFieldBlock::New {
flags: header.flags,
},
);
}
}
} else if header.frame_type != FrameType::Priority {
// Distinguish closed vs idle: check whether the stream
// was previously opened. For Server position, compare
// against highest_peer_stream_id (client-initiated).
// For Client position, compare against last_stream_id
// (our own initiated streams) since the peer never
// initiates streams on a backend connection.
let is_closed_stream = if self.position.is_server() {
header.stream_id <= self.stream_table.highest_peer_stream_id()
} else {
header.stream_id < self.last_stream_id
};
if is_closed_stream {
match header.frame_type {
FrameType::RstStream | FrameType::WindowUpdate => {
// RFC 9113 §5.1: RST_STREAM and WINDOW_UPDATE
// on a closed stream can arrive due to race
// conditions and should be consumed/discarded.
debug!(
"{} Ignoring {:?} on closed stream {}",
log_context!(self),
header.frame_type,
header.stream_id
);
self.flood_detector.record_glitch();
check_flood_or_return!(self);
}
FrameType::Data => {
// RFC 9113 §5.1: DATA on a closed stream is a
// stream error of type STREAM_CLOSED. Queue
// RST_STREAM (not GOAWAY) to preserve the
// connection for other streams. The payload is
// still routed through stream 0 so handle_frame
// can do connection-level flow control accounting.
// A stream this endpoint reset or refused never
// gets here while the bounded recently-reset set
// remembers it: its late DATA is ignored above.
debug!(
"{} DATA on closed stream {}, sending RST_STREAM(STREAM_CLOSED)",
log_context!(self),
header.stream_id
);
self.flood_detector.record_glitch();
check_flood_or_return!(self);
// Local: this DATA is already counted as a
// glitch above, which is what bounds it;
// charging it to the provoked-RST cap too
// would count it twice.
if let Some(result) = self.enqueue_rst(
header.stream_id,
H2Error::StreamClosed,
RstOrigin::Local,
) {
return result;
}
}
_ => {
// RFC 9113 §5.1: HEADERS or other frames on a
// closed stream we did not reset → connection
// error STREAM_CLOSED, which §5.1 permits.
error!(
"{} Received {:?} on closed stream {}, sending GOAWAY(STREAM_CLOSED)",
log_context!(self),
header.frame_type,
header.stream_id
);
return self.goaway(H2Error::StreamClosed);
}
}
} else {
error!(
"{} Received {:?} on idle stream {}, sending GOAWAY(PROTOCOL_ERROR)",
log_context!(self),
header.frame_type,
header.stream_id
);
return self.goaway(H2Error::ProtocolError);
}
}
H2StreamId::Zero
};
trace!(
"{} {} {:?} {:#?}",
log_context!(self),
header.stream_id,
stream_id,
self.stream_table.streams()
);
self.stream_table
.set_expect_read(Some((read_stream, header.payload_len as usize)));
self.state = H2State::Frame(header);
}
Err(error) => {
let error = error_nom_to_h2(error);
error!("{} COULD NOT PARSE FRAME HEADER", log_context!(self));
return self.goaway(error);
}
};
MuxResult::Continue
}
/// Process the `H2State::ContinuationHeader` state: parse a CONTINUATION
/// frame header from `self.zero`, validate stream ID continuity, track
/// flood detection counters, and transition to `ContinuationFrame`.
///
/// The `headers` parameter is the accumulated HEADERS context from the
/// initial HEADERS frame (cloned from the state enum to avoid borrow
/// conflicts).
fn handle_continuation_header_state(&mut self, headers: &Headers) -> MuxResult {
let i = self.zero.storage.unparsed_data();
trace!("{} continuation header: {:?}", log_context!(self), i);
match parser::frame_header(i, self.local_settings.settings_max_frame_size) {
Ok((
_,
FrameHeader {
payload_len,
frame_type: FrameType::Continuation,
flags,
stream_id,
},
)) => {
// The 9-byte CONTINUATION frame header has now been fully
// parsed from `zero.storage`; nothing about it needs to
// survive the transition below. Earlier revisions kept the
// buffer's trailing bytes alive with a manual
// `storage.end -= 9` so the next payload read would land
// contiguously after the accumulated fragment inside
// `zero.storage` itself — that trick existed only to serve
// the old design where the fragment lived here. It now
// lives in `self.header_reassembly` (h2_header_reassembly.rs),
// so there is nothing left to stay contiguous with.
self.zero.storage.clear();
if stream_id != headers.stream_id {
error!(
"{} CONTINUATION stream_id {} does not match HEADERS stream_id {}",
log_context!(self),
stream_id,
headers.stream_id
);
return self.goaway(H2Error::ProtocolError);
}
// CVE-2024-27316: track CONTINUATION frame count and accumulated size
self.flood_detector.record_continuation_frame(payload_len);
check_flood_or_return!(self);
// RFC 9113 §10.5.1: reject header blocks that cannot be
// buffered. Previously we silently removed READABLE interest
// when amount > available_space, stalling the connection.
// If the payload still fits in our zero buffer we can refuse
// just this stream (RST_STREAM + drain); if not, the
// connection can no longer decode header blocks safely and we
// escalate to GOAWAY(EnhanceYourCalm).
if self.flood_detector.accumulated_header_size()
> self.flood_detector.config().max_header_list_size()
{
error!(
"{} CONTINUATION accumulated header size {} exceeds {}",
log_context!(self),
self.flood_detector.accumulated_header_size(),
self.flood_detector.config().max_header_list_size()
);
if (payload_len as usize) > self.zero.storage.available_space() {
return self.goaway(H2Error::EnhanceYourCalm);
}
// Remove the already-created stream slot before refusing,
// so it does not leak against MAX_CONCURRENT_STREAMS. Route
// through `remove_dead_stream` so the expect_write/read
// invariant (§LIFECYCLE.md 5.4) holds on this path too.
if let Some(global_stream_id) = self.stream_table.get(stream_id) {
self.remove_dead_stream(stream_id, global_stream_id);
}
// The field-block bytes accumulated by every *prior*
// frame in this block already live in
// `self.header_reassembly`, decoupled from
// `zero.storage`'s own bookkeeping — no eager copy is
// needed here the way the old single-buffer design
// required (the buffer this comment used to warn about
// reuse of is `zero.storage`; that reuse now targets an
// empty, unrelated scratch region, not this fragment).
let prior_fragment = self.header_reassembly.finish();
return self.refuse_stream_and_discard(
stream_id,
H2Error::RefusedStream,
RstOrigin::PeerProvoked,
payload_len,
DiscardedFieldBlock::Continuation {
prior_fragment,
end_headers: flags & parser::FLAG_END_HEADERS != 0,
},
);
}
if (payload_len as usize) > self.zero.storage.available_space() {
error!(
"{} CONTINUATION payload {} exceeds buffer space {}",
log_context!(self),
payload_len,
self.zero.storage.available_space()
);
return self.goaway(H2Error::EnhanceYourCalm);
}
self.stream_table
.set_expect_read(Some((H2StreamId::Zero, payload_len as usize)));
let mut headers = headers.clone();
headers.end_headers = flags & parser::FLAG_END_HEADERS != 0;
// `header_block_fragment`'s window is no longer extended
// here: it described an offset into `zero.storage`, which
// this CONTINUATION frame's payload has not been read into
// yet. `(H2State::ContinuationFrame(headers), _)` folds this
// frame's payload into `self.header_reassembly` once it has
// actually been read, and `handle_headers_frame` reads the
// accumulator directly rather than through this field.
self.state = H2State::ContinuationFrame(headers);
}
Err(error) => {
let error = error_nom_to_h2(error);
error!("{} COULD NOT PARSE CONTINUATION HEADER", log_context!(self));
return self.goaway(error);
}
other => {
error!(
"{} UNEXPECTED {:?} WHILE PARSING CONTINUATION HEADER",
log_context!(self),
other
);
return self.goaway(H2Error::ProtocolError);
}
};
MuxResult::Continue
}
/// Ask the core what it wants read from the socket, so the caller can
/// perform that read and report it back through [`Self::handle_read`].
///
/// This is the read half of the same **two-call protocol** the write half
/// already uses: `h2_transmit::gather` borrows the bytes to send, the
/// caller writes them, `h2_transmit::confirm` is told how many landed.
/// Here the core names the buffer it wants filled, the caller
/// performs the read, and [`Self::handle_read`] is told how many bytes
/// arrived and with what status.
///
/// **This is not `AsyncRead::poll_read`.** Nothing here is a future,
/// `context` is this module's [`Context`] and not a `task::Context`, and
/// `lib/` holds no asynchronous function. The name follows
/// `UdpManager::poll_output` (`protocol/udp/manager.rs`), this
/// repository's existing spelling for "ask the core what it has for its
/// caller", the way [`Self::handle_read`] follows that module's
/// `UdpManager::handle_input`.
///
/// Everything up to and including the decision of *which* buffer needs
/// *how many* bytes lives on this side of the split, because two of the
/// three answers are produced by that prelude: a SETTINGS-ACK timeout and
/// an idle `expect_read` both end the pass with no read at all. A poll
/// that could not say "nothing" would not be the core's answer.
pub fn poll_read_target<E, L>(
&mut self,
context: &mut Context<L>,
endpoint: &mut E,
) -> H2ReadTarget
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
// Entry point: adopt the mux's snapshot for this pass.
self.now = context.now;
self.prune_inactive_streams_while_closing(context);
// Pass 4 Medium #3: per-stream idle guard. Slow-multiplex Slowloris
// sends one byte or a control frame per stream just often enough to
// reset the connection-level timer; per-stream deadlines catch it.
self.cancel_timed_out_streams(context, endpoint);
// RFC 9113 §6.5: check if peer has timed out on SETTINGS ACK
if let Some(sent_at) = self.settings_sent_at
&& self.now.saturating_duration_since(sent_at) >= SETTINGS_ACK_TIMEOUT
{
warn!(
"{} SETTINGS ACK timeout: no SETTINGS ACK observed within {:?}",
log_context!(self),
SETTINGS_ACK_TIMEOUT
);
return H2ReadTarget::Done(self.goaway(H2Error::SettingsTimeout));
}
// Don't reset the timeout unconditionally here. Only application data
// (DATA/HEADERS frames) should reset the timeout. H2 control frames
// (PING, WINDOW_UPDATE, SETTINGS) must NOT reset it, otherwise a peer
// sending periodic PINGs prevents timeout detection on stuck sessions.
// The timeout is reset:
// - Below, when reading DATA payload (H2StreamId::Other)
// - In handle_frame(), when processing HEADERS frames
// - On the write side, in handle_write(), once a stream transmit has
// moved bytes — outbound application data is activity too, and an
// acknowledgement-only write pass moves none of it
let Some((stream_id, amount)) = self.stream_table.expect_read() else {
self.readiness.event.remove(Ready::READABLE);
return H2ReadTarget::Done(MuxResult::Continue);
};
// Every frame read can queue an answer in `self.output` (an ACK, a
// RST_STREAM, a WINDOW_UPDATE). Past one buffer of queued output the
// peer is not reading, so stop reading it too until that drains, as
// HAProxy's demux stops on a full `mbuf` (`H2_CF_DEM_MROOM`). The event
// is kept for the later read, and `Self::consume_output` restores the
// interest. A forced READABLE (the shutdown drive) is stopped as well.
if self.output.len() >= self.output_read_cap() {
self.reads_wait_for_output = true;
self.readiness.interest.remove(Ready::READABLE);
return H2ReadTarget::Done(MuxResult::Continue);
}
match stream_id {
H2StreamId::Zero => {}
H2StreamId::Other { .. } => {
// Reading DATA frame payload for an application stream.
// This is real application activity — reset the timeout.
self.arm_timeout();
}
}
let skipping = self.skipping_orphaned_payload();
let kawa = read_buffer(
&mut self.zero,
&mut context.streams,
&self.position,
stream_id,
);
trace!(
"{} {:?}({:?}, {})",
log_context!(self),
self.state,
stream_id,
amount
);
if amount > 0 {
let available = kawa.storage.available_space();
// An orphaned DATA remainder is read and dropped, never kept, so
// it only needs *some* room: read what fits and come back for the
// rest instead of waiting for room for all of it.
let amount = if skipping {
amount.min(available)
} else {
amount
};
if amount == 0 || amount > available {
self.readiness.interest.remove(Ready::READABLE);
return H2ReadTarget::Done(MuxResult::Continue);
}
H2ReadTarget::Fill { stream_id, amount }
} else {
self.stream_table.set_expect_read(None);
H2ReadTarget::Skip(stream_id)
}
}
/// Tell the core what the caller's read actually did, then let it consume
/// the bytes and dispatch the frame they completed.
///
/// The second half of [`Self::poll_read_target`]'s protocol. `stream_id`
/// is the one the matching [`H2ReadTarget`] named, and `outcome` answers
/// that same variant: [`H2ReadOutcome::Skipped`] for
/// [`H2ReadTarget::Skip`], [`H2ReadOutcome::Filled`] for
/// [`H2ReadTarget::Fill`].
///
/// `read_buffer` is called a second time here rather than carried across
/// the split: `StreamParts` borrows `context`, so a buffer held across the
/// caller's read would pin `context.debug` and `Self::handle_frame`'s whole
/// `&mut Context` with it. Re-deriving it from a `Copy` [`H2StreamId`] is
/// a match and a field projection.
pub fn handle_read<E, L>(
&mut self,
context: &mut Context<L>,
endpoint: E,
stream_id: H2StreamId,
outcome: H2ReadOutcome,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
let skipping = self.skipping_orphaned_payload();
let kawa = read_buffer(
&mut self.zero,
&mut context.streams,
&self.position,
stream_id,
);
match outcome {
H2ReadOutcome::Skipped => {}
H2ReadOutcome::Filled {
amount,
size,
status,
} => {
let did = match stream_id {
H2StreamId::Zero => usize::MAX,
H2StreamId::Other {
gid: global_stream_id,
..
} => global_stream_id,
};
context.debug.push(DebugEvent::SocketIO(0, did, size));
if !skipping {
kawa.storage.fill(size);
}
let event = self.position.bytes_in_event(size);
self.metric_events.push(event);
self.bytes.zero_bytes_read += size;
if update_readiness_after_read(size, status, &mut self.readiness) {
if matches!(self.position, Position::Server)
&& self.drain.draining()
&& matches!(status, SocketResult::Closed | SocketResult::Error)
{
// During graceful drain, a frontend EOF/HUP means no
// further frame headers or payload bytes can arrive.
// Keeping expect_read here strands the connection in
// Header/Frame forever even after the peer is gone.
self.stream_table.set_expect_read(None);
}
return MuxResult::Continue;
} else if skipping {
// `amount` was capped to the room in `zero`; the debt is
// what `expect_read` still names.
let remaining = self
.stream_table
.expect_read()
.map_or(0, |(_, remaining)| remaining.saturating_sub(size));
if remaining > 0 {
self.stream_table
.set_expect_read(Some((stream_id, remaining)));
return MuxResult::Continue;
}
self.stream_table.set_expect_read(None);
} else if size == amount {
self.stream_table.set_expect_read(None);
} else {
self.stream_table
.set_expect_read(Some((stream_id, amount - size)));
if let (H2State::ClientPreface, Position::Server) =
(&self.state, &self.position)
{
let (pri, i) = (serializer::H2_PRI.as_bytes(), kawa.storage.data());
if !pri.starts_with(&i[..i.len().min(pri.len())]) {
debug!("{} EARLY INVALID PREFACE: {:?}", log_context!(self), i);
return self.force_disconnect();
}
}
return MuxResult::Continue;
}
}
}
match (&self.state, &self.position) {
(H2State::Error, _)
| (H2State::GoAway, _)
| (H2State::ServerSettings, Position::Server)
| (H2State::ClientPreface, Position::Client(..))
| (H2State::ClientSettings, Position::Client(..)) => {
error!(
"{} Unexpected combination: (Readable, {:?}, {:?})",
log_context!(self),
self.state,
self.position
);
return self.force_disconnect();
}
(H2State::Discard, _) => {
let i = kawa.storage.data();
trace!("{} DISCARDING: {:?}", log_context!(self), i);
// RFC 9113 §4.3: HPACK field-compression state is scoped to
// the connection, not the stream. `i` is this refused
// frame's own just-read payload; decode it (folding in any
// earlier frames' bytes for a CONTINUATION refusal — see
// `DiscardedFieldBlock`) before it is dropped, so our
// decoder does not fall behind the peer's encoder.
//
// Without a field block this is an orphaned DATA remainder
// (`Self::skip_orphaned_data_payload`): its bytes were never
// kept, and `zero` may still hold unflushed output.
if let Some((stream_id, discarded)) = self.discarded_field_block.take() {
match decode_discarded_field_block(self.hpack.decoder_mut(), i, discarded) {
// The block continues on CONTINUATION frames.
Ok(Some(fragment)) => {
self.pending_discarded_block = Some((stream_id, fragment));
}
Ok(None) => self.flood_detector.reset_continuation(),
Err(error) => {
error!(
"{} discarded stream's HPACK field block failed to decode: {:?}",
log_context!(self),
error
);
return self.goaway(error);
}
}
kawa.storage.clear();
}
self.attribute_bytes_to_overhead();
self.expect_header();
}
(H2State::ClientPreface, Position::Server) => {
let i = kawa.storage.data();
let i = match parser::preface(i) {
Ok((i, _)) => i,
Err(_) => return self.force_disconnect(),
};
match parser::frame_header(i, self.local_settings.settings_max_frame_size) {
Ok((
_,
FrameHeader {
payload_len,
frame_type: FrameType::Settings,
flags: 0,
stream_id: 0,
},
)) => {
kawa.storage.clear();
self.state = H2State::ClientSettings;
self.stream_table
.set_expect_read(Some((H2StreamId::Zero, payload_len as usize)));
}
_ => return self.force_disconnect(),
};
}
(H2State::ClientSettings, Position::Server) => {
let i = kawa.storage.data();
let settings = match parser::settings_frame(
i,
&FrameHeader {
payload_len: i.len() as u32,
frame_type: FrameType::Settings,
flags: 0,
stream_id: 0,
},
) {
Ok((_, settings)) => {
kawa.storage.clear();
settings
}
Err(_) => return self.force_disconnect(),
};
let local_settings = &self.local_settings;
match self.output.push_frames(SETTINGS_FRAME_SIZE, |buf| {
serializer::gen_settings(buf, local_settings).map(|(_, size)| size)
}) {
Ok(_) => {
self.metric_events.push(MetricEvent::SettingsFrameSent);
// RFC 9113 §6.5: start tracking SETTINGS ACK timeout
self.settings_sent_at = Some(self.now);
}
Err(error) => {
error!(
"{} Could not serialize SettingsFrame: {:?}",
log_context!(self),
error
);
return self.force_disconnect();
}
};
// Enlarge the connection-level receive window beyond the RFC
// default of 65 535 bytes, in the same output flush as the
// SETTINGS above and the ACK `handle_frame` appends below: one
// `writev(2)` for the whole server preface instead of a second
// write pass for a queued WINDOW_UPDATE. RFC 9113 §3.4 only
// requires SETTINGS to come first. Skipped when the configured
// window is the default, since a zero-increment WINDOW_UPDATE
// violates RFC 9113 §6.9. Our own send window is untouched:
// this enlarges the peer's send allowance, not ours.
let increment = self
.connection_config
.initial_connection_window
.saturating_sub(DEFAULT_INITIAL_WINDOW_SIZE);
self.connection_window_enlarged = true;
if increment > 0 {
match self.output.push_frames(WINDOW_UPDATE_FRAME_SIZE, |buf| {
serializer::gen_window_update(buf, 0, increment).map(|(_, size)| size)
}) {
Ok(_) => {
self.metric_events
.push(MetricEvent::WindowUpdateFramesSent(1));
}
Err(error) => {
error!(
"{} Could not serialize the connection WINDOW_UPDATE: {:?}",
log_context!(self),
error
);
return self.force_disconnect();
}
}
}
self.state = H2State::ServerSettings;
self.readiness.signal_pending_write();
return self.handle_frame(settings, 0, context, endpoint);
}
(H2State::ServerSettings, Position::Client(..)) => {
let i = kawa.storage.data();
match parser::frame_header(i, self.local_settings.settings_max_frame_size) {
Ok((
_,
header @ FrameHeader {
payload_len,
frame_type: FrameType::Settings,
flags: 0,
stream_id: 0,
},
)) => {
kawa.storage.clear();
self.stream_table
.set_expect_read(Some((H2StreamId::Zero, payload_len as usize)));
self.state = H2State::Frame(header)
}
_ => return self.force_disconnect(),
};
}
(H2State::Header, _) => {
return self.handle_header_state(context, endpoint);
}
(H2State::ContinuationHeader(headers), _) => {
let headers = headers.clone();
return self.handle_continuation_header_state(&headers);
}
(H2State::Frame(header), _) => {
let i = kawa.storage.unparsed_data();
trace!("{} data: {:?}", log_context!(self), i);
let wire_payload_len = header.payload_len;
let frame = match parser::frame_body(i, header) {
Ok((_, frame)) => frame,
Err(error) => {
let error = error_nom_to_h2(error);
// RFC 9113 §6.3: a PRIORITY frame of the wrong length
// is a stream error, unlike every other frame size
// error (§4.2). PRIORITY is read through stream 0.
if header.frame_type == FrameType::Priority
&& error == H2Error::FrameSizeError
{
let priority_stream_id = header.stream_id;
kawa.storage.end = kawa.storage.head;
self.expect_header();
return self.handle_priority_frame_size_error(
priority_stream_id,
context,
endpoint,
);
}
error!("{} COULD NOT PARSE FRAME BODY", log_context!(self));
return self.goaway(error);
}
};
if let H2StreamId::Zero = stream_id {
// Free `zero.storage` for the next frame header read. A
// HEADERS frame used to be special-cased here
// (`head = end`, preserving its payload bytes in place
// so a following CONTINUATION could extend them
// in-buffer) — the reassembly accumulator that needed
// now lives in `self.header_reassembly` instead (see
// `handle_headers_frame`), copied out before this read
// cycle ends, so every zero-stream frame gets the same
// treatment.
kawa.storage.end = kawa.storage.head;
}
self.expect_header();
return self.handle_frame(frame, wire_payload_len, context, endpoint);
}
(H2State::ContinuationFrame(headers), _) => {
// This CONTINUATION frame's payload has just finished being
// read into `zero.storage` (the generic stream-0 read
// dispatch above, shared by every zero-stream frame type).
// Move it into the owned reassembly accumulator and free
// `zero.storage` immediately — from this point until the
// next CONTINUATION's bytes start arriving, `zero.storage`
// holds nothing belonging to this header block. See
// `h2_header_reassembly.rs` and LIFECYCLE.md invariant 24.
self.header_reassembly.append(kawa.storage.data());
kawa.storage.clear();
let headers = headers.clone();
self.expect_header();
return self.handle_frame(Frame::Headers(headers), 0, context, endpoint);
}
}
MuxResult::Continue
}
/// Update the H2 connection-level *aggregate* gauges with this connection's
/// current contribution, expressed as a signed delta against the last
/// snapshot we emitted.
///
/// The four metrics are returned as [`MetricEvent`] signed deltas (rather
/// than absolute values) so that the dashboard sees the **sum across all
/// live H2 connections**:
///
/// - `h2.connection.window_bytes` — sum of available connection-level
/// send-window bytes. Negative per-connection windows clamp to 0 so the
/// aggregate represents only available capacity, not deficit.
/// - `h2.connection.active_streams` — sum of in-flight streams across
/// every H2 connection.
/// - `h2.connection.pending_window_updates` — sum of queued (un-flushed)
/// per-stream WINDOW_UPDATE entries across every H2 connection.
/// - `h2.streams.ready_incremental.by_urgency` — sum of
/// [`Self::ready_incremental_streams`] across every H2 connection.
///
/// Called from the write hot path; emits nothing when the snapshot is
/// unchanged so the steady state stays cheap. The paired decrement for
/// every increment is provided by [`Self::release_connection_gauges`],
/// which subtracts the final snapshot when the connection is dropped —
/// keeping the aggregate arithmetically symmetric independent of which
/// close path runs (`graceful_goaway`, `force_disconnect`,
/// `handle_goaway_frame`, `Mux::close`, panic-unwind, …).
/// [`H2Shell`]'s [`Drop`] is the single site that calls it.
fn gauge_connection_state(&mut self) {
let snapshot = (
self.flow_control.window().max(0) as usize,
self.stream_table.streams().len(),
self.flow_control.pending_window_updates_len(),
self.ready_incremental_streams,
);
if self.last_gauge_snapshot == Some(snapshot) {
return;
}
let prev = self.last_gauge_snapshot.unwrap_or((0, 0, 0, 0));
// Diff in i64 — usize cannot represent the negative side of the delta.
let dw = snapshot.0 as i64 - prev.0 as i64;
let ds = snapshot.1 as i64 - prev.1 as i64;
let du = snapshot.2 as i64 - prev.2 as i64;
let dr = snapshot.3 as i64 - prev.3 as i64;
if dw != 0 {
self.metric_events
.push(MetricEvent::ConnectionWindowBytes(dw));
}
if ds != 0 {
self.metric_events
.push(MetricEvent::ConnectionActiveStreams(ds));
}
if du != 0 {
self.metric_events
.push(MetricEvent::ConnectionPendingWindowUpdates(du));
}
if dr != 0 {
self.metric_events
.push(MetricEvent::StreamsReadyIncrementalByUrgency(dr));
}
self.last_gauge_snapshot = Some(snapshot);
}
/// Subtract this connection's contribution from the four aggregate gauges
/// [`Self::gauge_connection_state`] feeds. Idempotent: clears
/// `last_gauge_snapshot` so a second call (or a `Drop` on top of an
/// explicit reset) is a no-op.
///
/// Pairs with every prior call to [`Self::gauge_connection_state`]. Queues
/// the negatives onto [`Self::metric_events`] like every other core-side
/// metric; the caller is [`H2Shell`]'s [`Drop`], which calls this and then
/// drains, so the symmetry is guaranteed regardless of the close path.
///
/// Stays module-private: the release has to run exactly once per
/// connection, at teardown, and that single caller is in this module
/// alongside it.
fn release_connection_gauges(&mut self) {
if let Some((w, s, u, r)) = self.last_gauge_snapshot.take() {
if w != 0 {
self.metric_events
.push(MetricEvent::ConnectionWindowBytes(-(w as i64)));
}
if s != 0 {
self.metric_events
.push(MetricEvent::ConnectionActiveStreams(-(s as i64)));
}
if u != 0 {
self.metric_events
.push(MetricEvent::ConnectionPendingWindowUpdates(-(u as i64)));
}
if r != 0 {
self.metric_events
.push(MetricEvent::StreamsReadyIncrementalByUrgency(-(r as i64)));
}
}
}
/// Take every [`MetricEvent`] this core has queued since the last drain.
///
/// The core's half of the boundary: it states what happened and by how
/// much, and stops there. Recording is [`record_metric`]'s job, and
/// [`H2Shell::record_pending_metrics`] is the production caller. Reading
/// the queue instead is what will let a driver with no `METRICS` registry
/// — `sim/tests/h2_simulation.rs`, once the slice needing it exports the
/// type — fold the events into a shadow model, as `udp_simulation.rs` does.
pub(super) fn drain_metric_events(&mut self) -> std::vec::Drain<'_, MetricEvent> {
self.metric_events.drain(..)
}
/// Ask the core what it wants written to the socket, so the caller can
/// perform that write and report it back through [`Self::handle_write`].
///
/// The write half of [`Self::poll_read_target`]'s protocol, with ONE forced
/// divergence: a read pass ENDS with `handle_read`, while a write pass
/// performs an unbounded number of writes, so this is re-entered until it
/// answers [`H2WriteTarget::Done`] or [`H2WriteTarget::Finalize`]. The
/// `pass` parameter is what makes that re-entry meaningful: 9.6a needed no
/// stash because `expect_read` already lived on `stream_table`, whereas one
/// write pass builds a dozen values that have to survive the round trip.
///
/// It is a `&mut` local of `Self::write_streams` and not an
/// `Option<H2WritePass>` field on `self`: every exit ends the pass, so it
/// never outlives one call, and resumption ACROSS calls is already
/// `H2StreamTable::expect_write`.
///
/// **A caller that stops driving after an [`H2WriteTarget::Transmit`]
/// strands the pass's converter**, and with it the three HPACK scratch
/// buffers it took out of `hpack_state::HpackState` — silently, and for
/// the life of the connection. Between the transition into
/// `H2WritePhase::Prepare` and the first statement of
/// `H2WritePhase::End` this function has exactly one `return`, the
/// `Transmit` yield, so the drive loop is the whole guarantee;
/// `H2WritePass`'s [`Drop`] carries the matching `debug_assert!`. A
/// re-entry AFTER the pass answered is the other half of that argument and
/// is handled separately: `End` moves the pass to
/// `H2WritePhase::Ended`, whose arm answers `Done` rather than
/// re-entering an arm whose first statement takes three `Option`s it has
/// already emptied.
///
/// Unlike [`Self::handle_write`] this never sees a [`SocketResult`]. The
/// pass terminator is `pass.stalled`, written in exactly one statement over
/// there, and `size > 0` with `SocketResult::WouldBlock` is NOT a stall —
/// `update_readiness` decides on the size alone. A terminator keyed on the
/// status would truncate every response a TLS frontend accepts in two
/// rounds, which is why adding that parameter here has to be a visible
/// signature change rather than a one-token edit.
pub fn poll_write_target<E, L>(
&mut self,
context: &mut Context<L>,
endpoint: &mut E,
pass: &mut H2WritePass,
) -> H2WriteTarget
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
loop {
match pass.phase {
H2WritePhase::Start => {
// sozu-proxy/sozu#1641: DATA the last pass left parked
// and that is no longer queued was dropped unsent. Its
// stream is still here (a removal refunds eagerly and
// clears the park), so both windows get it back; the
// DATA still queued goes out in the resume below.
let parked_data = std::mem::take(&mut self.parked_data);
if parked_data > 0 {
match self.stream_table.expect_write() {
Some(H2StreamId::Other { gid, .. }) => {
let parts = context.streams[gid].split(&self.position);
let queued = parked_data_credit(parts.wbuffer);
debug_assert!(
queued <= parked_data,
"nothing adds DATA to a parked stream before it resumes"
);
let dropped = parked_data - queued;
if dropped > 0 {
*parts.window = parts.window.saturating_add(dropped);
if let Some(result) = self.refund_parked_data(dropped) {
return H2WriteTarget::Done(result);
}
}
}
// The park was cleared without a removal: the
// frontend hung up while draining.
_ => {
if let Some(result) = self.refund_parked_data(parked_data) {
return H2WriteTarget::Done(result);
}
}
}
}
debug_assert_eq!(self.parked_data, 0, "the park's DATA is settled here");
// sozu-proxy/sozu#1627: a header block the last pass left
// parked and unsent is gone if its stream was removed or
// its `kawa.out` cleared. Nothing encoded after it is
// queued anywhere, so emptying the encoder's table here,
// before this pass encodes a block, resynchronises the
// peer through that block's leading size updates.
if std::mem::take(&mut self.parked_header_block) {
let dropped = match self.stream_table.expect_write() {
Some(parked @ H2StreamId::Other { .. }) => write_buffer(
&mut self.zero,
&mut context.streams,
&self.position,
parked,
)
.out
.is_empty(),
_ => true,
};
if dropped {
self.reset_encoder_table();
}
}
if let Some(
write_stream @ H2StreamId::Other {
id: stream_id,
gid: global_stream_id,
},
) = self.stream_table.expect_write()
{
// Resume path: if the same stream is parked waiting for
// buffer space (expect_read matches write_stream), keep
// the amount so `handle_write` can re-enable READABLE as
// soon as we drain.
let cross_read_amount = match self.stream_table.expect_read() {
Some((read_stream, amount)) if write_stream == read_stream => {
Some(amount)
}
_ => None,
};
pass.phase = H2WritePhase::Resume {
stream_id,
gid: global_stream_id,
cross_read_amount,
// Read BEFORE the flush, where the pre-image read
// it, and carried across the transmits rather than
// re-read after them: its consumer is
// `handle_1xx_reset`, so a stream retired mid-flush
// must not change which state that check sees.
stream_state: context.streams[global_stream_id].state,
};
} else {
self.begin_scheduler_pass(context, pass);
}
}
H2WritePhase::Resume {
stream_id,
gid: global_stream_id,
stream_state,
..
} => {
let write_stream = H2StreamId::Other {
id: stream_id,
gid: global_stream_id,
};
// The pre-image's `while !kawa.out.is_empty()`, as a
// resumption state. `pass.stalled` is the loop's ONLY
// terminator, and it is false on the first entry.
if !pass.stalled {
let kawa = write_buffer(
&mut self.zero,
&mut context.streams,
&self.position,
write_stream,
);
// The queue can hold the adopted rest of this very
// stream's frame: it goes out first, in this transmit.
if !kawa.out.is_empty() || !self.output.is_empty() {
return H2WriteTarget::Transmit {
stream_id: write_stream,
};
}
}
let stream = &mut context.streams[global_stream_id];
let parts = stream.split(&self.position);
let kawa = parts.wbuffer;
// Refresh the per-stream idle timer when outbound bytes move: a
// large response delivered at low bandwidth is "active", not idle,
// even when the peer sends no inbound frames.
if pass.resume_bytes > 0 {
self.stream_table.touch_activity(stream_id, self.now);
// Clear the flow-control-stall deadline ONLY when the effective
// send window is genuinely open — that alone is a real un-stall.
// A window-stalled stream can flush a `WINDOW_UPDATE(+1)`-drip
// byte HERE via socket-backpressure resume; clearing on that
// would reset the deadline at 1-byte granularity and re-open the
// drip the M2 cumulative-stall budget closes. While still blocked,
// leave the deadline (and its progress accumulator) for the main
// write loop's budget to govern — keeping the two maps in lockstep.
if min(*parts.window, self.flow_control.window()) > 0 {
self.stream_table.clear_fc_stall(stream_id);
}
}
if pass.stalled {
// The FIRST of the two terminators that must not
// finalize: the scheduler pass never began, so LIFECYCLE
// §9 invariant 16's readiness policy has nothing to
// decide and the park must survive untouched.
self.parked_header_block = h2_transmit::holds_header_frame(kawa);
self.parked_data = parked_data_credit(kawa);
return H2WriteTarget::Done(MuxResult::Continue);
}
self.stream_table.set_expect_write(None);
if (kawa.is_terminated() || kawa.is_error())
&& kawa.is_completed()
&& !Self::handle_1xx_reset(kawa, stream_state, endpoint)
{
let (client_rtt, server_rtt) =
self.snapshot_rtts(endpoint, stream.linked_token());
if let Some((dead_id, token)) = self.try_recycle_server_stream(
stream,
global_stream_id,
stream_id,
pass.byte_totals,
&mut context.debug,
context.listener.clone(),
client_rtt,
server_rtt,
) {
// Remove the recycled stream from the connection maps
// before endpoint.end_stream() can trigger teardown.
// Otherwise session close can observe a stale `Recycle`
// entry in self.streams and mis-handle the connection as
// if it still had an active H2 stream.
self.remove_dead_stream(dead_id, global_stream_id);
if let Some(token) = token {
remove_backend_stream(
&mut context.backend_streams,
token,
global_stream_id,
);
endpoint.end_stream(token, global_stream_id, context);
}
}
}
self.begin_scheduler_pass(context, pass);
}
H2WritePhase::Prepare { cursor } => {
let Some(&stream_id) = pass.order().get(cursor) else {
pass.phase = H2WritePhase::End;
continue;
};
let Some(&global_stream_id) = self.stream_table.streams().get(&stream_id)
else {
error!(
"{} stream_id {} from sorted keys missing in streams map",
log_context!(self),
stream_id
);
pass.phase = H2WritePhase::Prepare { cursor: cursor + 1 };
continue;
};
let (urgency, is_incremental) = self.scheduler.priority(&stream_id);
let stream = &mut context.streams[global_stream_id];
// Read BEFORE the flush, where the pre-image read it, and
// carried through `H2WritePhase::Flush` — see that field's
// doc.
let stream_state = stream.state;
let parts = stream.split(&self.position);
let kawa = parts.wbuffer;
pass.consumed = 0;
// The pre-image re-ASSIGNED `stalled` from each
// `flush_stream_out` call, and a stream with an empty queue
// flushed zero rounds and read Drained. Clearing it here is
// what keeps one stream's stall from terminating the next
// stream's flush before its first transmit.
pass.stalled = false;
// A request in error on a backend stream whose HEADERS
// never left (the H1 frontend rejected the body and
// answered 400 itself, say) must not reach the converter,
// whose `initialize` would queue a RST_STREAM for a stream
// the backend has never seen: a connection error on the
// shared connection (RFC 9113 §5.1, §6.4,
// sozu-proxy/sozu#1631). There is nothing to cancel; the
// stream is left for `end_stream` or the idle reaper, which
// retire it without a frame.
let never_opened_error = kawa.is_error()
&& !kawa.consumed
&& matches!(self.position, Position::Client(..));
#[cfg(debug_assertions)]
let out_before = kawa.out.len();
if kawa.is_main_phase()
|| (kawa.is_terminated() && !kawa.is_completed())
|| (kawa.is_error()
&& !never_opened_error
&& !self.stream_table.rst_sent_contains(stream_id))
{
let window = min(*parts.window, self.flow_control.window());
// Same-urgency-bucket ready-peer count (Tier 3a, LIFECYCLE §9
// invariant 17). The converter skips the yield when there is
// no peer in the same bucket to interleave with — prevents
// the `finalize_write` WRITABLE-withdrawal strand (see
// `test_h2_solo_incremental_drains_fully`). A connection-wide
// count would wrongly yield for a solo incremental stream
// when another urgency bucket happens to contain an
// incremental peer.
let incremental_peer_count =
pass.census_mut().incremental_peer_count(urgency);
// Track RST_STREAM dedup: if kawa is in error state, the converter
// will generate a RST_STREAM frame via `initialize`. Mark it so we
// don't send a duplicate on the next writable cycle.
if kawa.is_error() {
let freshly_rst = self.stream_table.rst_sent_mut().insert(stream_id);
// LIFECYCLE §9 invariant 17: any transition to ineligible
// mid-pass MUST leave the pass census so later streams in
// the same iteration see the live count, not the
// snapshot. Missing this costs one voluntary yield per
// same-urgency peer that trails the RST.
if freshly_rst {
pass.census_mut().note_ineligible(urgency, is_incremental);
}
// Account for the RST that `initialize` is about to emit
// for this stream, so the per-error breakdown and the
// global tx counter see every RST that reaches the wire:
// any path that flips `parsing_phase` to Error before
// reaching this gate (oversized trailers, malformed
// bodies, etc.) would otherwise land an unaccounted RST.
// The accounting call is deferred to after the loop; see
// `H2WritePass::freshly_emitted_rsts`.
if freshly_rst {
pass.freshly_emitted_rsts.push(rst_error_from_kawa(kawa));
}
}
// Apply per-frontend response-side header edits
// (set/replace/delete) stashed by the routing layer at
// request time. H2 frontends always run as Server
// position; the back-side H2 client (when sozu speaks
// H2 to a backend) is a request emission and was
// already mutated by Router::route_from_request.
//
// The snapshot is **drained** via `mem::take` so the
// injection runs exactly once per response. Without
// this, a re-entry of `write_streams` for the same
// stream (multi-frame body, flow-control yield, or
// RFC 9218 same-urgency round-robin) would re-call
// `apply_response_header_edits` after `kawa.prepare`
// had already consumed the `Block::Flags{end_header}`
// anchor — the helper falls back to
// `kawa.blocks.len()` and appends the edit AFTER all
// remaining DATA blocks. The next prepare cycle then
// encodes that orphan `Block::Header` into
// `H2BlockConverter.out` with no closing
// `Block::Flags{end_header}` to flush it as a HEADERS
// frame, and `H2BlockConverter::finalize` trips the
// "out buffer not empty (38 bytes remaining), clearing"
// defense-in-depth log on every re-entry. 38 bytes is
// the static-table HPACK encoding of a typical HSTS
// header, which is how the symptom surfaces in
// production once the listener-default HSTS reaches a
// non-trivial share of frontends.
if matches!(self.position, super::Position::Server)
&& !parts.context.headers_response.is_empty()
{
let edits = std::mem::take(&mut parts.context.headers_response);
super::shared::apply_response_header_edits(kawa, &edits);
}
// One converter for exactly this `prepare` call. It borrows
// the encoder out of `self.hpack`; keeping that borrow no
// longer than the call is what leaves the rest of this
// phase free to take `&self` / `&mut self`. The scratch and
// the RFC 7541 §6.3 signal are moved in here and moved back
// out by `reclaim` below — no buffer is copied.
//
// RFC 9218 §4: `incremental_mode` makes an incremental
// stream yield the converter after a single DATA frame so
// same-urgency peers interleave; a non-scheduled caller
// (the resume phase above, the converter's own unit tests)
// keeps the sequential semantics with `false`.
let mut converter = pass.converter_mut().converter(
self.hpack.encoder_mut(),
stream_id,
window,
is_incremental,
incremental_peer_count,
);
kawa.prepare(&mut converter);
// sozu-proxy/sozu#1632: what a backend connection
// encoded belongs to it (its stream id, its HPACK
// table) and cannot be sent on another one.
if matches!(self.position, Position::Client(..)) && !kawa.out.is_empty() {
*parts.front_bound_to_backend = true;
}
let events_before = self.metric_events.len();
let remaining = pass
.converter_mut()
.reclaim(converter, &mut self.metric_events);
// Each DATA frame the converter just wrote earns the
// peer stream-0 WINDOW_UPDATE credit. `reclaim` appended
// this prepare's events to the tail, one
// `DataFrameSent` per DATA frame serialized.
let data_frames_sent = self.metric_events[events_before..]
.iter()
.filter(|event| matches!(event, MetricEvent::DataFrameSent))
.count() as u64;
self.flood_detector
.record_data_frames_sent(data_frames_sent);
pass.consumed = window - remaining;
// The pre-prepare gate above only inserts into
// `rst_sent` when `kawa.is_error()` is already true on
// entry. The HPACK over-budget abort path
// (`H2BlockConverter::check_header_capacity` →
// `finalize`) flips `parsing_phase` to Error AND pushes
// its own RST_STREAM frame inside this same prepare
// pass; without a post-prepare insert here the next
// writable cycle would gate-pass and double-emit a
// RST_STREAM via the existing `initialize` chokepoint.
//
// Per Codex P2: the converter's direct RST emission
// bypasses the metric accounting that
// `Self::reset_stream` performs. Mirror it here so
// dashboards see the per-error counter and the global
// tx counter.
//
// Per Codex P3: when an incremental stream flips to
// Error mid-prepare, the RFC 9218 §4 yield-after-one
// accounting must drop this stream from the
// same-urgency ready bucket so trailing peers see the
// live count.
let freshly_rst_post_prepare =
kawa.is_error() && self.stream_table.rst_sent_mut().insert(stream_id);
if freshly_rst_post_prepare {
// Deferred to after the loop; same reason as the
// pre-prepare collector above.
pass.freshly_emitted_rsts.push(rst_error_from_kawa(kawa));
pass.census_mut().note_ineligible(urgency, is_incremental);
}
*parts.window = parts.window.saturating_sub(pass.consumed);
self.flow_control.consume_send_window(pass.consumed);
let consumed = pass.consumed;
pass.census_mut()
.note_fired(urgency, stream_id, is_incremental, consumed);
}
#[cfg(debug_assertions)]
debug_assert!(
!never_opened_error || kawa.out.len() == out_before,
"nothing is encoded for a backend stream in error that never opened"
);
context.debug.push(DebugEvent::S(
stream_id,
global_stream_id,
kawa.parsing_phase,
kawa.blocks.len(),
kawa.out.len(),
));
pass.stream_bytes = 0;
pass.phase = H2WritePhase::Flush {
cursor,
stream_id,
gid: global_stream_id,
stream_state,
urgency,
is_incremental,
};
}
H2WritePhase::Flush {
cursor,
stream_id,
gid: global_stream_id,
stream_state,
urgency,
is_incremental,
} => {
let write_stream = H2StreamId::Other {
id: stream_id,
gid: global_stream_id,
};
// THE ROUND-AGAIN: the pre-image's
// `while !kawa.out.is_empty()`, re-expressed as a resumption
// state over the same stream. `pass.stalled` is the only
// terminator, and `update_readiness` sets it on `size == 0`
// alone — a write that moved bytes under `WouldBlock` comes
// straight back here for another round.
if !pass.stalled {
let kawa = write_buffer(
&mut self.zero,
&mut context.streams,
&self.position,
write_stream,
);
// A write that cut this stream's last frame left its
// rest in the output queue and `kawa.out` empty: that
// rest goes now, not after another stream's frames.
if !kawa.out.is_empty() || !self.output.is_empty() {
// The pre-image raised this flag at the top of every
// round of the SCHEDULER loop's flush and never in
// the resume path's, which passed `None` for it.
pass.socket_write = true;
return H2WriteTarget::Transmit {
stream_id: write_stream,
};
}
}
let stream = &mut context.streams[global_stream_id];
let parts = stream.split(&self.position);
let kawa = parts.wbuffer;
// Refresh the per-stream idle timer on outbound bytes. Without
// this, a long-running response trickled at low bandwidth would
// be killed by `cancel_timed_out_streams` mid-delivery — the
// inbound-only refreshes in `handle_data_frame` (non-empty DATA)
// and `handle_headers_frame` never fire while the peer is idle.
if pass.stream_bytes > 0 {
self.stream_table.touch_activity(stream_id, self.now);
}
// Arm/age the dedicated flow-control-stall deadline that catches a
// window-stalled stream — a buffered RESPONSE to a slow frontend
// (`Position::Server`) OR a buffered request UPLOAD to a slow H2
// backend (`Position::Client`): window-stall reaping is bidirectional
// by design (M4), so there is no position gate here. Set only when the
// stream holds sendable buffered data it cannot send because its
// effective send window is exhausted; unlike `stream_last_activity_at`
// it is NEVER refreshed by inbound DATA/HEADERS, so a peer dribbling
// 1-byte DATA cannot keep it warm.
//
// M2 cumulative-stall budget: a genuinely OPEN window clears the
// deadline immediately (real un-stall). While the window stays
// blocked, accumulate this pass's outbound drain; only cumulative
// progress reaching `FC_STALL_CLEAR_FLOOR` (a full frame of real
// delivery) clears it. A `WINDOW_UPDATE(+1)` drip drains ~1 byte/pass
// straight back to a zero window, so it never reaches the floor — the
// deadline ages out and `cancel_timed_out_streams` RST(CANCEL)s the
// slot-pinning stream after `stream_idle_timeout`.
let outbound_window_blocked = has_sendable_response(kawa)
&& min(*parts.window, self.flow_control.window()) <= 0
&& (!kawa.blocks.is_empty() || !kawa.out.is_empty());
match fc_stall_budget_decision(
outbound_window_blocked,
pass.consumed,
self.stream_table.fc_stall_progress(stream_id),
) {
FcStallAction::Clear => {
self.stream_table.clear_fc_stall(stream_id);
}
FcStallAction::Arm { progress } => {
self.stream_table
.arm_fc_stall(stream_id, self.now, progress);
}
}
pass.total_bytes_written =
pass.total_bytes_written.saturating_add(pass.stream_bytes);
if pass.stalled {
self.stream_table.set_expect_write(Some(write_stream));
self.parked_header_block = h2_transmit::holds_header_frame(kawa);
self.parked_data = parked_data_credit(kawa);
pass.phase = H2WritePhase::End;
continue;
}
self.stream_table.set_expect_write(None);
if (kawa.is_terminated() || kawa.is_error())
&& kawa.is_completed()
&& !Self::handle_1xx_reset(kawa, stream_state, endpoint)
{
// `keep_alive_frontend` is not read here: a default
// answer's `Connection: close` is an HTTP/1.1
// connection option, stripped from H2 (RFC 9113
// §8.2.2). The answer ends its own stream only.
let (client_rtt, server_rtt) =
self.snapshot_rtts(endpoint, stream.linked_token());
if let Some((dead_id, token)) = self.try_recycle_server_stream(
stream,
global_stream_id,
stream_id,
pass.byte_totals,
&mut context.debug,
context.listener.clone(),
client_rtt,
server_rtt,
) {
pass.completed_streams
.push((dead_id, global_stream_id, token));
// LIFECYCLE §9 invariant 17: leave the census INSIDE
// the scheduler loop so later streams see the reduced
// count. The post-loop retirement at remove_dead_stream
// is too late.
pass.census_mut().note_ineligible(urgency, is_incremental);
}
}
pass.phase = H2WritePhase::Prepare { cursor: cursor + 1 };
}
H2WritePhase::Ended => {
// The pass already answered. `write_streams` never gets
// here — it returns on `Done` and on `Finalize` — but this
// function is `pub`, and `End` below takes the
// scheduler-pass values out as its first statement, so a
// second poll re-entering that arm would `expect` on three
// empty `Option`s. Answering `Done` keeps a hand-driven
// caller on the ordinary terminator instead of a panic.
return H2WriteTarget::Done(MuxResult::Continue);
}
H2WritePhase::End => {
// FIRST statement of the arm, before any `return` it can
// take: it leaves the pass holding `None` for the rest of
// its life. The phase moves to `Ended` in the same
// breath so a re-entry cannot reach this statement twice.
let (converter_pass, order, census) = pass.release_scheduler_pass();
pass.phase = H2WritePhase::Ended;
// Sample the pass's final bucket totals. Publication is deferred to
// the `gauge_connection_state` call below because the value must
// reach the gauge in the SAME pass that computed it — the entry call
// in `begin_scheduler_pass` runs before the pass census exists,
// so sampling there would publish the previous pass's value.
self.ready_incremental_streams = census.ready_total();
// RFC 7541 §6.3: the pass hands back the signal no header block
// carried. It is `None` once a block did, and unchanged after a
// DATA-only pass, so the signal stays queued for the next pass
// with a header block. A block dropped for its size re-arms it
// with a table reset (`H2BlockConverter::check_header_capacity`),
// which must survive the pass even when an earlier block of the
// same pass carried the old signal.
self.pending_table_size_update = converter_pass.pending_table_size_update();
// End the pass and take its three reusable buffers back. They are
// moved, not copied: the pass never owned an allocation of its own.
let (converter_out, lowercase_buf, cookie_buf) = converter_pass.into_buffers();
// Publish `ready_incremental_streams` (and any window/stream drift the
// pass produced) before the early return below, so no pass
// samples without emitting.
self.gauge_connection_state();
// Account every RST that the converter emitted during this pass
// (pre-prepare gate + post-prepare HPACK over-budget abort) so
// the global tx counter and the per-error breakdown stay in
// step. These resets are `RstOrigin::Local` — what errored is
// the message from the other side of the proxy, not a frame
// this connection's peer sent — so they never feed the
// MadeYouReset cap; the `Done` arm stays for the contract of
// `Self::account_emitted_rst`.
//
// The SECOND terminator that must not finalize. It also sits
// between `into_buffers` and the `put_*` calls, so a cap trip
// drops the three buffers — the pre-image's behaviour, kept
// deliberately: the connection is sending a GOAWAY.
for error in std::mem::take(&mut pass.freshly_emitted_rsts) {
if let Some(result) = self.account_emitted_rst(error, RstOrigin::Local) {
return H2WriteTarget::Done(result);
}
}
self.hpack.put_converter_buf(converter_out);
self.hpack.put_lowercase_buf(lowercase_buf);
self.hpack.put_cookie_buf(cookie_buf);
self.hpack.shrink_converter_buffers();
// RFC 9218 §4: end the pass — take the order buffer back and commit
// the round-robin cursor so the next writable cycle begins with the
// stream immediately after the one we fired first this pass
// (LIFECYCLE §9 invariant 26). Placed here, after the deferred RST
// accounting above, so a MadeYouReset cap trip that returns a GOAWAY
// early skips both exactly as it did before the inversion.
self.scheduler.end_pass(order, census);
for (dead_id, global_stream_id, token) in
std::mem::take(&mut pass.completed_streams)
{
// Retirement is deferred out of the loop on purpose, and this is
// an ORDERING requirement rather than a borrow workaround:
// `try_recycle_server_stream` passes `is_last_stream` as
// `streams().len() == 1`, and that branch hands the WHOLE
// remaining connection overhead pool to one stream instead of a
// proportional share. Retiring inline would let a later completer
// of the same pass read `len() == 1` while other streams are still
// live, and drain the pool early. (`active_streams` is passed
// too, but it is only the even-split divisor of the fallback
// branch, reached while the connection-wide byte total is still
// zero — on every other pass the divisor is that total.)
// Retiring here also runs
// before `endpoint.end_stream()` can trigger teardown and observe
// a stale `Recycle` entry in `self.stream_table.streams()`.
self.remove_dead_stream(dead_id, global_stream_id);
if let Some(token) = token {
remove_backend_stream(
&mut context.backend_streams,
token,
global_stream_id,
);
endpoint.end_stream(token, global_stream_id, context);
}
}
return H2WriteTarget::Finalize {
socket_write: pass.socket_write,
bytes_written: pass.total_bytes_written,
};
}
}
}
}
/// Open the scheduler half of one write pass: the gauge sample, the
/// converter, the RFC 9218 ordering and its census.
///
/// Called once per pass, from [`H2WritePhase::Start`] when nothing is
/// parked and from [`H2WritePhase::Resume`] once the parked stream drained.
/// It is NOT part of `H2WritePass::new`, and the reason is behavioural
/// rather than stylistic: the resume path can retire a stream through
/// [`Self::remove_dead_stream`], and `H2Scheduler::begin_pass` enumerates
/// the same `stream_table.streams()` map — so building the order at pass
/// start would change which streams the pass visits. The converter is
/// excluded for a different reason again: its constructor `mem::take`s
/// three scratch buffers out of [`hpack_state::HpackState`], and the
/// resume path's stall ends the pass before anything hands them back.
fn begin_scheduler_pass<L>(&mut self, context: &mut Context<L>, pass: &mut H2WritePass)
where
L: ListenerHandler + L7ListenerHandler,
{
self.gauge_connection_state();
let scheme: &'static [u8] = if context.protocol == Protocol::HTTPS {
b"https"
} else {
b"http"
};
// The converter is built for ONE `kawa.prepare` call at a time (see
// [`converter::H2ConverterPass`]), so its encoder borrow never spans
// the per-stream loop and every `&self` / `&mut self` method stays
// callable inside it. The pass carries the three reusable scratch
// buffers — moved, never copied — plus the RFC 7541 §6.3 pending
// size-update, so the first header block of this pass prepends the
// signal and no later one repeats it. `H2WritePhase::End` takes back
// whatever signal the pass has left (`pending_table_size_update()`),
// so a DATA-only write pass (no header block) does not drop it.
let converter_pass = converter::H2ConverterPass::new(
self.peer_settings.settings_max_frame_size as usize,
scheme,
// When this connection is a backend client we are writing
// toward the upstream backend — flow-control stalls in that
// direction are scoped to `backend.flow_control.paused` (in
// addition to the existing direction-agnostic
// `h2.flow_control_stall`).
self.position.is_client(),
self.hpack.take_converter_buf(),
self.hpack.take_lowercase_buf(),
self.hpack.take_cookie_buf(),
self.pending_table_size_update,
);
// The whole RFC 9218 ordering decision — ascending urgency, stream
// id for stability, incremental streams to the tail of their bucket,
// that tail rotated by the round-robin cursor — plus the pass's
// same-urgency ready-peer census, belongs to
// [`h2_scheduler::H2Scheduler`]. It runs over state that module
// owns; the ONE fact it cannot own is whether a stream has anything
// to send, which it takes as this closure. The closure is called for
// incremental streams only, so it costs exactly what the inline
// census cost. `order` is returned by value for the same reason the
// scratch buffers are moved into the converter pass: the per-stream
// phases below re-borrow the encoder out of `self.hpack` for every
// eligible stream, so no borrow of a connection field may span them.
let is_server = matches!(self.position, Position::Server);
let (order, census) =
self.scheduler
.begin_pass(self.stream_table.streams().keys().copied(), |stream_id| {
let Some(&gid) = self.stream_table.streams().get(&stream_id) else {
return false;
};
let wbuffer = if is_server {
&context.streams[gid].back
} else {
&context.streams[gid].front
};
wbuffer.is_main_phase()
|| (wbuffer.is_terminated() && !wbuffer.is_completed())
|| (wbuffer.is_error() && !self.stream_table.rst_sent_contains(stream_id))
});
trace!(
"{} PRIORITIES: {:?} (incremental_count={}, per_bucket={:?})",
log_context!(self),
order,
census.incremental_count(),
census.ready_buckets()
);
pass.adopt_scheduler_pass(converter_pass, order, census);
pass.phase = H2WritePhase::Prepare { cursor: 0 };
}
/// Tell the core what the caller's write actually did.
///
/// The second half of [`Self::poll_write_target`]'s protocol, and the
/// pre-image flush loop's BODY minus the three statements the shell now
/// owns: [`h2_transmit::gather`] has already run, the write has happened,
/// and [`h2_transmit::confirm`] has already discharged the `'static`
/// descriptors, so nothing here holds a borrow of `kawa.storage`.
///
/// It answers nothing, because nothing in this body can end a pass: the
/// pass continues after a transmit, and the decision to stop is taken by
/// [`Self::poll_write_target`] reading `H2WritePass::stalled`.
///
/// **This is the only place on the write core that sees a
/// [`SocketResult`]**, and the only place that writes `pass.stalled` — in
/// one statement, the verbatim move of the pre-image's flush test.
/// `update_readiness_after_write` returns stalled **iff `size == 0`**;
/// `status` only clears the event bit. `FrontRustls` answers
/// `(buffered_size > 0, WouldBlock)` structurally whenever rustls accepted
/// plaintext while the kernel was full, and that is NOT a stall.
///
/// Which counter the bytes land in, and which `debug_site` they are logged
/// under, are decided by the PASS PHASE and not by the stream: the resume
/// path's bytes are socket catch-up rather than the voluntary scheduler
/// yield LIFECYCLE §9 invariant 16 retains `Ready::WRITABLE` for, so
/// `H2WritePass::resume_bytes` must never reach `finalize_write`.
pub fn handle_write<L>(
&mut self,
context: &mut Context<L>,
stream_id: H2StreamId,
written: H2Written,
status: SocketResult,
pass: &mut H2WritePass,
) where
L: ListenerHandler + L7ListenerHandler,
{
self.note_write_status(status);
let H2Written {
size,
from_output,
tail,
} = written;
// The queue went first. Its own bytes are connection overhead, and
// `consume_output` restores reading if they were holding it back.
if from_output > 0 {
self.consume_output(from_output);
}
// The stream's share: what the socket took past the queue, plus the
// unsent rest of the frame it cut, which the queue adopts now and
// sends ahead of anything queued later (#1604). That rest is counted
// to the stream here, since it is committed and the stream may be
// gone by the time the queue sends it.
if tail > 0 {
let kawa = write_buffer(
&mut self.zero,
&mut context.streams,
&self.position,
stream_id,
);
self.output.adopt_tail(kawa, tail);
}
let committed = size - from_output + tail;
let resuming = matches!(pass.phase, H2WritePhase::Resume { .. });
// `2` for the resume path and `3` for the scheduler loop, the two
// `debug_site` values the pre-image passed from its two call sites.
let debug_site = if resuming { 2 } else { 3 };
let cross_read_amount = match pass.phase {
H2WritePhase::Resume {
cross_read_amount, ..
} => cross_read_amount,
_ => None,
};
let global_stream_id = match stream_id {
H2StreamId::Zero => usize::MAX,
H2StreamId::Other {
gid: global_stream_id,
..
} => global_stream_id,
};
context
.debug
.push(DebugEvent::SocketIO(debug_site, global_stream_id, size));
let event = self.position.bytes_out_event(committed);
self.metric_events.push(event);
if let H2StreamId::Other {
gid: global_stream_id,
..
} = stream_id
{
self.position
.count_bytes_out(&mut context.streams[global_stream_id].metrics, committed);
if self.position.is_server() {
context.client_bytes_out = context.client_bytes_out.wrapping_add(committed);
}
// LIFECYCLE §9 invariant 9, write side. Outbound APPLICATION data
// is activity; an acknowledgement is not. Both halves of this
// condition are load-bearing and neither is implied by reaching
// `H2Shell::write_streams`: an `H2StreamId::Other` id is what
// makes these bytes a stream's rather than `self.zero`'s, and
// `size > 0` is what makes them bytes the socket actually took.
// `arm_timeout` reads `self.now`, the snapshot `writable()` adopted
// at pass entry, so arming from here lands on the same instant the
// top of the pass would have computed — the gate is the whole
// change, not the moment.
if committed > 0 {
self.arm_timeout();
}
}
if resuming {
pass.resume_bytes = pass.resume_bytes.saturating_add(committed);
} else {
pass.stream_bytes = pass.stream_bytes.saturating_add(committed);
}
if let Some(amount) = cross_read_amount {
// Resume path: same stream is parked waiting for buffer space.
// Re-enable READABLE once the write freed enough room.
let kawa = write_buffer(
&mut self.zero,
&mut context.streams,
&self.position,
stream_id,
);
if kawa.storage.available_space() >= amount {
self.readiness.interest.insert(Ready::READABLE);
}
}
pass.stalled = update_readiness_after_write(size, status, &mut self.readiness);
}
/// After forwarding a 1xx informational response (100 Continue, 103 Early
/// Hints), reset the back buffer and re-enable backend readable so the
/// final response can arrive on the same stream. Returns true if the
/// response was 1xx.
fn handle_1xx_reset<E: Endpoint>(
kawa: &mut GenericHttpStream,
stream_state: StreamState,
endpoint: &mut E,
) -> bool {
let is_1xx = matches!(
kawa.detached.status_line,
kawa::StatusLine::Response { code, .. } if (100..200).contains(&code)
);
if !is_1xx {
return false;
}
debug!(
"{} H2 write_streams: 1xx informational forwarded, resetting back buffer",
log_module_context!()
);
kawa.clear();
if let StreamState::Linked(token) = stream_state {
let readiness = endpoint.readiness_mut(token);
readiness.interest.insert(Ready::READABLE);
readiness.signal_pending_read();
}
true
}
/// Re-arm edge-triggered WRITABLE event if rustls still has buffered TLS data.
///
/// Takes the answer rather than asking for it: `tls_wants_write` is
/// `H2Shell::tls_wants_write`'s result, read by the caller. That query was
/// this composite's whole remaining socket reach, and handing it in is
/// what leaves a body a byte-in / byte-out core can keep.
///
/// When the caller reads it is not uniform, and cannot be. Five of the six
/// call sites already hold the answer — four from the `h2_close` decision
/// just taken on it, [`H2Shell::initiate_close_notify`] from its own branch
/// condition — and pass that binding: nothing mutates the socket in
/// between, so a second query would answer the same. The sixth, the
/// [`H2ControlFlushTarget::Stalled`] arm of [`H2Shell::writable`], has no
/// such binding and must read it AFTER the `H2Shell::flush_output_to_socket`
/// whose stall brought it there: that write is what changes the answer.
/// The stalled-drain tails of [`Self::flush_pending_control_frames`] used
/// to read it and re-arm individually; its one output flush now answers
/// [`H2ControlFlushTarget::Stalled`] and that arm re-arms.
///
/// No re-arm follows a write the kernel refused
/// (`kernel_refused_write`): the socket is full, the kernel raises
/// the next WRITABLE edge once the peer reads, and that edge flushes the
/// records. A synthetic edge would only repeat the refused write, and
/// `Mux::ready_inner` did so on every inner iteration until
/// `MAX_LOOP_ITERATIONS` counted `http.infinite_loop.error` whenever a
/// TLS client stopped reading (sozu-proxy/sozu#1788). The close decisions
/// still read `tls_wants_write` unchanged: a connection with records
/// pending keeps WRITABLE interest and stays open until they are flushed.
pub fn ensure_tls_flushed(&mut self, tls_wants_write: bool) {
if tls_wants_write && !self.kernel_refused_write {
self.readiness.signal_pending_write();
}
}
/// Record what a socket write of this pass answered, for
/// [`Self::ensure_tls_flushed`]. Every write the shell makes reports
/// here, so the flag always describes the latest one.
///
/// A refused write also consumes the WRITABLE event, as
/// `update_readiness_after_write` does for the writes whose status it
/// reads. The empty-buffer flushes of `H2Shell::flush_tls_records` have
/// no other reader of their status, so without this a refused flush left
/// the edge it was called on in place, and `Mux::ready_inner` ran the
/// pass again on every inner iteration.
fn note_write_status(&mut self, status: SocketResult) {
self.kernel_refused_write = status == SocketResult::WouldBlock;
if self.kernel_refused_write {
self.readiness.event.remove(Ready::WRITABLE);
}
}
/// Evict every per-stream piece of state carried by this `ConnectionH2`.
///
/// **Invariant**: `stream_table` (wire map, `rst_sent`,
/// `stream_last_activity_at`, `stream_fc_stalled_since`,
/// `stream_fc_stalled_progress`, `expect_read`/`expect_write`) and
/// `prioriser` MUST be emptied of `stream_id` here. `prioriser` is the
/// only per-stream cache not folded into [`h2_stream_table::H2StreamTable`]
/// — this module deliberately does not have the RFC 9218 priority map,
/// same boundary [`h2_flow_control`]'s doc draws for per-stream send
/// window state. `H2StreamTable::remove` asserts its own five caches are
/// clean as a postcondition; forgetting `prioriser` here would still
/// cause unbounded memory growth on long-lived connections with many
/// cancelled streams, so it stays a second, explicit call.
///
/// When the stream owns the DATA payload being read, the rest of that
/// payload is skipped instead of left unread — see
/// [`Self::skip_orphaned_data_payload`].
fn remove_dead_stream(&mut self, stream_id: StreamId, global_stream_id: GlobalStreamId) {
// Read before `H2StreamTable::remove` nulls `expect_write`: the
// parked DATA dies with its stream (sozu-proxy/sozu#1641).
if matches!(
self.stream_table.expect_write(),
Some(H2StreamId::Other { gid, .. }) if gid == global_stream_id
) {
let parked_data = std::mem::take(&mut self.parked_data);
if parked_data > 0 {
// An overflow sends GOAWAY(FLOW_CONTROL_ERROR); its outcome
// lives on in the connection state (see the callee's doc).
let _ = self.refund_parked_data(parked_data);
}
}
// Read before `H2StreamTable::remove` nulls `expect_read`.
let orphaned = match (self.stream_table.expect_read(), &self.state) {
(Some((H2StreamId::Other { gid, .. }, remaining)), H2State::Frame(header))
if gid == global_stream_id =>
{
Some((remaining, header.payload_len))
}
_ => None,
};
if self.stream_table.remove(stream_id, global_stream_id)
== h2_stream_table::RemoveOutcome::NotPresent
{
error!(
"{} dead stream_id {} missing from streams map",
log_context!(self),
stream_id
);
}
self.scheduler.remove_stream(&stream_id);
if let Some((remaining, payload_len)) = orphaned {
self.skip_orphaned_data_payload(remaining, payload_len);
}
}
/// Give the connection send window back `dropped` octets of DATA credit
/// spent on frames that were prepared and then dropped before the wire
/// took any of them ([`Self::parked_data`], sozu-proxy/sozu#1641), and
/// re-arm WRITABLE when that reopens the window: every stream starved on
/// it may have released WRITABLE, and nothing else would wake them. The
/// stream's own window is the caller's, since only a live stream has one
/// worth restoring.
///
/// When the peer's WINDOW_UPDATEs have meanwhile pushed the window so
/// high that the refund would pass 2^31-1, the credit the peer granted
/// overflows (RFC 9113 §6.9.1) and this answers the GOAWAY
/// (FLOW_CONTROL_ERROR) `Self::handle_window_update_frame` sends for an
/// overflowing increment. `Self::goaway` records its outcome in the
/// connection state, so a caller that cannot return it
/// ([`Self::remove_dead_stream`]) may drop the result.
fn refund_parked_data(&mut self, dropped: i32) -> Option<MuxResult> {
debug_assert!(dropped > 0, "only a positive refund is applied");
let window_before = self.flow_control.window();
match self.flow_control.refund_send_window(dropped) {
h2_flow_control::ApplyWindowUpdateOutcome::Applied {
new_window,
should_arm_writable,
} => {
if should_arm_writable {
self.readiness.arm_writable();
}
debug_assert!(
new_window > window_before,
"an applied refund grew the connection window"
);
debug!(
"{} H2 prepared DATA dropped unsent: {} octets of send credit given back, connection window {}",
log_context!(self),
dropped,
new_window
);
None
}
h2_flow_control::ApplyWindowUpdateOutcome::Overflow => {
debug_assert_eq!(
self.flow_control.window(),
window_before,
"an overflowing refund is not applied"
);
error!(
"{} INVALID WINDOW INCREMENT: refunding {} octets of dropped DATA passes 2^31-1",
log_context!(self),
dropped
);
Some(self.goaway(H2Error::FlowControlError))
}
}
}
/// Empty the encoder's dynamic table after a header block it encoded was
/// dropped unsent, and queue the size updates `0`, then the maximum size,
/// for the next block, which empty the peer's table as well (RFC 7541
/// §4.2; sozu-proxy/sozu#1627). The caller guarantees that no block
/// encoded before this call is still queued for the wire.
fn reset_encoder_table(&mut self) {
let max_size = self.hpack.reset_encoder_table();
let signal = u32::try_from(max_size).unwrap_or(u32::MAX);
debug_assert_eq!(
usize::try_from(signal).ok(),
Some(max_size),
"the encoder's maximum size came from a u32 setting"
);
self.pending_table_size_update = Some(signal);
debug!(
"{} H2 an encoded header block was dropped unsent: HPACK encoder table reset, next block signals 0 then {}",
log_context!(self),
max_size
);
debug_assert!(
self.pending_table_size_update.is_some(),
"the reset is signalled on the next header block"
);
}
/// Keep the connection reading after the stream that owned the DATA
/// payload being read was removed mid-frame (idle reap, reset).
///
/// Nulling `expect_read` alone left the connection in `H2State::Frame`
/// with nothing to read, so it never read again (#1597). The remaining
/// `remaining` bytes are read and dropped through `H2State::Discard`,
/// in pieces as small as the room in `zero` allows, like HAProxy's
/// "skipping remaining frame payload". `handle_data_frame` will never
/// see this frame, so its whole `payload_len`, padding included, is
/// credited to the connection receive window here (RFC 9113 §6.9), as
/// its closed-stream branch does. READABLE interest is restored because
/// the stream may have been parked on buffer pressure, and
/// `try_resume_reading` only resumes a stream that still exists.
fn skip_orphaned_data_payload(&mut self, remaining: usize, payload_len: u32) {
let conn_threshold = self.connection_config.initial_connection_window / 2;
if let Some(increment) = self
.flow_control
.account_received_bytes(payload_len, conn_threshold)
{
self.queue_window_update(0, increment);
self.readiness.arm_writable();
}
self.state = H2State::Discard;
self.discarded_field_block = None;
self.stream_table
.set_expect_read(Some((H2StreamId::Zero, remaining)));
self.readiness.interest.insert(Ready::READABLE);
}
/// Whether the read in progress is an orphaned DATA remainder that is
/// dropped as it is read ([`Self::skip_orphaned_data_payload`]). A
/// refused HEADERS discard always carries a field block.
fn skipping_orphaned_payload(&self) -> bool {
matches!(self.state, H2State::Discard) && self.discarded_field_block.is_none()
}
/// Drop stream-id mappings for streams that never became active before a
/// connection-level close. This happens on incomplete/oversized header
/// blocks: the stream slot is created on the initial HEADERS frame, then a
/// GOAWAY closes the connection before the request is fully materialized.
pub fn prune_inactive_streams_while_closing<L>(&mut self, context: &mut Context<L>)
where
L: ListenerHandler + L7ListenerHandler,
{
if !self.drain.draining() || !matches!(self.state, H2State::GoAway | H2State::Error) {
return;
}
let stale_streams = self
.stream_table
.streams()
.iter()
.filter_map(|(&stream_id, &global_stream_id)| {
(!context.streams[global_stream_id].state.is_open())
.then_some((stream_id, global_stream_id))
})
.collect::<Vec<_>>();
for (stream_id, global_stream_id) in stale_streams {
let stream = &mut context.streams[global_stream_id];
if stream.state == StreamState::Idle {
stream.front.clear();
stream.front.storage.clear();
stream.back.clear();
stream.back.storage.clear();
stream.metrics.reset();
stream.state = StreamState::Recycle;
}
self.remove_dead_stream(stream_id, global_stream_id);
}
}
/// Post-write phase: check drain completion, flush TLS, and update readiness.
///
/// `bytes_written_this_pass` reports the total outbound bytes `write_streams`
/// pushed to the socket (across every stream), and is used to distinguish
/// two very different "no `expect_write`" states:
///
/// - **Voluntary yield with progress**: at least one DATA/HEADERS frame
/// emitted, but a stream left non-empty `back.out`/`back.blocks` because
/// the converter yielded (e.g. RFC 9218 incremental rotation). LIFECYCLE
/// §9 invariant 16: keep `Ready::WRITABLE` armed so the session loop can
/// resume flushing on the next tick without waiting for an external
/// wake-up that edge-triggered epoll will not deliver.
/// - **No progress at all**: converter pushed every block back (e.g. flow
/// window exhausted, no HEADERS ready yet). Strip `Ready::WRITABLE` —
/// forward progress must come from an external trigger
/// (`WINDOW_UPDATE`, new request), not from looping writable().
///
/// Answers [`H2FinalizeTarget`]: [`H2FinalizeTarget::Done`] in the normal
/// case — `MuxResult::Continue`, or the graceful GOAWAY taken when
/// draining with every stream gone — and otherwise the TLS flush step its
/// caller must perform before [`Self::finalize_write_after_flush`] settles
/// the pass.
///
/// `tls_wants_write` is `H2Shell::tls_wants_write`'s answer, read by the
/// caller. It is the pre-flush half of the query / flush / query triple
/// this function used to run inline; the flush and the post-flush query
/// are the caller's too, which is what leaves a body a byte-in /
/// byte-out core can keep. The caller reads it before the RFC 9113 §6.8
/// graceful-GOAWAY check below rather than after, which the query's
/// freedom from side effects makes unobservable: on that path the value
/// is discarded.
pub fn finalize_write<L>(
&mut self,
tls_wants_write: bool,
socket_write: bool,
bytes_written_this_pass: usize,
context: &mut Context<L>,
) -> H2FinalizeTarget
where
L: ListenerHandler + L7ListenerHandler,
{
// RFC 9113 §6.8: if draining and all streams have completed,
// send the final GOAWAY with the actual last_stream_id
if self.drain.draining() && self.stream_table.streams().is_empty() {
return H2FinalizeTarget::Done(self.graceful_goaway(self.now));
}
// The flush triple and the readiness policy behind it are one
// decision, taken in `h2_close` where it is enumerable without a
// socket. Why it is a sibling of `CloseAction` rather than four more
// of its variants, why the conditional middle flush is an INPUT
// instead of an `if` kept here, and why the invariant-16 probe is
// passed as a closure: `h2_close::finalize_action`.
// Bound rather than matched inline: the invariant-16 probe below
// borrows `self.stream_table` and `context.streams`, and a `match`
// keeps its scrutinee's temporaries alive for every arm — including
// the arms that need `&mut self.readiness` and `&mut context.debug`.
let action = h2_close::finalize_action(
TlsFlushPhase::BeforeFlush,
tls_wants_write,
socket_write,
self.stream_table.expect_write().is_some(),
bytes_written_this_pass > 0,
|| any_stream_has_pending_back(self.stream_table.streams(), &context.streams),
self.control_tx.has_pending()
|| !self.flow_control.pending_window_updates_is_empty()
|| !self.output.is_empty(),
);
match action {
// A parked `expect_write` owns the next tick: no bit moves.
FinalizeAction::Parked => H2FinalizeTarget::Done(MuxResult::Continue),
// LIFECYCLE §9 invariant 16: a voluntary scheduler yield left
// stranded bytes in a stream's `back.out`/`back.blocks` after a
// pass that made forward progress. Retaining `Ready::WRITABLE` is
// the ABSENCE of the withdrawal below, so this arm changes no bit
// — it only narrates.
FinalizeAction::RetainPendingBack => {
#[cfg(debug_assertions)]
context.debug.push(DebugEvent::Str(
"finalize_write: invariant 16 retained WRITABLE (pending back-buffer)"
.to_owned(),
));
H2FinalizeTarget::Done(MuxResult::Continue)
}
// Control-frame liveness: a pass that ended before
// `flush_pending_control_frames` drained the RST / WINDOW_UPDATE
// queues, or with bytes still in the ordered output queue, must
// keep `Ready::WRITABLE`. Without this rearm the next tick would
// drop it and the queued frame would stall until an unrelated
// event re-triggered writable — which is exactly the scenario
// h2spec trips by sending back-to-back malformed streams. A
// stalled output flush parks the pass before it gets here, so
// the output leg is defence in depth (#1604).
FinalizeAction::ArmControlQueue => {
#[cfg(debug_assertions)]
context.debug.push(DebugEvent::Str(
"finalize_write: retained WRITABLE (control queue non-empty)".to_owned(),
));
self.readiness.arm_writable();
self.metric_events
.push(MetricEvent::WritableRearmedControlQueue);
H2FinalizeTarget::Done(MuxResult::Continue)
}
// We wrote everything.
FinalizeAction::Quiesce => {
#[cfg(debug_assertions)]
context.debug.push(DebugEvent::Str(format!(
"Wrote everything: {:?}",
self.stream_table.streams()
)));
self.readiness.interest.remove(Ready::WRITABLE);
H2FinalizeTarget::Done(MuxResult::Continue)
}
// The two TLS answers differ only in the step the CALLER performs,
// and both come back through the single post-flush query in
// `Self::finalize_write_after_flush`.
FinalizeAction::Flush => H2FinalizeTarget::Flush,
FinalizeAction::SkipFlush => H2FinalizeTarget::SkipFlush,
// Named rather than `other =>`: a wildcard arm would turn a new
// `FinalizeAction` variant into a release-mode panic on the proxy
// write path instead of a compile error.
action @ (FinalizeAction::ReArm | FinalizeAction::Settled) => {
unreachable!("BeforeFlush yielded {action:?}")
}
}
}
/// Settle a write pass whose [`H2FinalizeTarget`] asked for the TLS flush
/// step, given the answer `H2Shell::tls_wants_write` gave AFTER it.
///
/// The second half of [`Self::finalize_write`]'s protocol, and the reason
/// the split is not a rename: `tls_wants_write` here is a DIFFERENT
/// question from the one that produced [`H2FinalizeTarget::Flush`] or
/// [`H2FinalizeTarget::SkipFlush`]. The first asks whether rustls holds
/// records; this one asks whether the step between them landed —
/// `socket_write(&[])`'s `(size, status)` is discarded at the call site,
/// so nothing else on this path can tell. A caller that reused the first
/// answer here would re-arm forever or never, and either one strands the
/// connection under edge-triggered epoll.
///
/// The readiness policy of [`Self::finalize_write`] is deliberately NOT
/// reopened: once records were found pending, this pass leaves the bits to
/// the next one, so the degenerate arguments below are the shape
/// `h2_close::goaway_close_action`'s own post-flush call already uses —
/// the inputs the flush could not change are not re-read.
pub fn finalize_write_after_flush(&mut self, tls_wants_write: bool) -> MuxResult {
let action = h2_close::finalize_action(
TlsFlushPhase::AfterFlush,
tls_wants_write,
false,
false,
false,
|| false,
false,
);
match action {
// The post-flush answer is read once, by the caller, and feeds
// both the decision and the re-arm: they are one question asked at
// one point, and nothing mutates the socket between them. The
// re-arm still goes through `ensure_tls_flushed` rather than
// touching `signal_pending_write` here, which keeps every
// post-decision TLS re-arm in this file spelled the same way, as
// the GoAway and Error arms in `dispatch_writable_state` and
// `dispatch_writable_state_after_flush` already do.
FinalizeAction::ReArm => self.ensure_tls_flushed(tls_wants_write),
FinalizeAction::Settled => {}
action @ (FinalizeAction::Flush
| FinalizeAction::SkipFlush
| FinalizeAction::Parked
| FinalizeAction::RetainPendingBack
| FinalizeAction::ArmControlQueue
| FinalizeAction::Quiesce) => {
unreachable!("AfterFlush yielded {action:?}")
}
}
MuxResult::Continue
}
/// Queue pending control frames (WINDOW_UPDATEs, RST_STREAMs) behind the
/// ordered output and flush it, before entering the main writable state
/// machine.
///
/// Answers [`H2ControlFlushTarget`]: [`H2ControlFlushTarget::Proceed`]
/// when `writable()` should carry on into its state dispatch,
/// [`H2ControlFlushTarget::Done`] when a GOAWAY already ended the pass,
/// [`H2ControlFlushTarget::FlushOutput`] when [`Self::output_pending`]
/// holds bytes for the caller to move, and
/// [`H2ControlFlushTarget::Stalled`] when that move stalled — in which
/// case the caller reads `tls_wants_write` and hands it to
/// [`Self::ensure_tls_flushed`].
///
/// The socket write is the caller's, as everywhere on this write path:
/// the core answers a step, the caller performs the socket work, and the
/// core is told what the socket said, through
/// [`Self::handle_control_flush`].
///
/// There is one flush, at the end. Every stage appends whole frames to
/// the one ordered output queue (#1604), so a stage no longer has to wait
/// for an earlier one's bytes to leave a shared buffer before it can
/// serialise, and whatever was already queued — an ACK, the rest of a
/// stream frame a partial write cut — stays ahead of what this walk adds.
pub fn flush_pending_control_frames(&mut self) -> H2ControlFlushTarget {
// The peer hung up while the connection drains: nothing queued for it
// will be read. Only OUTPUT is dropped. `zero` is input only, and a
// `Ready::HUP` can be a TCP half-close with frames the peer already
// sent still unread (review of e1c3c2fb, B2), which the shutdown
// drive's forced `readable()` goes on to read.
if self.frontend_hung_up_while_draining() {
self.stream_table.set_expect_write(None);
self.clear_output();
self.flow_control.clear_pending_window_updates();
self.control_tx.clear_pending();
}
// RFC 9113 §6.5: check if peer has timed out on SETTINGS ACK
if let Some(sent_at) = self.settings_sent_at
&& self.now.saturating_duration_since(sent_at) >= SETTINGS_ACK_TIMEOUT
{
warn!(
"{} SETTINGS ACK timeout: no SETTINGS ACK observed within {:?}",
log_context!(self),
SETTINGS_ACK_TIMEOUT
);
return H2ControlFlushTarget::Done(self.goaway(H2Error::SettingsTimeout));
}
// Stage — queue pending WINDOW_UPDATE frames.
// Queued inline to avoid extra event loop iterations that could cause
// response data to be sent before subsequent frames are validated.
let pending = self.flow_control.pending_window_updates_len();
if pending > 0 {
let flow_control = &mut self.flow_control;
let mut frames_written = 0;
// Emission order is the deterministic ascending stream_id order
// `H2FlowControl` guarantees — see its module doc — not
// insertion/arrival order.
let _ = self
.output
.push_frames(pending * WINDOW_UPDATE_FRAME_SIZE, |buf| {
let (offset, frames) = flow_control.drain_window_updates_into(buf);
frames_written = frames;
Ok::<_, std::convert::Infallible>(offset)
});
if frames_written > 0 {
self.metric_events
.push(MetricEvent::WindowUpdateFramesSent(frames_written as i64));
}
}
// Stage — RST_STREAM overflow check + queue.
// The pending queue is bounded; an RST it had to refuse never reaches
// the wire, so the peer would believe that stream still live. Escalate
// before serialising anything. This is a bound on what is pending, not
// a lifetime cap: resets drained as they come never trip it, and the
// peer-provoked ones are capped by the flood detector instead.
if !matches!(self.state, H2State::GoAway | H2State::Error) && self.control_tx.overflowed() {
error!(
"{} pending RST_STREAM queue overflowed its bound {} ({} queued over the connection), sending GOAWAY(ENHANCE_YOUR_CALM)",
log_context!(self),
self.control_tx.max_pending(),
self.control_tx.lifetime_queued(),
);
return H2ControlFlushTarget::Done(self.goaway(H2Error::EnhanceYourCalm));
}
// Queue pending RST_STREAM frames (queued when refusing streams).
// Accounting happens at queue-time inside `Self::enqueue_rst`, so this
// only serialises — no metric/flood calls here would double-count.
// Emission order is queue order, which is arrival order over
// already-deduped stream ids — see `h2_control_tx`'s module doc.
let pending = self.control_tx.pending_len();
if pending > 0 {
let control_tx = &mut self.control_tx;
let _ = self
.output
.push_frames(pending * RST_STREAM_FRAME_SIZE, |buf| {
let (offset, _frames_written) = control_tx.drain_rst_streams_into(buf);
Ok::<_, std::convert::Infallible>(offset)
});
}
if self.output.is_empty() {
H2ControlFlushTarget::Proceed
} else {
H2ControlFlushTarget::FlushOutput
}
}
/// Answer the [`H2ControlFlushTarget::FlushOutput`] the caller just
/// performed: `stalled` is what the caller's write reported.
pub fn handle_control_flush(&mut self, stalled: bool) -> H2ControlFlushTarget {
if stalled {
H2ControlFlushTarget::Stalled
} else {
H2ControlFlushTarget::Proceed
}
}
/// Take `writable()`'s `(H2State, Position)` decision, given the answer
/// `H2Shell::tls_wants_write` gave AFTER the pass preamble's flush.
///
/// `tls_wants_write` is that answer, read by the caller. It is the third
/// step of the preamble's own query / flush / query triple and the
/// pre-flush question both close arms decide on: the
/// `(H2State::Error, Position::Server)` arm has no flush of its own and
/// reads nothing else, while the `H2State::GoAway` arm asks for a second
/// flush through [`H2WritableStateTarget::Flush`]. ONE read serves
/// whichever of the two runs — only one of them can — and nothing mutates
/// the socket between the caller's read and this call. The arms that never
/// consult it discard it, exactly as `H2Shell::write_streams` discards the
/// answer it reads before [`Self::finalize_write`]'s graceful-GOAWAY
/// check; `H2Shell::tls_wants_write` is free of side effects, which is what
/// makes both unobservable.
///
/// Answers [`H2WritableStateTarget`]. Every arm but two ends the pass with
/// [`H2WritableStateTarget::Done`]; the `H2State::GoAway` arm may ask for
/// the TLS flush step, and the four proxying states name
/// [`H2WritableStateTarget::WriteStreams`]. That last one is the caller's
/// to run for the same reason the flush is — `H2Shell::write_streams` owns
/// the socket and this function does not — and it is why this function
/// takes neither `Context` nor `Endpoint`: no other arm reads either.
pub fn dispatch_writable_state(&mut self, tls_wants_write: bool) -> H2WritableStateTarget {
match (&self.state, &self.position) {
(H2State::Error, Position::Server) => {
// The caller's preamble already attempted this pass's flush,
// so this arm decides on the post-flush answer and has no
// `Flush` of its own — see `h2_close`'s module doc.
match h2_close::error_close_action(tls_wants_write) {
CloseAction::ReArmAndContinue => {
self.ensure_tls_flushed(tls_wants_write);
H2WritableStateTarget::Done(MuxResult::Continue)
}
CloseAction::CloseSession => {
H2WritableStateTarget::Done(MuxResult::CloseSession)
}
// Named rather than `other =>`: a wildcard arm would turn a
// new `CloseAction` variant into a release-mode panic in the
// proxy write path instead of a compile error.
action @ (CloseAction::Flush | CloseAction::Disconnect) => {
unreachable!("error_close_action yielded {action:?}")
}
}
}
// The preface and SETTINGS are out and the server's SETTINGS are
// not read yet: there is nothing to write until they are. A write
// readiness here is not an error — `start_stream` arms one for a
// stream linked to this connection, which `Router::plan_connect`'s
// pool scan does for a `Connected` connection whatever its handshake
// state — so it is withdrawn, and the SETTINGS ACK the read queues
// re-arms it (sozu-proxy/sozu#1631). Only the control-frame preamble,
// which already ran, may have written: stream frames wait for `Header`.
(H2State::ServerSettings, Position::Client(..)) => {
trace!(
"{} waiting for the server SETTINGS before writing streams",
log_context!(self)
);
self.readiness.interest.remove(Ready::WRITABLE);
debug_assert!(
!self.readiness.interest.is_writable(),
"a backend awaiting the server SETTINGS must stop asking to write"
);
debug_assert!(
self.readiness.interest.is_readable(),
"a backend awaiting the server SETTINGS must keep reading them"
);
H2WritableStateTarget::Done(MuxResult::Continue)
}
(H2State::Error, _) | (H2State::ClientSettings, Position::Server) => {
error!(
"{} Unexpected combination: (Writable, {:?}, {:?})",
log_context!(self),
self.state,
self.position
);
H2WritableStateTarget::Done(self.force_disconnect())
}
(H2State::ClientPreface, Position::Server) => {
H2WritableStateTarget::Done(MuxResult::Continue)
}
// Discard state: pending data (e.g. RST_STREAM) was already
// written in the caller's control-frame preamble; let the readable
// path consume the remaining frame payload.
(H2State::Discard, _) => H2WritableStateTarget::Done(MuxResult::Continue),
// A stream parked by a stalled write still has whole frames
// queued. The rest of the frame the stall cut, if any, already
// went with the GOAWAY from the ordered output (#1604); finish
// the others before the close decision, since a GOAWAY does not
// close the streams at or below its last stream id (RFC 9113
// §6.8).
(H2State::GoAway, _)
if matches!(
self.stream_table.expect_write(),
Some(H2StreamId::Other { .. })
) =>
{
H2WritableStateTarget::WriteStreams
}
(H2State::GoAway, _) => {
// Response DATA frames may still sit in rustls's output
// buffer — accepted by socket_write_vectored during
// write_streams() but not yet flushed to TCP. Under TCP
// backpressure (HAProxy chain) this is the primary truncation
// vector, so the decision lives in `h2_close`, exhaustively
// unit-tested there rather than inline here. Why the two
// `socket_wants_write()` queries are two DIFFERENT questions,
// and why the flush between them stays inside the same
// `writable()` call: see `h2_close::TlsFlushPhase`. The flush
// and the query after it belong to the caller, and
// `H2WritableStateTarget::Flush` is how this arm asks for them.
match h2_close::goaway_close_action(
TlsFlushPhase::BeforeFlush,
self.peer_gone_after_final_goaway(),
tls_wants_write,
) {
CloseAction::CloseSession => {
H2WritableStateTarget::Done(MuxResult::CloseSession)
}
CloseAction::Flush => H2WritableStateTarget::Flush,
CloseAction::Disconnect => H2WritableStateTarget::Done(self.force_disconnect()),
action @ CloseAction::ReArmAndContinue => {
unreachable!("BeforeFlush yielded {action:?}")
}
}
}
(H2State::ClientPreface, Position::Client(..)) => {
trace!("{} Preparing preface and settings", log_context!(self));
// RFC 9113 §3.4: the preface is the first byte on the wire.
// No stream frame can be queued before it, because no stream
// opens before `Header` (sozu-proxy/sozu#1631).
debug_assert!(
self.output.is_empty(),
"nothing may be queued ahead of the client connection preface"
);
self.output.push(serializer::H2_PRI.as_bytes());
let local_settings = &self.local_settings;
match self.output.push_frames(SETTINGS_FRAME_SIZE, |buf| {
serializer::gen_settings(buf, local_settings).map(|(_, size)| size)
}) {
Ok(_) => {
self.metric_events.push(MetricEvent::SettingsFrameSent);
// RFC 9113 §6.5: start tracking SETTINGS ACK timeout
self.settings_sent_at = Some(self.now);
}
Err(error) => {
error!(
"{} Could not serialize SettingsFrame: {:?}",
log_context!(self),
error
);
return H2WritableStateTarget::Done(self.force_disconnect());
}
};
self.state = H2State::ClientSettings;
self.readiness.signal_pending_write();
H2WritableStateTarget::Done(MuxResult::Continue)
}
(H2State::ClientSettings, Position::Client(..)) => {
trace!("{} Sent preface and settings", log_context!(self));
self.state = H2State::ServerSettings;
self.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
// A backend connection starts write-only
// (`Connection::new_h2_client`); it reads from here on, for
// the server's SETTINGS. The preamble's output flush used to
// turn READABLE on as a side effect of draining `zero`.
self.readiness.interest.insert(Ready::READABLE);
self.readiness.interest.remove(Ready::WRITABLE);
H2WritableStateTarget::Done(MuxResult::Continue)
}
(H2State::ServerSettings, Position::Server) => {
// The connection-window enlargement already left with the
// SETTINGS it follows: the `(ClientSettings, Server)` readable
// arm serialises both into `zero`, which the preamble above
// drained in one write.
self.expect_header();
H2WritableStateTarget::Done(MuxResult::Continue)
}
// Proxying states — writing application data (request/response).
// These arms are NOT a filter on what the pass emits: a connection
// whose only queued output is a PING or SETTINGS acknowledgement is
// in `H2State::Header` too, so reaching `write_streams` never meant
// "application data is being written". The frontend timeout is
// therefore armed by `ConnectionH2::handle_write`, per stream
// transmit that moved bytes, and not on the way in here.
(H2State::Header, _)
| (H2State::Frame(_), _)
| (H2State::ContinuationFrame(_), _)
| (H2State::ContinuationHeader(_), _) => H2WritableStateTarget::WriteStreams,
}
}
/// Settle the `H2State::GoAway` arm whose [`H2WritableStateTarget::Flush`]
/// asked for the TLS flush step, given the answer
/// `H2Shell::tls_wants_write` gave AFTER it.
///
/// The second half of [`Self::dispatch_writable_state`]'s protocol, built
/// the way [`Self::finalize_write_after_flush`] is built and for the same
/// reason: `tls_wants_write` here is a DIFFERENT question from the one
/// that arm decided on. The first asks whether rustls holds records; this
/// one asks whether the flush between them landed —
/// `socket_write(&[])`'s `(size, status)` is discarded at the call site,
/// so nothing else on this path can tell. A caller that reused the first
/// answer here would close a TLS connection with records still pending,
/// and the peer would read a truncated response; that is the truncation
/// vector `h2_close`'s module doc names, not a style point.
///
/// `peer_gone` is deliberately not re-read — the degenerate `false` below
/// is `h2_close::goaway_close_action`'s own post-flush convention — for
/// the same reason [`Self::finalize_write_after_flush`] does not reopen
/// the readiness policy: the flush cannot resurrect a departed peer, and
/// the decision answers from the one input it could have changed.
pub fn dispatch_writable_state_after_flush(&mut self, tls_wants_write: bool) -> MuxResult {
match h2_close::goaway_close_action(TlsFlushPhase::AfterFlush, false, tls_wants_write) {
CloseAction::ReArmAndContinue => {
self.ensure_tls_flushed(tls_wants_write);
MuxResult::Continue
}
CloseAction::Disconnect => self.force_disconnect(),
// Named rather than `other =>`: a wildcard arm would turn a new
// `CloseAction` variant into a release-mode panic in the proxy
// write path instead of a compile error.
action @ (CloseAction::CloseSession | CloseAction::Flush) => {
unreachable!("AfterFlush yielded {action:?}")
}
}
}
/// Report the access-log RTTs for the local frontend and the linked backend.
///
/// Both cells are asked of `endpoint`, at the moment the stream is logged
/// and at no other time. `client_rtt` comes from [`Endpoint::local_rtt`]:
/// `ShellEndpoint` samples the frontend socket at the first stream logged
/// on the connection and hands every later stream that one value, so
/// every stream of a connection reports one number (issue #1339 Q11
/// asked for one per pass), and a connection that logs nothing reads
/// nothing (issue #1590). `server_rtt` comes from [`Endpoint::peer_rtt`],
/// the backend connection's own one sample. `doc/configure.md` states
/// both for operators reading the log.
///
/// Nothing in this method reaches a socket, which is the point: the local
/// RTT was the last `SocketHandler::socket_ref` read in this file, and the
/// shell that owns the socket answers it now.
///
/// `Position::Server`-only. On a backend H2 connection (`Position::Client`)
/// the report would write swapped values onto the shared `Stream.metrics`:
/// `Mux` samples the frontend into the frontend connection alone, and the
/// corresponding `EndpointServer::peer_rtt` returns that same frontend, so
/// the per-stream `client_rtt`/`server_rtt` cells would be populated with
/// mislabelled values. Gating keeps backend H2 from poisoning the
/// access-log metric for the matching frontend stream.
///
/// Callers must invoke this BEFORE `endpoint.end_stream(...)` on reset
/// paths so the backend lookup does not depend on
/// `EndpointClient::end_stream` continuing to leave entries in
/// `Router.backends`.
fn snapshot_rtts<E: Endpoint>(
&self,
endpoint: &E,
linked_token: Option<mio::Token>,
) -> (Option<Duration>, Option<Duration>) {
if !self.position.is_server() {
return (None, None);
}
(
endpoint.local_rtt(),
linked_token.and_then(|t| endpoint.peer_rtt(t)),
)
}
/// Try to recycle a completed server-side stream by distributing overhead,
/// generating access logs, and transitioning the stream to `Recycle` state.
///
/// Returns `Some((stream_id, Option<token>))` if the stream was recycled, so the
/// caller can add `stream_id` to the dead-streams list and call `endpoint.end_stream()`
/// if a token was returned. Returns `None` if recycling was deferred or not applicable.
///
/// `client_rtt`/`server_rtt` are snapshotted by the caller — which must
/// do so BEFORE `endpoint.end_stream(...)`, see [`Self::snapshot_rtts`] —
/// and forwarded into the access log. `stream` stays a parameter rather
/// than being looked up here because the caller already holds it as
/// `&mut context.streams[global_stream_id]`.
#[allow(clippy::too_many_arguments)]
fn try_recycle_server_stream<L>(
&mut self,
stream: &mut crate::protocol::mux::Stream,
global_stream_id: GlobalStreamId,
stream_id: StreamId,
byte_totals: (usize, usize),
debug: &mut DebugHistory,
listener: std::rc::Rc<std::cell::RefCell<L>>,
client_rtt: Option<Duration>,
server_rtt: Option<Duration>,
) -> Option<(StreamId, Option<mio::Token>)>
where
L: ListenerHandler + L7ListenerHandler,
{
match self.position {
Position::Client(..) => None,
Position::Server => {
// Already logged by a reset path; retire the stream after its RST is flushed.
if stream.metrics.start.is_none() {
let state = std::mem::replace(&mut stream.state, StreamState::Recycle);
return match state {
StreamState::Linked(token) => Some((stream_id, Some(token))),
_ => Some((stream_id, None)),
};
}
// Don't recycle if the client hasn't sent END_STREAM yet —
// more DATA frames may arrive for this stream.
if !stream.front_received_end_of_stream {
trace!(
"{} Defer recycle stream {}: client still sending",
log_module_context!(),
global_stream_id
);
return None;
}
let stream_bytes = (
stream.metrics.bin + stream.metrics.backend_bin,
stream.metrics.bout + stream.metrics.backend_bout,
);
let active_streams = self.stream_table.streams().len();
distribute_overhead(
&mut stream.metrics,
&mut self.bytes.overhead_bin,
&mut self.bytes.overhead_bout,
stream_bytes,
byte_totals,
active_streams,
active_streams == 1,
);
debug.push(DebugEvent::StreamEvent(4, global_stream_id));
trace!(
"{} Recycle stream: {}",
log_module_context!(),
global_stream_id
);
self.metric_events.push(MetricEvent::EndToEndH2Request);
if routed_to_a_backend(stream) {
self.flood_detector.record_stream_opened();
}
let (token, events) =
Self::complete_server_stream(stream, listener, client_rtt, server_rtt);
self.metric_events.extend(events.into_iter().flatten());
Some((stream_id, token))
}
}
}
/// Finalize a server-side stream after its response has been fully written.
///
/// Generates an access log, resets metrics, and transitions the stream to `Recycle`.
/// Returns the backend token if the stream was `Linked`, so the caller can call
/// `endpoint.end_stream()` with the full `Context` (which can't be passed here
/// because `stream` borrows from `context.streams`).
///
/// Callers must distribute overhead *before* calling this: it resets
/// `stream.metrics`, so a share credited afterwards would be discarded.
fn complete_server_stream<L>(
stream: &mut crate::protocol::mux::Stream,
listener: std::rc::Rc<std::cell::RefCell<L>>,
client_rtt: Option<Duration>,
server_rtt: Option<Duration>,
) -> (Option<mio::Token>, [Option<MetricEvent>; 2])
where
L: ListenerHandler + L7ListenerHandler,
{
stream.metrics.backend_stop();
let events = stream.generate_access_log(
false,
Some("H2::Complete"),
listener,
client_rtt,
server_rtt,
);
stream.metrics.reset();
let state = std::mem::replace(&mut stream.state, StreamState::Recycle);
let token = if let StreamState::Linked(token) = state {
Some(token)
} else {
None
};
(token, events)
}
/// Compute the total bytes transferred across all active streams.
///
/// Returns `(total_bytes_in, total_bytes_out)` where bytes_in = `bin + backend_bin`
/// and bytes_out = `bout + backend_bout` for each stream.
pub fn compute_stream_byte_totals<L: ListenerHandler + L7ListenerHandler>(
&self,
context: &Context<L>,
) -> (usize, usize) {
let mut total_in = 0usize;
let mut total_out = 0usize;
for &gid in self.stream_table.streams().values() {
let m = &context.streams[gid].metrics;
total_in += m.bin + m.backend_bin;
total_out += m.bout + m.backend_bout;
}
(total_in, total_out)
}
/// Distribute connection-level byte overhead proportionally to a single stream.
///
/// `totals` should be pre-computed via [`compute_stream_byte_totals`] **before**
/// taking a mutable borrow on the target stream, to avoid borrow conflicts.
/// Delegates to the free function [`distribute_overhead`].
fn distribute_overhead(&mut self, metrics: &mut SessionMetrics, totals: (usize, usize)) {
let stream_bytes = (
metrics.bin + metrics.backend_bin,
metrics.bout + metrics.backend_bout,
);
distribute_overhead(
metrics,
&mut self.bytes.overhead_bin,
&mut self.bytes.overhead_bout,
stream_bytes,
totals,
self.stream_table.streams().len(),
self.stream_table.streams().len() <= 1,
);
}
/// Attribute accumulated `zero_bytes_read` to the stream or to connection overhead.
fn attribute_bytes_to_stream(&mut self, metrics: &mut SessionMetrics) {
self.position
.count_bytes_in(metrics, self.bytes.zero_bytes_read);
self.bytes.zero_bytes_read = 0;
}
fn attribute_bytes_to_overhead(&mut self) {
self.bytes.overhead_bin += self.bytes.zero_bytes_read;
self.bytes.zero_bytes_read = 0;
}
/// Queue a WINDOW_UPDATE, coalescing with any existing entry for the same stream_id.
/// RFC 9113 §6.9.1: window size increment MUST be 1..2^31-1 (0x7FFFFFFF);
/// `increment == 0` is a legal no-op to *send* and queues nothing (see
/// `h2_flow_control::H2FlowControl::queue_window_update`).
///
/// Always signals pending write so callers don't have to remember the
/// edge-triggered epoll invariant:
/// under ET epoll a queued WINDOW_UPDATE without a live WRITABLE event bit
/// is invisible to filter_interest() and will never get flushed.
fn queue_window_update(&mut self, stream_id: u32, increment: u32) {
match self.flow_control.queue_window_update(
stream_id,
increment,
self.max_pending_window_updates,
) {
h2_flow_control::QueueWindowUpdateOutcome::Coalesced { old, new } => {
trace!(
"{} WINDOW_UPDATE coalesced: stream={} old={} new={}",
log_context!(self),
stream_id,
old,
new
);
}
h2_flow_control::QueueWindowUpdateOutcome::Inserted { increment } => {
trace!(
"{} WINDOW_UPDATE queued: stream={} increment={}",
log_context!(self),
stream_id,
increment
);
}
h2_flow_control::QueueWindowUpdateOutcome::Dropped => {
error!(
"{} WINDOW_UPDATE dropped: queue full ({} entries), stream={} increment={}",
log_context!(self),
self.max_pending_window_updates,
stream_id,
increment
);
self.metric_events.push(MetricEvent::WindowUpdateDropped);
}
// Zero increment: nothing was queued, nothing to log.
h2_flow_control::QueueWindowUpdateOutcome::Noop => {}
}
self.readiness.arm_writable();
}
/// Re-enable READABLE if this connection is parked waiting for buffer space
/// and the target stream's buffer now has enough room.
///
/// This is the cross-readiness counterpart to the same-connection check in
/// `writable()`. When the *other side* of a stream (frontend or backend)
/// drains data via its own `writable()`, it frees buffer space that this
/// connection was waiting for. Without this explicit wake-up the connection
/// stays parked and the session deadlocks until a timeout fires.
///
/// Returns `true` if READABLE was re-enabled.
pub fn try_resume_reading<L>(&mut self, context: &Context<L>) -> bool
where
L: ListenerHandler + L7ListenerHandler,
{
if let Some((
H2StreamId::Other {
gid: global_stream_id,
..
},
amount,
)) = self.stream_table.expect_read()
{
let stream = &context.streams[global_stream_id];
let kawa = match self.position {
Position::Client(..) => &stream.back,
Position::Server => &stream.front,
};
if kawa.storage.available_space() >= amount {
self.readiness.interest.insert(Ready::READABLE);
return true;
}
}
false
}
/// Mark a stream's position-appropriate end-of-stream flag.
///
/// Server reads from the front (client), so sets `front_received_end_of_stream`.
/// Client reads from the back (backend), so sets `back_received_end_of_stream`.
fn mark_end_of_stream(&self, stream: &mut crate::protocol::mux::Stream) {
if self.position.is_server() {
stream.front_received_end_of_stream = true;
} else {
stream.back_received_end_of_stream = true;
}
}
/// Cancel streams that have been idle longer than [`Self::stream_idle_timeout`].
///
/// A stream is considered idle when no meaningful application data (non-empty
/// DATA frames or HEADERS) has been received since the last activity timestamp
/// in `H2StreamTable::stream_last_activity_at`.
///
/// Mitigates slow-multiplex Slowloris (Pass 4 Medium #3): the connection-level
/// idle timer resets on every frame, so a peer sending periodic control frames
/// can pin `max_concurrent_streams` slots for the full nominal connection timeout.
/// Per-stream idle deadlines guarantee each stream terminates if it stops making
/// forward progress, regardless of connection-level liveness.
///
/// Timed-out streams receive RST_STREAM(CANCEL) and are immediately removed
/// from the streams map so they no longer count against MAX_CONCURRENT_STREAMS.
/// Backend endpoints are notified and metrics are finalized.
pub fn cancel_timed_out_streams<E, L>(&mut self, context: &mut Context<L>, endpoint: &mut E)
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
// Entry point: `Mux::timeout` reaches this directly, without going
// through `readable`, so adopt the mux's snapshot here too.
self.now = context.now;
// Per-connection scratch Vecs (`converter_buf`, `lowercase_buf`,
// `cookie_buf`, and the scheduler's own pass-order buffer) grow to a
// high-water mark and never shrink. On a long-lived idle H2
// connection that briefly carried a flurry of large headers, the
// backing memory stays pinned indefinitely. Reclaim past
// `SCRATCH_BUF_RETAIN` when the connection has live streams but
// each scratch buffer holds 4× the cap. Quiet-time only — runs
// at the top of every `cancel_timed_out_streams` invocation
// (which is itself called from the readable hot loop, but only
// on a session that has been idle long enough to risk timing
// out a stream).
const SCRATCH_BUF_RETAIN: usize = 16 * 1024;
self.hpack.reclaim_idle_buffers(SCRATCH_BUF_RETAIN);
self.scheduler.reclaim_idle_buffer(SCRATCH_BUF_RETAIN);
if self.stream_table.is_empty()
|| (self.stream_table.activity_is_empty() && self.stream_table.fc_stall_is_empty())
{
return;
}
let now = self.now;
let deadline = self.stream_idle_timeout;
// Two independent per-stream guards reap on the same deadline — see
// `H2StreamTable::collect_timed_out`. The flow-control-stall guard
// (`stream_fc_stalled_since`) closes the HTTP/2 window-stall vector that
// the bidirectional liveness guard (`stream_last_activity_at`) misses,
// because an inbound DATA drip keeps the liveness timer warm while the
// response stays window-blocked.
let timed_out = self.stream_table.collect_timed_out(now, deadline);
if timed_out.is_empty() {
return;
}
for (sid, reason) in timed_out {
info!(
"{} H2 stream {} exceeded {:?} ({}), cancelling",
log_context!(self),
sid,
deadline,
reason
);
// M1: break reaps down by guard so a window-stall reap (a DoS
// mitigation) is distinguishable from an ordinary idle reap on a
// dashboard. M2: a window-stall reap whose stream dribbled some
// outbound progress (`acc > 0`) below the floor is specifically a
// stall-budget reap — the `WINDOW_UPDATE`-drip vector the budget
// closes — counted as a subset. Read the accumulator BEFORE
// `remove_dead_stream` evicts it below.
match reason {
"H2::WindowStall" => {
self.metric_events
.push(MetricEvent::StreamReapedWindowStall);
if matches!(self.stream_table.fc_stall_progress(sid), Some(acc) if acc > 0) {
self.metric_events
.push(MetricEvent::StreamReapedStallBudget);
}
}
"H2::IdleTimeout" => self
.metric_events
.push(MetricEvent::StreamReapedIdleTimeout),
other => debug!("{} unexpected reap reason {}", log_context!(self), other),
}
// Route through the canonical chokepoint so dedupe (rst_sent), the
// pending-queue bound, and edge-triggered-epoll arming
// (Readiness::arm_writable) all stay consistent — see LIFECYCLE
// §8.2. The previous direct push bypassed all three: a mass reap
// could push past the queue bound silently, a double-cancel pass
// would grow the pending queue instead of short-circuiting on the
// existing rst_sent membership, and the hand-rolled
// `interest.insert(WRITABLE) + signal_pending_write` pair skipped
// invariant 15.
//
// The CANCEL is Sōzu's own decision (`RstOrigin::Local`), so it is
// not charged to the peer's MadeYouReset count: an idle stream is
// also what a slow backend, a long poll or a paused download looks
// like, and an idle stream pins one MAX_CONCURRENT_STREAMS slot for
// the idle timeout without amplifying anything.
//
// We deliberately ignore the `Option<MuxResult>` signal here —
// `cancel_timed_out_streams` returns `()` and is called as
// best-effort housekeeping during the read path. A local reset
// never trips the flood detector, and a queue overflow is
// escalated by the next `flush_pending_control_frames`.
//
// A backend stream whose HEADERS never left is forgotten, not
// reset: a RST_STREAM on a stream the backend has never seen is a
// connection error that takes down every stream of the shared
// connection (sozu-proxy/sozu#1631, see the `Position::Client` arm
// of `ConnectionH2::end_stream`).
let never_opened = matches!(self.position, Position::Client(..))
&& self
.stream_table
.get(sid)
.is_some_and(|gid| !context.streams[gid].front.consumed);
let queued_before = self.control_tx.lifetime_queued();
if !never_opened {
let _ = self.enqueue_rst(sid, H2Error::Cancel, RstOrigin::Local);
}
debug_assert!(
!never_opened || self.control_tx.lifetime_queued() == queued_before,
"a backend stream reaped before its HEADERS left must not be reset"
);
// Remove from streams map and recycle the context stream so the slot
// no longer counts against MAX_CONCURRENT_STREAMS.
// Compute totals per-stream before remove (matches RST_STREAM handler).
let byte_totals = self.compute_stream_byte_totals(context);
if let Some(global_stream_id) = self.stream_table.get(sid) {
{
let stream = &mut context.streams[global_stream_id];
self.attribute_bytes_to_stream(&mut stream.metrics);
}
// Check if stream is linked to a backend — borrow must be scoped
// so end_stream can take &mut context.
let linked_token = context.streams[global_stream_id].linked_token();
let (client_rtt, server_rtt) = self.snapshot_rtts(&*endpoint, linked_token);
if let Some(token) = linked_token {
endpoint.end_stream(token, global_stream_id, context);
}
let stream = &mut context.streams[global_stream_id];
match &self.position {
Position::Client(_, backend, BackendStatus::Connected) => {
context.record_backend_delta(backend, BackendChange::StreamsEnded(1));
}
Position::Client(..) => {}
Position::Server => {
self.distribute_overhead(&mut stream.metrics, byte_totals);
stream.metrics.backend_stop();
let events = stream.generate_access_log(
true,
Some(reason),
context.listener.clone(),
client_rtt,
server_rtt,
);
self.metric_events.extend(events.into_iter().flatten());
stream.state = StreamState::Recycle;
}
}
// Retire sid from streams/prioriser/stream_last_activity_at and
// invalidate expect_write/expect_read if they reference this gid.
self.remove_dead_stream(sid, global_stream_id);
}
}
// Writable arming is already done by enqueue_rst -> arm_writable in
// the loop above; the trailing pair was redundant after the chokepoint
// routing landed.
}
/// Queue a `RST_STREAM` frame for serialisation by
/// [`Self::flush_pending_control_frames`] on the next writable tick.
///
/// This is the canonical entry point for proxy-emitted stream resets:
/// `DATA` on a closed stream, `MAX_CONCURRENT_STREAMS` refusal, and the
/// per-stream error paths in [`Self::reset_stream`] all funnel through
/// here. `origin` says whether the peer provoked the reset; see
/// [`RstOrigin`]. Serialisation is independent of the owning `Stream` still
/// existing in `self.streams`, which is what lets us emit even after a
/// caller has already called [`Self::remove_dead_stream`].
///
/// Delegates the queueing itself to [`h2_control_tx::H2ControlTx::enqueue_rst`],
/// which owns the invariants (dedupe via `rst_sent`, the per-insert queue
/// bound and its overflow flag, edge-triggered-epoll arm via
/// [`Readiness::arm_writable`]) and covers them with unit tests that need no
/// `ConnectionH2` fixture. What stays here is the accounting, because a
/// MadeYouReset cap trip converts to a connection-wide GOAWAY only this
/// type can return.
///
/// Two of the call paths that reach here are never inspected by
/// [`Self::check_invariants`], whose only production caller is
/// [`Self::handle_frame`]:
///
/// * `cancel_timed_out_streams`, which queues one RST per timed-out stream
/// in a single sweep and, from `Mux::timeout` against a silent peer,
/// runs when `handle_frame` does not run at all;
/// * the DATA-on-closed-stream reset, which sits in
/// [`Self::handle_header_state`] — `handle_read` returns into that helper
/// directly and that branch never reaches `handle_frame`.
///
/// The second path is separately rate-limited: it is preceded by
/// `H2FloodDetector::record_glitch` + `check_flood_or_return!`, whose
/// `DEFAULT_MAX_GLITCH_COUNT` (2000) bounds it inside one flood window, and
/// the window can only half-decay when `Mux::ready_inner` resamples
/// `context.now` on a new outer iteration — which is strictly after an
/// inner iteration that already ran `writable()`, since `arm_writable`
/// raises both the WRITABLE interest and its event bit. So it cannot
/// accumulate across windows without a `flush_pending_control_frames`
/// pass in between. The per-insert bound inside
/// [`h2_control_tx::H2ControlTx::enqueue_rst`] is what makes that reasoning
/// unnecessary for correctness.
fn enqueue_rst(
&mut self,
wire_stream_id: StreamId,
error: H2Error,
origin: RstOrigin,
) -> Option<MuxResult> {
// Only a registered stream's id goes into `rst_sent`: eviction from
// the stream table is what removes it again. A refused stream or a
// closed one was never (or is no longer) registered, and its id would
// otherwise stay in the set for the connection's lifetime. A refusal
// records its id in the bounded recently-reset set instead
// (`H2StreamTable::remember_refused`).
let registered = self.stream_table.get(wire_stream_id).is_some();
let rst_sent = registered.then(|| self.stream_table.rst_sent_mut());
let outcome =
self.control_tx
.enqueue_rst(rst_sent, &mut self.readiness, wire_stream_id, error);
// Account ONLY when a new RST actually entered the queue.
// Calling `enqueue_rst` for a stream that already has a queued
// (or already-flushed) RST is the dedup short-circuit — counting
// those would inflate `h2.frames.tx.rst_stream` /
// `h2.rst_stream.sent.*` and the CVE-2025-8671 MadeYouReset
// count on frames that never reached the wire. An
// at-capacity refusal is accounted the same way, and for the same
// reason: that frame never reaches the wire either.
//
// Account at queue-time, not at drain-time. Doing it later in
// `flush_pending_control_frames` would double-count any RST that
// a re-entrant call (DATA on a closed stream we already RSTed)
// tried to enqueue — and missing it at queue-time leaves
// `cancel_timed_out_streams` / `refuse_stream_and_discard` /
// DATA-on-closed-stream paths bypassing the lifetime cap
// (security review LISA-001 on commit `da845c71`).
match outcome {
h2_control_tx::EnqueueRstOutcome::Queued => self.account_emitted_rst(error, origin),
h2_control_tx::EnqueueRstOutcome::Deduped => None,
// Drop + metric + contextual log, never a panic on the release
// path. No GOAWAY is raised here: the refusal set
// `H2ControlTx::overflowed`, which
// `flush_pending_control_frames` escalates to
// `GOAWAY(ENHANCE_YOUR_CALM)` before its drain loop for as long as
// the connection is not already in `H2State::GoAway`/`Error` — and
// once it is, the peer already holds a GOAWAY and `writable()`
// force-disconnects. See `EnqueueRstOutcome::Dropped`. Raising a
// second one from here would re-enter `goaway()` once per
// remaining reaped stream, clobbering `self.zero` and inflating
// `h2.goaway.sent.*`.
h2_control_tx::EnqueueRstOutcome::Dropped => {
error!(
"{} RST_STREAM dropped: pending queue already at capacity ({}), stream={} error={:?}",
log_context!(self),
self.control_tx.max_pending(),
wire_stream_id,
error
);
self.metric_events.push(MetricEvent::RstStreamDropped);
None
}
}
}
/// Single accounting site for proxy-emitted RST_STREAM frames.
/// Every emitted RST bumps the global tx counter and the per-error
/// breakdown; only a non-`NoError` reset the peer provoked
/// ([`RstOrigin::PeerProvoked`]) also feeds the MadeYouReset emitted-RST
/// cap.
///
/// Two distinct emission paths feed this helper:
/// * Queued frames — `Self::enqueue_rst` (and therefore every
/// callable that funnels through it: `reset_stream`,
/// `refuse_stream_and_discard`, `cancel_timed_out_streams`,
/// DATA-on-closed-stream) calls this once at queue-time. The
/// drain in `flush_pending_control_frames` does NOT call it
/// again — that would double-count.
/// * Converter-emitted frames — the converter's `initialize`
/// chokepoint (and the HPACK over-budget abort path) writes
/// RST_STREAM frames straight into `kawa.out` from inside
/// `kawa.prepare`. We collect those `H2Error` codes during the
/// `write_streams` loop and call this helper for each one
/// after the loop. They are all [`RstOrigin::Local`]: what errored
/// is the message coming from the other side of the proxy — a
/// backend response on a frontend connection, a client request on a
/// backend one — never a frame this connection's peer sent.
///
/// Returning `Some(MuxResult)` means the caller MUST short-circuit
/// with that result — the flood detector tripped its lifetime cap
/// and converted to a connection-wide GOAWAY.
fn account_emitted_rst(&mut self, error: H2Error, origin: RstOrigin) -> Option<MuxResult> {
self.metric_events.push(MetricEvent::RstStreamSent(error));
if origin == RstOrigin::PeerProvoked && !matches!(error, H2Error::NoError) {
if let Some(violation) = self.flood_detector.record_rst_emitted() {
return Some(self.handle_flood_violation(violation));
}
// A provoked reset answers a protocol error by the peer, so it is
// also a glitch: the per-window rate limit the ratio cap above
// lacks. None of the `PeerProvoked` call sites records a glitch
// of its own, so this is the only charge.
self.flood_detector.record_glitch();
if let Some(violation) = self.flood_detector.check_flood(self.now) {
return Some(self.handle_flood_violation(violation));
}
}
None
}
/// Refuse a newly-opened stream with RST_STREAM and discard its HEADERS payload.
///
/// Used when MAX_CONCURRENT_STREAMS is exceeded or buffer pool is exhausted.
/// Queues the RST_STREAM for the writable path (can't write to kawa.storage
/// here because it is needed to discard the HEADERS payload).
///
/// Also applies SETTINGS back-pressure per RFC 9113 §5.1.2: if refusals
/// burst past [`BACKPRESSURE_REFUSAL_THRESHOLD`] within
/// [`BACKPRESSURE_WINDOW_DURATION`], the advertised
/// `SETTINGS_MAX_CONCURRENT_STREAMS` is halved via
/// [`Self::apply_mcs_backpressure`].
///
/// `discarded` is stashed in [`Self::discarded_field_block`] for the
/// `H2State::Discard` arm of [`Self::handle_read`] to consume — see
/// [`DiscardedFieldBlock`] for why the HPACK field block cannot simply be
/// dropped with the rest of the payload.
fn refuse_stream_and_discard(
&mut self,
stream_id: StreamId,
error: H2Error,
origin: RstOrigin,
payload_len: u32,
discarded: DiscardedFieldBlock,
) -> MuxResult {
if let Some(result) = self.enqueue_rst(stream_id, error, origin) {
return result;
}
// RFC 9113 §5.1: the frames the peer sent on this stream before it
// read the refusal are ignored, as on any stream Sōzu reset.
self.stream_table.remember_refused(stream_id);
let result = self.discard_field_block(stream_id, payload_len, discarded);
self.record_refusal_for_backpressure();
result
}
/// Extend [`Self::flood_refused_streams`] with `stream_id`, refused under
/// flood pressure, or start a new run when a stream was accepted since
/// the last refusal (the even watermark `last_stream_id` moved past the
/// run's first id).
fn note_flood_refused_stream(&mut self, stream_id: StreamId) {
let run = match self.flood_refused_streams {
Some((first, _)) if self.last_stream_id < first => (first, stream_id),
_ => (stream_id, stream_id),
};
debug_assert!(
run.0 <= run.1 && run.1 == stream_id,
"the refused run ends at the stream just refused"
);
debug_assert!(
self.last_stream_id < run.0,
"no accepted stream lies inside the refused run"
);
self.flood_refused_streams = Some(run);
}
/// Read the `payload_len` bytes of a HEADERS or CONTINUATION frame of
/// `stream_id` that is dropped through `H2State::Discard`, which decodes
/// its field block only to keep the HPACK dynamic table in sync (RFC
/// 9113 §4.3), then resumes normal reading. Queues nothing. A frame
/// without END_HEADERS leaves its fragment in
/// [`Self::pending_discarded_block`], so the CONTINUATION frames that
/// complete the block are discarded and decoded with it.
fn discard_field_block(
&mut self,
stream_id: StreamId,
payload_len: u32,
discarded: DiscardedFieldBlock,
) -> MuxResult {
// A new block's first frame counts toward the block's size like an
// accepted one's (CVE-2024-27316), so its CONTINUATION frames are
// held to the same `max_header_list_size`; the whole frame payload
// stands in for the fragment, which it bounds.
if matches!(discarded, DiscardedFieldBlock::New { .. }) {
self.flood_detector.begin_header_block_if_new(payload_len);
}
self.state = H2State::Discard;
self.stream_table
.set_expect_read(Some((H2StreamId::Zero, payload_len as usize)));
self.discarded_field_block = Some((stream_id, discarded));
MuxResult::Continue
}
/// Minimally process and drop the payload of the DATA or HEADERS frame
/// whose `header` was just parsed, for a stream that is reset or being
/// reset (RFC 9113 §5.1): a field block, with the CONTINUATION frames
/// completing it, still updates the HPACK decoder, DATA still counts
/// toward the connection flow-control window.
fn discard_frame_payload(&mut self, header: &FrameHeader) -> MuxResult {
debug_assert!(
matches!(header.frame_type, FrameType::Data | FrameType::Headers),
"only DATA and HEADERS payloads are discarded per stream"
);
if header.frame_type == FrameType::Headers {
self.discard_field_block(
header.stream_id,
header.payload_len,
DiscardedFieldBlock::New {
flags: header.flags,
},
)
} else {
self.skip_orphaned_data_payload(header.payload_len as usize, header.payload_len);
MuxResult::Continue
}
}
/// RFC 9113 §5.4.2: answer a stream error on the open stream `header`
/// names with RST_STREAM(`error`), tear the stream down, and discard the
/// frame's payload ([`Self::discard_frame_payload`]). Every other stream
/// on the connection carries on. The RST_STREAM goes through
/// [`Self::reset_stream`], so it feeds the emitted-RST flood accounting.
fn reset_stream_and_discard_frame<E, L>(
&mut self,
header: &FrameHeader,
global_stream_id: GlobalStreamId,
error: H2Error,
context: &mut Context<L>,
endpoint: E,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
let result =
self.reset_stream(header.stream_id, global_stream_id, context, endpoint, error);
self.remove_dead_stream(header.stream_id, global_stream_id);
if !matches!(result, MuxResult::Continue) {
return result;
}
self.discard_frame_payload(header)
}
/// RFC 9113 §5.1.2 SETTINGS back-pressure bookkeeping.
///
/// Increments the refusal counter for the current back-pressure window
/// and, when the burst threshold is crossed, halves the advertised
/// `SETTINGS_MAX_CONCURRENT_STREAMS`. Further halving attempts in the
/// same connection are suppressed by [`Self::mcs_backpressure_applied`]
/// so sustained abuse does not collapse the cap to zero — callers can
/// still promote the situation to `EnhanceYourCalm` via the flood
/// detector.
fn record_refusal_for_backpressure(&mut self) {
if self.now.saturating_duration_since(self.refuse_window_start)
>= BACKPRESSURE_WINDOW_DURATION
{
self.refuse_count_window = 0;
self.refuse_window_start = self.now;
}
self.refuse_count_window = self.refuse_count_window.saturating_add(1);
if !self.mcs_backpressure_applied
&& self.refuse_count_window >= BACKPRESSURE_REFUSAL_THRESHOLD
{
self.apply_mcs_backpressure();
}
}
/// Halve the advertised `SETTINGS_MAX_CONCURRENT_STREAMS` and mark the
/// back-pressure state as applied. The new value takes effect locally
/// immediately — subsequent stream-open checks in `handle_header_state`
/// compare `self.stream_table.streams().len()` against this reduced cap, so the peer
/// starts receiving `REFUSED_STREAM` earlier. A full SETTINGS re-send on
/// the wire is deferred until we have a mid-connection SETTINGS queue
/// (the existing path in `handle_preface_state` only fires during the
/// handshake); this is noted in the task log as a minimal first step.
fn apply_mcs_backpressure(&mut self) {
let previous = self.local_settings.settings_max_concurrent_streams;
let reduced = (previous / 2).max(1);
warn!(
"{} H2 SETTINGS back-pressure: refusals={} in {}s — halving \
SETTINGS_MAX_CONCURRENT_STREAMS {} -> {}",
log_context!(self),
self.refuse_count_window,
BACKPRESSURE_WINDOW_DURATION.as_secs(),
previous,
reduced,
);
self.local_settings.settings_max_concurrent_streams = reduced;
self.mcs_backpressure_applied = true;
}
/// Log a flood violation with full session context and emit the GOAWAY.
///
/// Centralises the "flood detected" reporting so every site that observes a
/// `H2FloodViolation` gets the same session-scoped log line, matching the
/// RUSTLS log-context convention. Also emits the per-kind statsd counter
/// (`h2.flood.violation.<kind>`) so SOC dashboards can window the trip
/// rate without parsing logs — every CVE-mitigation in the H2 family
/// (Rapid Reset, MadeYouReset, CONTINUATION/PING/SETTINGS floods, header
/// overflow, glitch) funnels through this site.
pub fn handle_flood_violation(&mut self, violation: H2FloodViolation) -> MuxResult {
self.metric_events.push(MetricEvent::FloodViolation {
metric_key: violation.metric_key,
});
warn!(
"{} H2 flood detected: {} count {} exceeds threshold {}",
log_context!(self),
violation.reason,
violation.count,
violation.threshold,
);
self.goaway(violation.error)
}
}
/// Whether an answered frontend stream counts toward the RST caps'
/// denominator (`H2FloodDetector`'s `streams_opened`,
/// `lib/src/protocol/mux/h2_flood_detector.rs`): it was routed to a backend.
/// A backend answered it, a backend was selected for it — including when
/// Sōzu then answered 502/503/504 because that backend refused the
/// connection, failed or timed out — or its cluster has backends and none
/// could be selected because all of them are failing (`backends_unavailable`).
/// So a backend outage does not turn the client's ordinary cancels into a
/// Rapid Reset verdict. A stream answered before any backend was selected
/// for another reason does not count, whatever its status: no route, a
/// redirect, 401, 421, 429, a refusal, a 503 for a cluster with no backend,
/// or a session or buffer limit hit before selection. The peer opens those
/// for free, and counting them would let filler streams dilute the ratio.
fn routed_to_a_backend(stream: &crate::protocol::mux::Stream) -> bool {
stream.metrics.backend_headers_received.is_some()
|| stream.context.backend_id.is_some()
|| stream.context.backends_unavailable
}
/// Who decided a proxy-emitted `RST_STREAM`, for flood accounting.
///
/// Only a reset the peer provoked counts toward the CVE-2025-8671
/// MadeYouReset cap (`H2FloodDetector::record_rst_emitted`,
/// `lib/src/protocol/mux/h2_flood_detector.rs`). A reset Sōzu decides on its
/// own — the idle reaper's `CANCEL`, a `REFUSED_STREAM` from its own
/// concurrency limit, back-pressure, buffer pool or flood soft state, `STREAM_CLOSED` for DATA
/// on a stream it already closed, the converter's error on a backend failure —
/// says nothing about the peer, and counting it would end healthy connections
/// for Sōzu's own decisions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RstOrigin {
/// A frame the peer sent forced the reset: Content-Length mismatch,
/// header parse error, oversized header block, PRIORITY
/// self-dependency, zero-increment or overflowing WINDOW_UPDATE.
PeerProvoked,
/// Sōzu reset the stream on its own initiative.
Local,
}
/// Recover the [`H2Error`] code that the converter's `initialize`
/// chokepoint will encode into the synthesised RST_STREAM frame for a
/// kawa stuck in [`kawa::ParsingPhase::Error`]. Mirrors the parse +
/// fallback at `lib/src/protocol/mux/converter.rs::initialize` so the
/// flood-accounting helper sees the same code that lands on the wire.
fn rst_error_from_kawa<T: kawa::AsBuffer>(kawa: &kawa::Kawa<T>) -> H2Error {
match kawa.parsing_phase {
kawa::ParsingPhase::Error {
kind: kawa::ParsingErrorKind::Processing { message },
..
} => message.parse::<H2Error>().unwrap_or(H2Error::InternalError),
_ => H2Error::InternalError,
}
}
/// Compile-time mapping from `(prefix, H2Error)` to a static metric key.
///
/// Materialises a `&'static str` literal via `concat!`, so the metric key
/// never crosses through a heap allocation and the statsd drain can store it
/// as `&'static str`. Adding a new `H2Error` variant fails the build here —
/// the metric breakdown stays in lock-step with RFC 9113 §7 codes.
///
/// Used for the per-error-code counters emitted around GOAWAY and RST_STREAM
/// in either direction (see `metric_for_goaway_sent` etc. below).
macro_rules! h2_error_metric_key {
($prefix:literal, $error:expr) => {
match $error {
H2Error::NoError => concat!($prefix, ".no_error"),
H2Error::ProtocolError => concat!($prefix, ".protocol_error"),
H2Error::InternalError => concat!($prefix, ".internal_error"),
H2Error::FlowControlError => concat!($prefix, ".flow_control_error"),
H2Error::SettingsTimeout => concat!($prefix, ".settings_timeout"),
H2Error::StreamClosed => concat!($prefix, ".stream_closed"),
H2Error::FrameSizeError => concat!($prefix, ".frame_size_error"),
H2Error::RefusedStream => concat!($prefix, ".refused_stream"),
H2Error::Cancel => concat!($prefix, ".cancel"),
H2Error::CompressionError => concat!($prefix, ".compression_error"),
H2Error::ConnectError => concat!($prefix, ".connect_error"),
H2Error::EnhanceYourCalm => concat!($prefix, ".enhance_your_calm"),
H2Error::InadequateSecurity => concat!($prefix, ".inadequate_security"),
H2Error::HTTP11Required => concat!($prefix, ".http_1_1_required"),
}
};
}
/// Static metric key for an outbound GOAWAY. Same call shape as the other three
/// helpers below — keeps the call sites uniform.
fn metric_for_goaway_sent(error: H2Error) -> &'static str {
h2_error_metric_key!("h2.goaway.sent", error)
}
/// Static metric key for an inbound GOAWAY by raw wire error code. Codes
/// outside RFC 9113 §7 fall into the dedicated `…unknown_error` bucket so the
/// breakdown stays bounded and operators can still spot non-standard peers.
fn metric_for_goaway_received(error_code: u32) -> &'static str {
H2Error::try_from(error_code)
.map(|e| h2_error_metric_key!("h2.goaway.received", e))
.unwrap_or("h2.goaway.received.unknown_error")
}
/// Static metric key for an outbound RST_STREAM. Mirrors
/// [`metric_for_goaway_sent`] under a separate namespace so RST and GOAWAY
/// rates can be alerted on independently.
fn metric_for_rst_stream_sent(error: H2Error) -> &'static str {
h2_error_metric_key!("h2.rst_stream.sent", error)
}
/// Static metric key for an inbound RST_STREAM by raw wire error code. Same
/// `…unknown_error` fallback as [`metric_for_goaway_received`].
fn metric_for_rst_stream_received(error_code: u32) -> &'static str {
H2Error::try_from(error_code)
.map(|e| h2_error_metric_key!("h2.rst_stream.received", e))
.unwrap_or("h2.rst_stream.received.unknown_error")
}
/// Static metric key for an inbound H2 frame by RFC 9113 §6 frame type.
/// Emitted at the `handle_frame` dispatch — single chokepoint that any
/// new H2 frame type must traverse, so adding a `Frame::*` variant fails
/// the build here. Counts are per-frame, not per-byte; pair with
/// `bytes_in` for traffic-mix dashboards.
fn h2_frame_rx_metric_key(frame_type: FrameType) -> &'static str {
match frame_type {
FrameType::Data => "h2.frames.rx.data",
FrameType::Headers => "h2.frames.rx.headers",
FrameType::PushPromise => "h2.frames.rx.push_promise",
FrameType::Priority => "h2.frames.rx.priority",
FrameType::RstStream => "h2.frames.rx.rst_stream",
FrameType::Settings => "h2.frames.rx.settings",
FrameType::Ping => "h2.frames.rx.ping",
FrameType::GoAway => "h2.frames.rx.goaway",
FrameType::WindowUpdate => "h2.frames.rx.window_update",
FrameType::Continuation => "h2.frames.rx.continuation",
FrameType::PriorityUpdate => "h2.frames.rx.priority_update",
FrameType::Unknown(_) => "h2.frames.rx.unknown",
}
}
impl ConnectionH2 {
/// Queue the final GOAWAY with `last_stream_id` set to the highest
/// peer-initiated stream observed (`H2StreamTable::highest_peer_stream_id`).
pub fn goaway(&mut self, error: H2Error) -> MuxResult {
let last_stream_id = self.stream_table.highest_peer_stream_id();
self.goaway_with_last_stream_id(error, last_stream_id)
}
/// True while a peer HEADERS block is still being reassembled
/// (`H2State::ContinuationHeader` / `H2State::ContinuationFrame`).
pub fn peer_header_block_in_progress(&self) -> bool {
matches!(
self.state,
H2State::ContinuationHeader(_) | H2State::ContinuationFrame(_)
)
}
/// RFC 9113 §6.8: the final GOAWAY a draining connection sends when its
/// graceful-shutdown budget elapses, just before `Mux::shutting_down_inner`
/// (`lib/src/protocol/mux/mod.rs`) closes it. Without it a forced close
/// leaves the peer holding only the advisory `2^31-1` GOAWAY, which says
/// nothing about which streams were processed (sozu-proxy/sozu#1647).
///
/// A stream whose opening field block is still being reassembled is
/// excluded: it is `StreamState::Idle`, it never reached a backend, and
/// the peer may retry it. `create_stream` already counted it in
/// `H2StreamTable::highest_peer_stream_id` when its HEADERS frame header
/// arrived. Every stream `create_stream` accepted before it has a lower
/// id (RFC 9113 §5.1.1, enforced by `handle_header_state` against
/// `Self::last_stream_id`), and none can be accepted after it while the
/// block is open (§6.10), so every stream sozu may have acted on is below
/// it: the reported id is the one two below it (the previous odd id, or
/// 0). A higher id counted in `highest_peer_stream_id` can only be a
/// refused stream, which sozu did not act on either.
///
/// A connection already in `H2State::GoAway` or `H2State::Error` has
/// sent (or failed to send) its final GOAWAY and gets no second one.
pub fn goaway_before_forced_close<L>(&mut self, context: &Context<L>) -> MuxResult
where
L: ListenerHandler + L7ListenerHandler,
{
debug_assert!(
self.drain.draining(),
"a forced close follows a graceful drain, which marks the connection draining"
);
if matches!(self.state, H2State::GoAway | H2State::Error) {
return MuxResult::Continue;
}
let highest = self.stream_table.highest_peer_stream_id();
let last_stream_id = match &self.state {
H2State::ContinuationHeader(headers) | H2State::ContinuationFrame(headers)
if self
.stream_table
.get(headers.stream_id)
.and_then(|global_stream_id| context.streams.get(global_stream_id))
.is_some_and(|stream| stream.state == StreamState::Idle) =>
{
headers.stream_id.saturating_sub(2)
}
_ => highest,
};
debug_assert!(
last_stream_id <= highest && (last_stream_id == 0 || last_stream_id & 1 == 1),
"the final GOAWAY reports 0 or a peer-initiated id no higher than any observed"
);
self.goaway_with_last_stream_id(H2Error::NoError, last_stream_id)
}
fn goaway_with_last_stream_id(
&mut self,
error: H2Error,
last_stream_id: StreamId,
) -> MuxResult {
self.state = H2State::Error;
self.drain.enter_final_goaway();
self.graceful_goaway = error == H2Error::NoError;
self.stream_table.set_expect_read(None);
// Disarm the SETTINGS ACK timer: once we've committed to GOAWAY, the
// timeout check at `readable()` / `flush_pending_control_frames()` must
// not re-fire. Without this, `signal_pending_write()` below re-enters
// `writable()` → `flush_pending_control_frames()` on the next tick,
// the elapsed check is still true, and we emit another
// `warn!` + `goaway()` pair, each bumping `h2.goaway.sent.*`.
self.settings_sent_at = None;
// Severity tiering: only `InternalError` implies a sozu-side bug when
// WE emit it. Every other non-`NoError` reason is "peer misbehaved,
// sozu defended correctly" — operators don't need paging on abusive
// or buggy peers. Caller sites already log the specific antecedent
// (flood detected, parser failure, SETTINGS timeout, invalid window)
// before reaching `goaway()`, so demoting this summary line avoids
// duplicate noise without hiding the root cause.
match error {
H2Error::NoError => debug!("{} GOAWAY: {:?}", log_context!(self), error),
H2Error::InternalError => error!("{} GOAWAY: {:?}", log_context!(self), error),
_ => warn!("{} GOAWAY: {:?}", log_context!(self), error),
}
self.metric_events.push(MetricEvent::GoAwaySent(error));
// RFC 9113 §6.8: last_stream_id is the highest peer-initiated stream
// we processed. Queued behind whatever output is already waiting (an
// ACK, the rest of a stream frame), never in place of it.
match self.output.push_frames(GOAWAY_FRAME_SIZE, |buf| {
serializer::gen_goaway(buf, last_stream_id, error).map(|(_, size)| size)
}) {
Ok(_) => {
self.metric_events.push(MetricEvent::GoAwayFrameSent);
self.state = H2State::GoAway;
self.readiness.interest = Ready::WRITABLE | Ready::HUP | Ready::ERROR;
self.readiness.signal_pending_write();
MuxResult::Continue
}
Err(error) => {
error!(
"{} Could not serialize GoAwayFrame: {:?}",
log_context!(self),
error
);
self.force_disconnect()
}
}
}
/// RFC 9113 §6.8: Initiate graceful shutdown using the double-GOAWAY pattern.
///
/// First call sends GOAWAY with `last_stream_id = 0x7FFFFFFF` (MAX) to signal
/// the intent to stop accepting new streams while allowing in-flight streams
/// to complete. The connection enters draining mode.
///
/// When `draining` is already true (second invocation), sends the final GOAWAY
/// with the actual `highest_peer_stream_id` so the peer knows which streams
/// were processed.
///
/// `now` is the caller's clock snapshot and is what arms the forced-close
/// budget. It is a parameter rather than a read of `Self::now` because
/// the caller that matters — `Mux::shutting_down` — runs outside
/// `ready()` and therefore outside the pass that last refreshed the
/// mirror. In-module callers pass `self.now`.
pub fn graceful_goaway(&mut self, now: Instant) -> MuxResult {
// The initial GOAWAY goes out even while a peer HEADERS/CONTINUATION
// block is still being reassembled: RFC 9113 §4.3/§6.10 constrain the
// block's sender, not a GOAWAY travelling the other way, and it is
// queued in `self.output`, which shares nothing with `zero` or
// `header_reassembly`. Deferring it (#1401, removed in #1637) let a
// drain close a session whose only stream was mid-block without any
// GOAWAY — see `h2_drain`'s module doc.
match self.drain.begin_graceful_drain(now) {
// Second GOAWAY: send with the real last_stream_id.
GracefulDrainDecision::AlreadyDraining => self.goaway(H2Error::NoError),
// First GOAWAY: advertise MAX stream ID so the peer knows we are
// draining but does not yet know the cutoff. This gives in-flight
// requests a chance to arrive before we commit to a final
// last_stream_id.
GracefulDrainDecision::SendInitial => self.send_initial_goaway(),
}
}
/// Serializes and queues the first, advisory GOAWAY
/// (`NO_ERROR`, `last_stream_id = STREAM_ID_MAX`) of a graceful drain
/// in the ordered output queue (`self.output`), behind whatever is
/// already queued there.
fn send_initial_goaway(&mut self) -> MuxResult {
// Keep expect_read as-is: existing streams should continue reading
// data during the drain window opened by the initial GOAWAY. Only
// the final GOAWAY (via `goaway()`) removes READABLE.
debug!(
"{} GOAWAY (graceful, initial): last_stream_id=0x7FFFFFFF",
log_context!(self)
);
// The initial GOAWAY sends NO_ERROR on the wire — count it under
// the same per-code key as the final GOAWAY. The downstream alert
// that wants to distinguish drain from termination compares
// against the `h2.goaway.sent.no_error` rate (drain) vs the other
// variants (termination on error).
self.metric_events
.push(MetricEvent::GoAwaySent(H2Error::NoError));
// Queued behind whatever output is already waiting, like the final one.
match self.output.push_frames(GOAWAY_FRAME_SIZE, |buf| {
serializer::gen_goaway(buf, STREAM_ID_MAX, H2Error::NoError).map(|(_, size)| size)
}) {
Ok(_) => {
self.metric_events.push(MetricEvent::GoAwayFrameSent);
// Stay in the current state so the connection can continue processing
// existing streams. The final GOAWAY will transition to GoAway state.
// Keep READABLE so in-flight request bodies can still be received
// during the drain window. Only remove READABLE in the final GOAWAY
// (via `goaway()`).
self.readiness.arm_writable();
MuxResult::Continue
}
Err(error) => {
error!(
"{} Could not serialize graceful GoAwayFrame: {:?}",
log_context!(self),
error
);
self.force_disconnect()
}
}
}
/// Returns `true` when the graceful-shutdown budget armed by
/// [`Self::graceful_goaway`] has elapsed. A return of `true` signals
/// the enclosing session loop that the proxy-initiated drain must
/// transition to a forced close: remaining streams will not complete
/// in time and keeping the connection open past the deadline defeats
/// the soft-stop SLA.
///
/// Returns `false` when:
/// - drain has not started yet (`started_at` is `None`),
/// - the knob is `0` / `None` (indefinite wait explicitly opted in),
/// - or the elapsed time is still within the configured budget.
pub fn graceful_shutdown_deadline_elapsed(&self) -> bool {
self.drain.deadline_elapsed(self.now)
}
/// Arm the graceful-shutdown budget of a connection a proxy soft-stop
/// finds already draining, which `graceful_goaway` is not called for: a
/// peer GOAWAY drains without arming it. See
/// `H2DrainState::arm_deadline_if_unarmed` (`h2_drain.rs`).
pub fn arm_graceful_shutdown_deadline(&mut self, now: Instant) {
self.drain.arm_deadline_if_unarmed(now);
}
/// True when the reaper has queued control frames (`RST_STREAM`) into
/// `h2_control_tx::H2ControlTx` that have not yet been serialized. Kept SEPARATE
/// from [`H2Shell::has_pending_write`] because that probe gates connection close
/// (the `mod.rs` close-gating sites) and must NOT treat a queued RST as a
/// reason to keep the connection open; this probe is consulted ONLY by the
/// `MuxState::timeout` flush gate to push a silent-peer `RST_STREAM(CANCEL)`
/// onto the wire before the connection closes.
/// Adopt the embedder's clock snapshot for this pass.
///
/// `Self::now` is mirrored from `Context::now` at each public entry
/// point rather than read from the host — invariant 20 — and a driver
/// standing where `H2Shell::writable` stands has to perform that mirror
/// itself. The other entry points that need it do it inside their own
/// bodies; the write pass is assembled by its caller, so its mirror is
/// exposed.
pub fn adopt_now(&mut self, now: Instant) {
self.now = now;
}
/// The landing zone an [`H2ReadTarget::Fill`] names.
///
/// The read half of the byte-in / byte-out surface: a driver answers
/// `Fill` by filling this slice and reporting what it managed, and never
/// needs to know which buffer it belongs to. `H2Shell::readable` fills it
/// from a socket; `sim/tests/h2_simulation.rs` fills it from a
/// `VecDeque<u8>`, and that is the whole difference between them.
///
/// Takes `context` apart from `self` rather than borrowing all of `*self`,
/// for the reason `read_space` gives: the caller must keep the rest of
/// the connection borrowable while the slice is live.
pub fn read_space<'a, L>(
&'a mut self,
context: &'a mut Context<L>,
stream_id: H2StreamId,
amount: usize,
) -> &'a mut [u8]
where
L: ListenerHandler + L7ListenerHandler,
{
read_space(
&mut self.zero,
&mut context.streams,
&self.position,
stream_id,
amount,
)
}
/// Gather one [`H2WriteTarget::Transmit`]: the ordered output queue
/// first, then `stream_id`'s queued frames, through
/// [`h2_transmit::gather_after`]. Answers `(prefix_len, bytes_offered)`.
///
/// # Safety
///
/// [`h2_transmit::gather_after`]'s contract: neither this connection's
/// output queue nor the stream's `kawa` may be mutated or dropped until
/// `io_slices` is emptied, which [`h2_transmit::confirm`] does first.
pub unsafe fn gather_transmit<L>(
&self,
context: &Context<L>,
stream_id: H2StreamId,
io_slices: &mut Vec<std::io::IoSlice<'static>>,
) -> (usize, usize)
where
L: ListenerHandler + L7ListenerHandler,
{
let H2StreamId::Other { gid, .. } = stream_id else {
unreachable!("no transmit names the connection's input buffer");
};
let stream = &context.streams[gid];
// The WRITE side of `Stream::split`: a server writes the response
// from `back`, a client the request from `front`.
let kawa = match self.position {
Position::Client(..) => &stream.front,
Position::Server => &stream.back,
};
// SAFETY: forwarded, see this function's contract.
unsafe { h2_transmit::gather_after(self.output.pending(), kawa, io_slices) }
}
/// The buffer an [`H2WriteTarget::Transmit`] names.
///
/// The write half of the same surface, and the one a driver brackets with
/// [`Self::gather_transmit`] and [`h2_transmit::confirm`] around its own
/// vectored write. See `write_buffer` for why it takes `context` apart
/// from `self`.
pub fn write_buffer<'a, L>(
&'a mut self,
context: &'a mut Context<L>,
stream_id: H2StreamId,
) -> &'a mut GenericHttpStream
where
L: ListenerHandler + L7ListenerHandler,
{
write_buffer(
&mut self.zero,
&mut context.streams,
&self.position,
stream_id,
)
}
/// The queued output bytes an [`H2ControlFlushTarget::FlushOutput`] is
/// asking the caller to move, or empty when there are none left.
pub fn output_pending(&self) -> &[u8] {
self.output.pending()
}
/// Whether [`Self::output_pending`] ends with the last bytes this
/// connection will ever send, so the caller may queue the TLS
/// `close_notify` behind them and flush both at once.
///
/// Only [`Self::goaway`] sets [`H2State::GoAway`], and only once its final
/// GOAWAY is queued; the state's writable arm then closes
/// (`h2_close::goaway_close_action`). Every other condition rules out a
/// write the same pass would still make after this flush:
///
/// - no stream left: no stream frame follows, and no stream is parked
/// (`H2StreamTable::remove` nulls its park);
/// - no WINDOW_UPDATE or RST_STREAM queued: the preamble already moved
/// every one of them into the output this flush drains;
/// - `Position::Server`: `close_notify` is a frontend TLS alert, as in
/// [`H2Shell::initiate_close_notify`];
/// - `close_notify_sent` unset: the alert is queued once.
fn output_flush_closes_connection(&self) -> bool {
self.position.is_server()
&& matches!(self.state, H2State::GoAway)
&& !self.close_notify_sent
&& self.stream_table.streams().is_empty()
&& self.flow_control.pending_window_updates_is_empty()
&& !self.control_tx.has_pending()
}
/// Account for one write of [`Self::output_pending`], and answer whether
/// the caller should stop.
///
/// `true` means the write stalled (WouldBlock or a zero-length write) and
/// the caller must wait for its next writable event; `false` means carry
/// on until [`Self::output_pending`] is empty.
pub fn consume_output_flush(&mut self, size: usize, status: SocketResult) -> bool {
self.note_write_status(status);
self.consume_output(size);
update_readiness_after_write(size, status, &mut self.readiness)
}
/// [`H2Shell::has_pending_write`] with the TLS answer supplied instead of
/// queried — the byte-in / byte-out spelling of the same probe.
///
/// A driver with no TLS layer passes `false` and gets the connection's own
/// two conditions back.
pub fn has_pending_write_with(&self, tls_wants_write: bool) -> bool {
if self.peer_gone_after_final_goaway() {
return false;
}
self.stream_table.expect_write().is_some() || !self.output.is_empty() || tls_wants_write
}
pub fn has_pending_control_write(&self) -> bool {
self.control_tx.has_pending()
}
/// Number of streams currently tracked in the wire map. `stream_table` is
/// private to this module, so this is the sole accessor for `router.rs`'s
/// H2-connection-reuse heuristic (picks the non-draining H2 backend
/// connection with the fewest active streams).
pub fn stream_count(&self) -> usize {
self.stream_table.len()
}
pub fn create_stream<L>(
&mut self,
stream_id: StreamId,
context: &mut Context<L>,
) -> Option<GlobalStreamId>
where
L: ListenerHandler + L7ListenerHandler,
{
// RFC 9113 §6.8: reject new streams on a draining connection
if self.drain.draining() {
error!(
"{} Rejecting new stream {} on draining connection",
log_context!(self),
stream_id
);
return None;
}
let streams_before = self.stream_table.len();
// Track the highest peer-initiated stream ID for GoAway frames
// before any early return, so GoAway always reports the correct last
// stream. `observe_peer_stream_id` asserts the monotonic-non-decreasing
// property itself.
self.stream_table.observe_peer_stream_id(stream_id);
// `Ulid::generate()` would read the wall clock and a thread-local RNG
// from inside `rusty_ulid` — two host reaches no grep over this module
// can see. `Context::next_request_id` composes the same ULID from the
// two sources the embedder owns instead.
let request_id = context.next_request_id();
let global_stream_id =
context.create_stream(request_id, self.peer_settings.settings_initial_window_size)?;
self.last_stream_id = (stream_id + 2) & !1;
self.stream_table
.register(stream_id, global_stream_id, self.now);
// Post-conditions: the stream is now reachable in both indices (see
// `H2StreamTable::register`'s own postconditions), the active count
// grew by exactly one (the id was not already present —
// `handle_header_state` rejects re-used ids), and `last_stream_id` is
// the even watermark just past this id so `new_stream_id` never collides.
debug_assert_eq!(
self.stream_table.len(),
streams_before + 1,
"create_stream must add exactly one stream (id must not pre-exist)"
);
debug_assert!(
self.last_stream_id > stream_id && self.last_stream_id & 1 == 0,
"last_stream_id watermark must be the even value strictly above stream_id"
);
Some(global_stream_id)
}
pub fn new_stream_id(&mut self) -> Option<StreamId> {
let watermark_before = self.last_stream_id;
let (issued, next) = next_stream_id(self.last_stream_id, self.position.is_client())?;
self.last_stream_id = next;
// Post-conditions: the locally-issued id has the parity of our role and
// the watermark advanced strictly (so the next allocation cannot reuse
// this id). `next_stream_id` already asserts parity vs `is_client`; here
// we re-assert against `self.position` and the watermark monotonicity.
debug_assert_eq!(
issued & 1 == 1,
self.position.is_client(),
"locally-issued stream id parity must match our role"
);
debug_assert!(
self.last_stream_id > watermark_before,
"issuing a stream id must advance the watermark"
);
Some(issued)
}
/// Test-only setter: jump `last_stream_id` close to `STREAM_ID_MAX` so
/// that the next call to [`Self::new_stream_id`] exhausts the 31-bit
/// space. FIX-22 ("Stream-ID exhaustion disconnects backend gracefully")
/// exercises the `None`-return branch — reaching it through normal API
/// usage would require issuing ~2³¹ requests, which is not tractable in
/// an E2E harness.
#[cfg(any(test, feature = "e2e-hooks"))]
pub fn __test_set_last_stream_id(&mut self, id: StreamId) {
self.last_stream_id = id;
}
/// Test-only setter: map `stream_id -> gid` on the wire WITHOUT arming
/// the per-stream liveness timer `stream_table.register` otherwise always
/// pairs it with. `mod::tests::shutting_down_refreshes_the_snapshot_so_the_drain_budget_expires`
/// needs exactly this artificial state, to force `cancel_timed_out_streams`
/// down its early-return path (empty activity map) rather than race an
/// RST_STREAM into that test's unrelated drain-budget assertion.
#[cfg(any(test, feature = "e2e-hooks"))]
pub fn __test_insert_wire_mapping_only(&mut self, stream_id: StreamId, gid: GlobalStreamId) {
self.stream_table
.__test_insert_wire_mapping_only(stream_id, gid);
}
/// Cross-field invariant sweep for the H2 connection state machine,
/// asserted as a run-to-completion post-condition at the end of every
/// frame-handling pass (see the call in [`Self::handle_frame`]).
///
/// These are relationships between *separate* fields that no single setter
/// can guarantee on its own — exactly the class of bug TigerStyle's
/// `check_invariants` targets. Each one is cheap (counter compares + a few
/// `HashMap` membership probes); the whole function is `#[cfg(debug_assertions)]`
/// and compiles out of release entirely.
///
/// Encoded invariants:
/// 1. **Stream-id watermark parity**: locally-issued ids never exceed
/// `STREAM_ID_MAX`; `last_stream_id` stays the even watermark (it is
/// rounded to `(id + 2) & !1` and initialised to 0).
/// 2. **Per-stream caches are subsets of the live stream set**:
/// `stream_last_activity_at` is keyed only by currently-tracked stream
/// ids — a leak here would let a removed stream keep an idle timer and
/// mis-fire `cancel_timed_out_streams`. (`rst_sent` is intentionally NOT
/// a subset: a queued RST for an already-removed stream is legal.)
/// 3. **RST queue accounting** is checked by
/// [`h2_control_tx::H2ControlTx::check_invariants`] as a post-condition
/// of its own mutating methods, so it is not restated here — a second
/// copy of a rule drifts from the first.
/// 4. **Pending WINDOW_UPDATE bound**: the coalescing map never exceeds the
/// per-connection cap derived from `max_concurrent_streams`.
/// 5. **Drain/state coupling**: a terminal `GoAway`/`Error` state implies the
/// connection is draining (`goaway()` sets both); the converse need not
/// hold (graceful drain stays in a live state).
#[cfg(debug_assertions)]
fn check_invariants<L>(&self, context: &Context<L>)
where
L: ListenerHandler + L7ListenerHandler,
{
// (1) Watermark parity and bound.
debug_assert!(
self.last_stream_id & 1 == 0,
"last_stream_id must stay an even watermark, got {}",
self.last_stream_id
);
// (2) Per-stream caches are subsets of the live stream set, and every
// mapping points at a valid context slot.
debug_assert!(
self.stream_table
.stream_last_activity_at()
.keys()
.all(|id| self.stream_table.streams().contains_key(id)),
"stream_last_activity_at must only track currently-open stream ids"
);
debug_assert!(
self.stream_table
.streams()
.values()
.all(|&gid| gid < context.streams.len()),
"every stream mapping must point at a valid context slot"
);
// (4) Pending WINDOW_UPDATE coalescing map bound.
debug_assert!(
self.flow_control.pending_window_updates_len() <= self.max_pending_window_updates,
"pending WINDOW_UPDATE map must stay within its per-connection cap"
);
// (5) Drain/state coupling: terminal states imply draining.
debug_assert!(
!matches!(self.state, H2State::GoAway | H2State::Error) || self.drain.draining(),
"GoAway/Error state must imply the connection is draining"
);
}
fn handle_frame<E, L>(
&mut self,
frame: Frame,
wire_payload_len: u32,
context: &mut Context<L>,
endpoint: E,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
trace!("{} {:#?}", log_context!(self), frame);
// Per-frame-type RX counter. Single chokepoint covers every H2 frame
// type — adding a new `Frame::*` variant fails the build inside the
// helper, keeping the metric breakdown in lock-step with RFC 9113 §6.
self.metric_events
.push(MetricEvent::FrameReceived(frame.frame_type()));
let result = match frame {
Frame::Data(data) => self.handle_data_frame(data, wire_payload_len, context, endpoint),
Frame::Headers(headers) => self.handle_headers_frame(headers, context, endpoint),
Frame::PushPromise(_) => self.handle_push_promise_frame(),
Frame::Priority(priority) => self.handle_priority_frame(priority, context, endpoint),
Frame::RstStream(rst_stream) => {
self.handle_rst_stream_frame(rst_stream, context, endpoint)
}
Frame::Settings(settings) => self.handle_settings_frame(settings, context),
Frame::Ping(ping) => self.handle_ping_frame(ping),
Frame::GoAway(goaway) => self.handle_goaway_frame(goaway, context, endpoint),
Frame::WindowUpdate(wu) => self.handle_window_update_frame(wu, context, endpoint),
Frame::PriorityUpdate(pu) => self.handle_priority_update_frame(pu),
Frame::Continuation(_) => {
// Unreachable: standalone CONTINUATION is rejected in
// `handle_header_state` (RFC 9113 §6.10) and in-block
// CONTINUATION is consumed by the inline header-parsing
// path. Keep a defensive fallback that returns
// PROTOCOL_ERROR rather than panicking in debug builds.
self.attribute_bytes_to_overhead();
warn!(
"{} CONTINUATION frames are handled inline during header parsing",
log_context!(self)
);
self.goaway(H2Error::ProtocolError)
}
// RFC 9113 §5.5: unknown frame types MUST be ignored and discarded.
// The parser already consumed the payload; attribute the bytes
// to connection-level overhead and continue.
Frame::Unknown(raw) => {
debug!(
"{} Ignoring unknown H2 frame type {}",
log_context!(self),
raw
);
self.attribute_bytes_to_overhead();
MuxResult::Continue
}
};
// Run-to-completion post-condition: the connection-level cross-field
// invariants must hold after every frame is dispatched, on success and
// on the protocol-error paths alike.
#[cfg(debug_assertions)]
self.check_invariants(context);
result
}
/// RFC 9110 §8.6: Content-Length validation must be skipped for responses
/// where the body is absent by definition:
/// - Responses to HEAD requests (any status)
/// - 1xx informational responses
/// - 204 No Content
/// - 304 Not Modified
fn content_length_exempt(
&self,
context: &crate::protocol::kawa_h1::editor::HttpContext,
) -> bool {
use crate::protocol::kawa_h1::parser::Method;
// HEAD method responses (only relevant when reading backend responses)
if self.position.is_client() && context.method == Some(Method::Head) {
return true;
}
// 1xx, 204, 304 status codes
if let Some(status) = context.status
&& ((100..200).contains(&status) || status == 204 || status == 304)
{
return true;
}
false
}
/// What `handle_data_frame` does with the DATA payload of a backend
/// response that has no content by definition (RFC 9110 §6.4.1). Only
/// meaningful when reading a backend response (`Position::Client`): on
/// the server side `context.status` describes the response, not the
/// request being read.
fn backend_response_content(
context: &crate::protocol::kawa_h1::editor::HttpContext,
) -> BackendResponseContent {
use crate::protocol::kawa_h1::parser::Method;
if matches!(context.status, Some(204 | 304)) {
BackendResponseContent::Forbidden
} else if context.method == Some(Method::Head) {
BackendResponseContent::Discarded
} else {
BackendResponseContent::Forwarded
}
}
fn handle_data_frame<E, L>(
&mut self,
data: parser::Data,
wire_payload_len: u32,
context: &mut Context<L>,
mut endpoint: E,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
// CVE-2019-9518: track empty DATA frames (no payload, no END_STREAM)
if data.payload.is_empty() && !data.end_stream {
self.flood_detector.record_empty_data_frame();
check_flood_or_return!(self);
}
let Some(global_stream_id) = self.stream_table.get(data.stream_id) else {
// The stream was terminated while data was expected,
// probably due to automatic answer for invalid/unauthorized access.
// RFC 9113 §6.9: we MUST still account for the DATA payload in
// connection-level flow control using the full wire length
// (including pad-length byte and padding), otherwise the window
// shrinks permanently and eventually stalls the connection.
let conn_threshold = self.connection_config.initial_connection_window / 2;
if let Some(increment) = self
.flow_control
.account_received_bytes(wire_payload_len, conn_threshold)
{
self.queue_window_update(0, increment);
self.readiness.arm_writable();
}
self.attribute_bytes_to_overhead();
return MuxResult::Continue;
};
let mut slice = data.payload;
let stream = &mut context.streams[global_stream_id];
// Unpadded application payload size — what is forwarded to the backend
// and counted against Content-Length.
let content_len = slice.len();
// Full wire-payload size (includes pad-length byte and padding).
// RFC 9113 §5.2: padding counts against flow-control windows.
let wire_len = wire_payload_len as usize;
let cl_exempt = self.content_length_exempt(&stream.context);
let content = if self.position.is_client() {
Self::backend_response_content(&stream.context)
} else {
BackendResponseContent::Forwarded
};
// Extract declared content-length and update position-aware data counter
let (data_received, declared_length) = {
let parts = stream.split(&self.position);
*parts.data_received += content_len;
let total = *parts.data_received;
let declared = match parts.rbuffer.body_size {
kawa::BodySize::Length(n) => Some(n),
_ => None,
};
(total, declared)
};
// RFC 9113 §6.9 + §5.2: credit connection-level flow control BEFORE any
// early-return path. Malformed DATA still consumed the peer's send
// window; without crediting it back, repeated bad streams permanently
// shrink the connection window and stall unrelated streams that share
// the same H2 connection. Stream-level credit can stay below — once we
// RST the violating stream, its per-stream window is moot per
// RFC 9113 §6.9 (the receiver discards further frames for the stream).
let conn_threshold = self.connection_config.initial_connection_window / 2;
if let Some(increment) = self
.flow_control
.account_received_bytes(wire_payload_len, conn_threshold)
{
self.queue_window_update(0, increment);
}
// RFC 9110 §15.3.5, §15.4.5, RFC 9113 §8.1.1: a 204 or a 304 cannot
// contain content, so DATA carrying a payload makes it malformed:
// reset the backend stream before the payload is queued, so it never
// reaches the client (an H1 client would read it as the start of the
// next response). `content_length_exempt` skips the length checks
// below for these responses, so they would not catch it. A response
// to HEAD only SHOULD NOT carry content (RFC 9110 §9.3.2): its payload
// is discarded below instead.
if content == BackendResponseContent::Forbidden && content_len > 0 {
error!(
"{} DATA with a {}-byte payload on a {:?} response (RFC 9110 §6.4.1)",
log_context!(self),
content_len,
stream.context.status
);
incr!(names::h2::BODILESS_RESPONSE_DATA_RESET);
if !self.flow_control.pending_window_updates_is_empty() {
self.readiness.arm_writable();
}
let result = self.reset_stream(
data.stream_id,
global_stream_id,
context,
endpoint,
H2Error::ProtocolError,
);
self.remove_dead_stream(data.stream_id, global_stream_id);
return result;
}
// RFC 9113 §8.1.1: if Content-Length is present, total DATA payload
// must not exceed the declared length (check on every frame).
// RFC 9110 §8.6: skip for HEAD/1xx/204/304 responses (body absent by definition).
if !cl_exempt
&& let Some(expected) = declared_length
&& data_received > expected
{
error!(
"{} Content-Length mismatch: received {} > declared {}",
log_context!(self),
data_received,
expected
);
// Pair WRITABLE arming with the queued connection-level
// WINDOW_UPDATE before returning; otherwise the credit sits
// until the next inbound frame on this connection.
if !self.flow_control.pending_window_updates_is_empty() {
self.readiness.arm_writable();
}
let result = self.reset_stream(
data.stream_id,
global_stream_id,
context,
endpoint,
H2Error::ProtocolError,
);
self.remove_dead_stream(data.stream_id, global_stream_id);
return result;
}
let stream = &mut context.streams[global_stream_id];
self.attribute_bytes_to_stream(&mut stream.metrics);
let stream_state = stream.state;
let is_unlinked = matches!(stream_state, StreamState::Unlinked);
let parts = stream.split(&self.position);
let kawa = parts.rbuffer;
self.position.count_bytes_in(parts.metrics, content_len);
// Stream-level flow control (only if stream is still open).
// Connection-level credit was already applied above the CL check so
// malformed DATA cannot starve the connection window for other streams.
if !data.end_stream {
self.queue_window_update(data.stream_id, wire_payload_len);
}
// If we have pending updates, ensure we get a writable event.
// Must use signal_pending_write() — not just interest.insert() — because
// under edge-triggered epoll the WRITABLE event bit may have been consumed
// by a previous write cycle. Without the event bit set, filter_interest()
// returns 0 and the WINDOW_UPDATEs never get flushed, stalling the client.
if !self.flow_control.pending_window_updates_is_empty() {
self.readiness.arm_writable();
}
// Refresh per-stream idle timer on non-empty DATA.
// Empty DATA frames (CVE-2019-9518 vector) must NOT reset the timer,
// otherwise an attacker can keep a stream alive indefinitely with
// zero-length frames while pinning a MAX_CONCURRENT_STREAMS slot.
if content_len > 0 {
self.stream_table.touch_activity(data.stream_id, self.now);
}
if is_unlinked {
// Backend is gone but client is still sending DATA.
// Discard the data (flow control updates were already
// queued above) to prevent the buffer from filling up.
kawa.storage.clear();
if data.end_stream {
kawa.parsing_phase = kawa::ParsingPhase::Terminated;
self.mark_end_of_stream(stream);
}
} else {
// A response to HEAD has no content (RFC 9110 §9.3.2): its
// payload is never queued, as kawa's H1 parser leaves the bytes
// an H1 backend sends after such a head unread, and its windows
// were credited above.
let discarded = content == BackendResponseContent::Discarded && content_len > 0;
if discarded {
// The frame's wire payload is the tail of the buffer: drop
// it there. Kept behind `storage.head`, it would hold space
// only the frontend consuming a block frees, and no block
// refers to it, so a payload larger than the buffer would
// stop the backend connection from being read before its
// END_STREAM.
debug_assert_eq!(
kawa.storage.end,
kawa.storage.head + wire_len,
"a DATA payload being read is the tail of the stream buffer"
);
kawa.storage.end = kawa.storage.head;
} else {
// Advance storage.head by the full wire payload length so the
// next frame doesn't read stale pad-length+padding bytes.
slice.start = slice.start.saturating_add(kawa.storage.head as u32);
kawa.storage.head += wire_len;
}
// Emit chunk framing for chunked transfer encoding (H2→H1 path).
// H2 converter ignores ChunkHeader and end_chunk Flags, so this is safe for H2→H2.
if kawa.body_size == kawa::BodySize::Chunked && content_len > 0 && !discarded {
let hex_len = {
let mut buf = Vec::with_capacity(16);
let _ = write!(buf, "{content_len:x}");
buf
};
kawa.push_block(kawa::Block::ChunkHeader(kawa::ChunkHeader {
length: kawa::Store::from_vec(hex_len),
}));
}
if discarded {
debug!(
"{} DATA payload of {} bytes on a response to HEAD discarded",
log_context!(self),
content_len
);
} else {
kawa.push_block(kawa::Block::Chunk(kawa::Chunk {
data: kawa::Store::Slice(slice),
}));
}
if kawa.body_size == kawa::BodySize::Chunked && content_len > 0 && !discarded {
kawa.push_block(kawa::Block::Flags(kawa::Flags {
end_body: false,
end_chunk: true,
end_header: false,
end_stream: false,
}));
}
if data.end_stream {
// RFC 9113 §8.1.1: on end_stream, total DATA must equal Content-Length.
// RFC 9110 §8.6: skip for HEAD/1xx/204/304 responses.
if !cl_exempt
&& let Some(expected) = declared_length
&& data_received != expected
{
error!(
"{} Content-Length mismatch: received {} != declared {}",
log_context!(self),
data_received,
expected
);
let result = self.reset_stream(
data.stream_id,
global_stream_id,
context,
endpoint,
H2Error::ProtocolError,
);
self.remove_dead_stream(data.stream_id, global_stream_id);
return result;
}
let is_chunked = kawa.body_size == kawa::BodySize::Chunked;
kawa.push_block(kawa::Block::Flags(kawa::Flags {
end_body: true,
end_chunk: is_chunked,
end_header: false,
end_stream: true,
}));
kawa.parsing_phase = kawa::ParsingPhase::Terminated;
self.mark_end_of_stream(stream);
}
if let StreamState::Linked(token) = stream_state {
// Mirror of `ConnectionH1::readable`'s close-delimited EOF branch
// for the H2-backend → H2-frontend path: edge-triggered epoll will
// NOT re-fire for bytes we just pushed into stream.back; the
// synthetic event is the only wake path. LIFECYCLE invariant 15.
endpoint.readiness_mut(token).arm_writable();
self.metric_events
.push(MetricEvent::WritableRearmedPeerData);
}
}
MuxResult::Continue
}
fn handle_headers_frame<E, L>(
&mut self,
headers: Headers,
context: &mut Context<L>,
mut endpoint: E,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
// HEADERS frames represent real application activity (new request
// or response). Reset the timeout since the peer is actively
// communicating, unlike control frames (PING, WINDOW_UPDATE).
self.arm_timeout();
if !headers.end_headers {
// CVE-2024-27316: only initialize tracking on the very first HEADERS
// fragment, not on re-entries from ContinuationFrame (which call
// handle_frame(Frame::Headers) with the accumulated header block).
self.flood_detector
.begin_header_block_if_new(headers.header_block_fragment.len);
if !self.header_reassembly.is_in_progress() {
// First HEADERS frame of this block: copy its fragment out of
// `zero.storage` now, before the caller's `expect_header()`
// starts overwriting it with the next frame's bytes. A
// re-entry from `(H2State::ContinuationFrame(headers), _)`
// already appended THIS frame's own payload to the
// accumulator before calling back in here (see that match
// arm in `handle_read()`), so there is nothing left to copy.
//
// `data_opt` (bounds-checked), not `data` (panics on OOB):
// `header_block_fragment` is network-facing-derived — the
// parser computes it correctly by construction, so this
// should never actually be out of bounds, but "no panic on
// network-facing input" (CLAUDE.md) applies regardless, and
// the CVE-2024-27316 abort path below this one used to make
// exactly this check before this step replaced its own
// buffer access with `HeaderBlockAccumulator::finish()`
// (owned bytes, no slicing left to bound-check there).
let Some(fragment) = headers
.header_block_fragment
.data_opt(self.zero.storage.buffer())
else {
error!(
"{} header_block_fragment out of bounds of zero.storage",
log_context!(self)
);
return self.goaway(H2Error::InternalError);
};
self.header_reassembly.begin(fragment);
self.zero.storage.clear();
}
debug!(
"{} FRAGMENT: stream_id={}, accumulated_len={}",
log_context!(self),
headers.stream_id,
self.header_reassembly.len()
);
self.state = H2State::ContinuationHeader(headers);
return MuxResult::Continue;
}
// Header block is complete — reset CONTINUATION counters
self.flood_detector.reset_continuation();
// can this fail?
let stream_id = headers.stream_id;
let Some(global_stream_id) = self.stream_table.get(stream_id) else {
error!(
"{} Handling Headers frame with no attached stream {:#?}",
log_context!(self),
self
);
self.metric_events.push(MetricEvent::HeadersWithoutStream);
self.attribute_bytes_to_overhead();
return self.force_disconnect();
};
// Refresh per-stream idle timer on HEADERS (response headers or trailers
// on an existing stream). Initial HEADERS that create the stream already
// set the timestamp in create_stream().
self.stream_table.touch_activity(stream_id, self.now);
if let Some(priority) = &headers.priority
&& self.scheduler.push_priority(stream_id, priority.clone())
{
// This HEADERS frame's own block just completed (single-frame,
// or the final CONTINUATION of a multi-frame sequence) — but
// we're aborting before ever reaching the `finish()` call below.
// If a CONTINUATION sequence fed it, `header_reassembly` is
// still `is_in_progress() == true`; retire it here or it leaks
// that flag into the NEXT HEADERS frame processed on this
// connection, which may belong to a completely different
// stream — that frame's own "read from zero.storage" fast path
// would then be skipped in favour of these stale, already-
// discarded bytes (sozu-proxy/sozu review of e1c3c2fb, B1).
// Regression:
// `a_priority_self_dependency_reset_does_not_leak_the_reassembly_accumulator`.
if self.header_reassembly.is_in_progress() {
self.header_reassembly.finish();
}
self.reset_stream(
stream_id,
global_stream_id,
context,
endpoint,
H2Error::ProtocolError,
);
self.remove_dead_stream(stream_id, global_stream_id);
return MuxResult::Continue;
}
let stream = &mut context.streams[global_stream_id];
self.attribute_bytes_to_stream(&mut stream.metrics);
// The field-block bytes live in one of two places depending on how
// we got here: the owned reassembly accumulator when a CONTINUATION
// sequence fed this HEADERS (`header_reassembly.is_in_progress()`),
// or straight out of `zero.storage` for the single-frame fast path
// — the common case, which never touches the accumulator at all.
// See `h2_header_reassembly.rs`.
let buffer: &[u8] = if self.header_reassembly.is_in_progress() {
self.header_reassembly.data()
} else {
// Invariant: only reachable when nothing is accumulating for
// THIS stream's own block. Every early-return path above this
// point that could have left a CONTINUATION-fed reassembly
// `is_in_progress()` now retires it first (see the priority
// self-dependency branch above — B1 in the review of e1c3c2fb
// was exactly a return that skipped this and leaked the flag
// into the next HEADERS frame's decode).
debug_assert!(!self.header_reassembly.is_in_progress());
headers
.header_block_fragment
.data(self.zero.storage.buffer())
};
let stream = &mut context.streams[global_stream_id];
let parts = &mut stream.split(&self.position);
let was_initial = parts.rbuffer.is_initial();
let elide_x_real_ip = parts.context.elide_x_real_ip;
let (header_events, status) = pkawa::handle_header(
self.hpack.decoder_mut(),
self.scheduler.prioriser_mut(),
stream_id,
parts.rbuffer,
buffer,
headers.end_stream,
parts.context,
self.flood_detector.config().max_header_list_size(),
self.flood_detector.config().max_header_fields(),
elide_x_real_ip,
);
self.metric_events.extend(header_events);
if self.header_reassembly.is_in_progress() {
self.header_reassembly.finish();
}
self.zero.storage.clear();
if let Err((error, global)) = status {
match self.position {
Position::Client(..) => self
.metric_events
.push(MetricEvent::BackendHeaderParseError),
Position::Server => self
.metric_events
.push(MetricEvent::FrontendHeaderParseError),
}
if global {
error!(
"{} GOT GLOBAL ERROR WHILE PROCESSING HEADERS",
log_context!(self)
);
return self.goaway(error);
} else {
let result =
self.reset_stream(stream_id, global_stream_id, context, endpoint, error);
self.remove_dead_stream(stream_id, global_stream_id);
return result;
}
}
if headers.end_stream {
// RFC 9113 §8.1.1: when END_STREAM arrives via trailers,
// validate that total DATA received matches Content-Length.
// RFC 9110 §8.6: skip for HEAD/1xx/204/304 responses.
if !was_initial && !self.content_length_exempt(&stream.context) {
let parts = stream.split(&self.position);
if let kawa::BodySize::Length(expected) = parts.rbuffer.body_size
&& *parts.data_received != expected
{
error!(
"{} Content-Length mismatch on trailers: received {} != declared {}",
log_context!(self),
*parts.data_received,
expected
);
let result = self.reset_stream(
stream_id,
global_stream_id,
context,
endpoint,
H2Error::ProtocolError,
);
self.remove_dead_stream(stream_id, global_stream_id);
return result;
}
}
self.mark_end_of_stream(stream);
}
if let StreamState::Linked(token) = stream.state {
// Mirror of handle_data_frame's rearm. LIFECYCLE invariant 15.
endpoint.readiness_mut(token).arm_writable();
self.metric_events
.push(MetricEvent::WritableRearmedPeerHeaders);
}
if was_initial && self.position.is_client() {
// The backend's response field block has just been decoded and
// parsed: the client twin of the `is_server()` branch below, on
// the same first-HEADERS edge (`was_initial` is what keeps a
// trailer block out of it). This is the whole of
// `backend_header_time`'s H2 arming — nginx's
// `$upstream_header_time` (sozu-proxy/sozu#426).
//
// Unconditional, including on a 1xx: `ConnectionH2::handle_1xx_reset`
// clears the back buffer once the informational response has been
// forwarded, so the FINAL response returns to the initial parsing
// phase and re-enters this edge, overwriting. That is the response
// `SessionMetrics::backend_stop` also anchors on.
stream.metrics.backend_headers_received();
}
// was_initial prevents trailers from triggering connection
if was_initial && self.position.is_server() {
self.metric_events.push(MetricEvent::RequestStarted);
stream.metrics.service_start();
stream.request_counted = true;
stream.state = StreamState::Link;
context.pending_links.push_back(global_stream_id);
}
MuxResult::Continue
}
fn handle_push_promise_frame(&mut self) -> MuxResult {
self.attribute_bytes_to_overhead();
match self.position {
Position::Client(..) => {
// RFC 9113 §8.4: Server push is deprecated. Sozu never sends
// SETTINGS_ENABLE_PUSH=1, so receiving PUSH_PROMISE is a protocol error.
error!(
"{} Received PUSH_PROMISE but server push is not supported",
log_context!(self)
);
self.goaway(H2Error::ProtocolError)
}
Position::Server => {
// Clients must never send PUSH_PROMISE (RFC 9113 §8.4)
error!("{} Received PUSH_PROMISE from client", log_context!(self));
self.goaway(H2Error::ProtocolError)
}
}
}
fn handle_priority_frame<E, L>(
&mut self,
priority: parser::Priority,
context: &mut Context<L>,
endpoint: E,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
if let Some(global_stream_id) = self
.stream_table
.streams()
.get(&priority.stream_id)
.copied()
{
let stream = &mut context.streams[global_stream_id];
self.attribute_bytes_to_stream(&mut stream.metrics);
} else {
self.attribute_bytes_to_overhead();
}
// Pass 3 Medium #4: standalone PRIORITY frames can arrive for any
// peer-chosen stream ID. Accept only currently-open streams and a
// small idle look-ahead window; everything else is dropped before
// it can feed memory into the priority map.
if self.scheduler.push_priority_guarded(
priority.stream_id,
priority.inner,
self.last_stream_id,
self.stream_table.streams(),
) {
if let Some(global_stream_id) = self
.stream_table
.streams()
.get(&priority.stream_id)
.copied()
{
let result = self.reset_stream(
priority.stream_id,
global_stream_id,
context,
endpoint,
H2Error::ProtocolError,
);
self.remove_dead_stream(priority.stream_id, global_stream_id);
return result;
} else {
// RFC 7540 §5.3.1 makes a self-dependency a stream error, but
// this stream is idle and RFC 9113 §6.4 forbids RST_STREAM on
// an idle stream: the connection error is the only reply.
error!(
"{} INVALID PRIORITY RECEIVED ON INVALID STREAM",
log_context!(self)
);
return self.goaway(H2Error::ProtocolError);
}
}
MuxResult::Continue
}
/// RFC 9113 §6.3: a PRIORITY frame with a length other than 5 octets is a
/// stream error of type FRAME_SIZE_ERROR. An open stream is reset; on an
/// idle, closed or already reset stream the frame is dropped, since
/// RST_STREAM must not name an idle stream (§6.4) and PRIORITY changes no
/// stream state. The frame's payload was already consumed.
fn handle_priority_frame_size_error<E, L>(
&mut self,
stream_id: StreamId,
context: &mut Context<L>,
endpoint: E,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
self.metric_events
.push(MetricEvent::FrameReceived(FrameType::Priority));
self.attribute_bytes_to_overhead();
if let Some(global_stream_id) = self.stream_table.get(stream_id)
&& context.streams[global_stream_id].state.is_open()
&& !self.stream_table.rst_sent_contains(stream_id)
{
debug!(
"{} PRIORITY frame of invalid length on stream {}, sending RST_STREAM(FRAME_SIZE_ERROR)",
log_context!(self),
stream_id
);
let result = self.reset_stream(
stream_id,
global_stream_id,
context,
endpoint,
H2Error::FrameSizeError,
);
self.remove_dead_stream(stream_id, global_stream_id);
return result;
}
debug!(
"{} Ignoring PRIORITY frame of invalid length on stream {}",
log_context!(self),
stream_id
);
self.flood_detector.record_glitch();
check_flood_or_return!(self);
MuxResult::Continue
}
/// RFC 9218 §7.1: PRIORITY_UPDATE reprioritizes an open or idle-soon
/// stream at the connection level. Decodes the priority field value
/// (same grammar as the `priority` request header, `parse_rfc9218_priority`)
/// and pushes it into the `Prioriser` through the same guarded path used
/// for standalone PRIORITY frames — the guard bounds memory against a
/// client spamming PRIORITY_UPDATE for far-future stream IDs.
///
/// Prioritized stream ID `0` is a connection-level `PROTOCOL_ERROR`
/// (RFC 9218 §7.1). For any other ID that is not currently open or
/// within the idle look-ahead budget, the update is silently dropped
/// (matches the PRIORITY-frame guard semantics — no state change).
fn handle_priority_update_frame(&mut self, pu: parser::PriorityUpdate) -> MuxResult {
self.attribute_bytes_to_overhead();
if pu.prioritized_stream_id == 0 {
error!(
"{} PRIORITY_UPDATE with prioritized_stream_id=0 (RFC 9218 §7.1)",
log_context!(self)
);
return self.goaway(H2Error::ProtocolError);
}
let (urgency, incremental) = pkawa::parse_rfc9218_priority(&pu.priority_field_value);
let (prev_urgency, _) = self.scheduler.priority(&pu.prioritized_stream_id);
trace!(
"{} PRIORITY_UPDATE stream={} urgency={}->{} incremental={} rearmed_writable=true",
log_context!(self),
pu.prioritized_stream_id,
prev_urgency,
urgency,
incremental
);
let _ = self.scheduler.push_priority_guarded(
pu.prioritized_stream_id,
parser::PriorityPart::Rfc9218 {
urgency,
incremental,
},
self.last_stream_id,
self.stream_table.streams(),
);
// LIFECYCLE invariant 15: reprioritisation only changes ordering for
// the NEXT write pass. Under ET epoll, if finalize_write already
// stripped WRITABLE, the scheduler won't re-run without a synthetic
// wake — pair the interest insert with signal_pending_write.
self.readiness.arm_writable();
self.metric_events
.push(MetricEvent::WritableRearmedPriorityUpdate);
MuxResult::Continue
}
fn handle_rst_stream_frame<E, L>(
&mut self,
rst_stream: parser::RstStream,
context: &mut Context<L>,
mut endpoint: E,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
// Per-error-code counter for the inbound RST. Emitted before the
// flood-detector trip check so even a connection that gets terminated
// by `handle_flood_violation` shows up in the per-code breakdown
// (the dedicated `h2.flood.violation.rst_stream_*` series tracks the
// mitigation event itself).
self.metric_events
.push(MetricEvent::RstStreamReceived(rst_stream.error_code));
// CVE-2023-44487 Rapid Reset + CVE-2019-9514: track RST_STREAM rate.
self.flood_detector.record_rst_stream_window();
check_flood_or_return!(self);
// Additional CVE-2023-44487 mitigation: connection-lifetime caps on
// RST_STREAM frames received. The per-window counter above
// half-decays, so a patient client could keep resetting forever; the
// lifetime counters cap that, relative to the streams a backend
// answered so benign cancels never accumulate toward a fixed ceiling.
// Streams whose backend response has not yet started count toward a
// much lower "abusive" cap (more than half of the streams, past a
// floor) — the Rapid Reset signature, where the attacker pays one RST
// frame and we pay a backend round-trip for each.
//
// "Response started" here means the Server has begun producing
// response bytes (backend kawa buffer past its initial phase). On a
// backend connection (`Position::Client`) the Rapid Reset signature
// does not exist: Sōzu opened the stream, and a backend resetting it
// before answering — `REFUSED_STREAM` under load, a `CANCEL` from its
// own timeout — costs the backend, not Sōzu. Those resets never count
// as pre-response.
//
// On a frontend connection, a stream reset after its response started
// is an answered stream: it counts toward the caps' denominator
// (`routed_to_a_backend`) before the caps are checked, so a client
// that cancels every stream after its response never has one more
// reset than answered streams.
// A stream refused under flood pressure never got a response:
// resetting it is a pre-response reset, whatever the refusal did to
// the stream table. It also shows the peer does not honour
// REFUSED_STREAM, so the soft refusal ends for this connection: more
// refusals would only multiply the RST_STREAM frames Sōzu sends ahead
// of the cap's GOAWAY.
let refused_stream_reset = self.position.is_server()
&& self.stream_table.get(rst_stream.stream_id).is_none()
&& self
.flood_refused_streams
.is_some_and(|(first, last)| (first..=last).contains(&rst_stream.stream_id));
if refused_stream_reset {
self.flood_refusal_ignored = true;
}
let (response_started, answered) =
match self.stream_table.streams().get(&rst_stream.stream_id) {
Some(global_stream_id) => {
let stream = &context.streams[*global_stream_id];
let started = !stream.back.is_initial();
(
self.position.is_client() || started,
self.position.is_server() && started && routed_to_a_backend(stream),
)
}
None if refused_stream_reset => (false, false),
// Stream already gone (e.g. closed, not yet registered) —
// treat as response-started to avoid over-counting benign
// races as abusive.
None => (true, false),
};
if let Some(violation) =
self.flood_detector
.record_rst_received(response_started, answered, self.now)
{
return self.handle_flood_violation(violation);
}
// Rapid Reset signature (CVE-2023-44487): a RST that arrives before the
// backend has begun answering. Emitted alongside the per-code counter
// so the SOC can alert on the rate of pre-response RSTs without
// having to differentiate by error code.
if !response_started {
self.metric_events
.push(MetricEvent::RstStreamReceivedBeforeResponse);
}
debug!(
"{} RstStream({} -> {})",
log_context!(self),
rst_stream.error_code,
H2Error::try_from(rst_stream.error_code).map_or("UNKNOWN_ERROR", |e| e.as_str())
);
// Compute totals before removing the stream from the map,
// so the removed stream's bytes are included in the total.
let rst_byte_totals = self.compute_stream_byte_totals(context);
if let Some(global_stream_id) = self
.stream_table
.streams()
.get(&rst_stream.stream_id)
.copied()
{
let stream = &mut context.streams[global_stream_id];
self.attribute_bytes_to_stream(&mut stream.metrics);
let linked_token = stream.linked_token();
let (client_rtt, server_rtt) = self.snapshot_rtts(&endpoint, linked_token);
if let Some(token) = linked_token {
endpoint.end_stream(token, global_stream_id, context);
}
let stream = &mut context.streams[global_stream_id];
match &self.position {
// Inbound RST_STREAM on the backend side terminates the in-flight
// request without going through Connection::end_stream (the normal
// place where Backend.active_requests is decremented), so do the
// bookkeeping explicitly here to avoid leaking load counters.
Position::Client(_, backend, BackendStatus::Connected) => {
context.record_backend_delta(backend, BackendChange::StreamsEnded(1));
}
Position::Client(..) => {}
Position::Server => {
self.distribute_overhead(&mut stream.metrics, rst_byte_totals);
// This is a special case, normally, all stream are terminated by the server
// when the last byte of the response is written. Here, the reset is requested
// on the server endpoint and immediately terminates, shortcutting the other path
stream.metrics.backend_stop();
let events = stream.generate_access_log(
true,
Some("H2::ResetFrame"),
context.listener.clone(),
client_rtt,
server_rtt,
);
self.metric_events.extend(events.into_iter().flatten());
stream.state = StreamState::Recycle;
}
}
// Retire from streams/prioriser/stream_last_activity_at and
// invalidate expect_write/expect_read if they reference this gid.
self.remove_dead_stream(rst_stream.stream_id, global_stream_id);
} else {
self.attribute_bytes_to_overhead();
}
MuxResult::Continue
}
fn handle_settings_frame<L>(
&mut self,
settings: parser::Settings,
context: &mut Context<L>,
) -> MuxResult
where
L: ListenerHandler + L7ListenerHandler,
{
if settings.ack {
// RFC 9113 §6.5: SETTINGS ACK must have empty payload
if !settings.settings.is_empty() {
error!("{} SETTINGS ACK with non-empty payload", log_context!(self));
return self.goaway(H2Error::FrameSizeError);
}
// RFC 9113 §6.5: peer acknowledged our SETTINGS — clear timeout
self.settings_sent_at = None;
// RFC 7541 §4.2: sync the decoder's max allowed table size with
// what we advertised. Currently a no-op (settings don't change at
// runtime), but guards against future runtime SETTINGS updates.
self.hpack.set_decoder_max_allowed_table_size(
self.local_settings.settings_header_table_size as usize,
);
self.attribute_bytes_to_overhead();
return MuxResult::Continue;
}
// CVE-2019-9515: track SETTINGS frame rate
self.flood_detector.record_settings_frame();
check_flood_or_return!(self);
for setting in settings.settings {
let v = setting.value;
let mut is_error = false;
#[rustfmt::skip]
match setting.identifier {
parser::SETTINGS_HEADER_TABLE_SIZE => {
// Cap to the configured maximum — a malicious peer can
// advertise up to 4 GB to inflate HPACK encoder memory.
let cap = self.flood_detector.config().max_header_table_size();
let capped = v.min(cap);
self.peer_settings.settings_header_table_size = capped;
self.hpack.set_encoder_max_table_size(capped as usize);
// RFC 7541 §4.2 / §6.3: queue a dynamic-table-size-update
// HPACK directive for the next header block we emit.
// Without it, the peer's decoder keeps its previous (possibly
// larger) table cap and our encoder-side change is silent
// — conformance suites (h2spec `hpack/4.2`) will flag it.
self.pending_table_size_update = Some(capped);
},
parser::SETTINGS_ENABLE_PUSH => { self.peer_settings.settings_enable_push = v == 1; is_error |= v > 1 },
parser::SETTINGS_MAX_CONCURRENT_STREAMS => { self.peer_settings.settings_max_concurrent_streams = v },
parser::SETTINGS_INITIAL_WINDOW_SIZE => {
// RFC 9113 §6.5.2 / §6.9.2: a value above 2^31-1, or one
// that pushes a stream window past it, is a connection
// error of type FLOW_CONTROL_ERROR, not PROTOCOL_ERROR.
if self.update_initial_window_size(v, context) {
error!("{} INVALID SETTINGS_INITIAL_WINDOW_SIZE {}", log_context!(self), v);
return self.goaway(H2Error::FlowControlError);
}
},
parser::SETTINGS_MAX_FRAME_SIZE => { self.peer_settings.settings_max_frame_size = v; is_error |= !(MIN_MAX_FRAME_SIZE..MAX_MAX_FRAME_SIZE).contains(&v) },
parser::SETTINGS_MAX_HEADER_LIST_SIZE => { self.peer_settings.settings_max_header_list_size = v },
parser::SETTINGS_ENABLE_CONNECT_PROTOCOL => { self.peer_settings.settings_enable_connect_protocol = v == 1; is_error |= v > 1 },
parser::SETTINGS_NO_RFC7540_PRIORITIES => { self.peer_settings.settings_no_rfc7540_priorities = v == 1; is_error |= v > 1 },
other => { warn!("Unknown setting_id: {}, we MUST ignore this", other); self.flood_detector.record_glitch() },
};
if is_error {
error!("{} INVALID SETTING", log_context!(self));
return self.goaway(H2Error::ProtocolError);
}
}
self.attribute_bytes_to_overhead();
// Enlarge the connection-level receive window for backend H2
// connections (Position::Client). The server side serialises it
// beside its own SETTINGS in the `(ClientSettings, Server)` readable
// arm, but the client needs to do it here after receiving the
// server's initial SETTINGS. Once only: a later SETTINGS frame must
// not grant the surplus again (RFC 9113 §6.9.1).
if self.position.is_client() && !self.connection_window_enlarged {
self.connection_window_enlarged = true;
let increment = self
.connection_config
.initial_connection_window
.saturating_sub(DEFAULT_INITIAL_WINDOW_SIZE);
if increment > 0 {
self.queue_window_update(0, increment);
}
// Do NOT increment flow_control.window here: sending our own
// WINDOW_UPDATE enlarges the peer's send allowance, not ours.
// Our send window is only updated by WINDOW_UPDATEs we receive
// from the peer (RFC 9113 §6.9).
}
self.output.push(&serializer::SETTINGS_ACKNOWLEDGEMENT);
self.queue_output();
MuxResult::Continue
}
/// Hand the frame just queued in [`Self::output`] to the write side.
///
/// Reading goes on: `zero` is input only, so the next frame header no
/// longer lands on queued output. The frame goes out after everything
/// queued before it, including the rest of a stream frame a partial
/// write cut (#1604), as HAProxy appends it to its single output ring.
fn queue_output(&mut self) {
self.readiness.interest.insert(Ready::WRITABLE);
self.readiness.signal_pending_write();
}
/// How much queued output stops reading, as HAProxy's demux stops on a
/// full `mbuf` (`H2_CF_DEM_MROOM`): one buffer's worth. Every queued
/// control frame answers a frame the peer sent, so a peer that sends
/// while never reading cannot grow [`Self::output`] past this.
fn output_read_cap(&self) -> usize {
self.zero.storage.capacity()
}
/// Drop everything queued in [`Self::output`], and restore reading if the
/// read cap had withdrawn it: the only thing reads waited for is gone.
/// Every site that empties the queue goes through here or through
/// [`Self::consume_output`], so `reads_wait_for_output` never outlives
/// the output it waited on.
fn clear_output(&mut self) {
self.output.clear();
if self.reads_wait_for_output {
self.reads_wait_for_output = false;
self.readiness.interest.insert(Ready::READABLE);
}
}
/// Account for `size` bytes the socket took from [`Self::output`], and
/// restore reading once it drains below [`Self::output_read_cap`].
fn consume_output(&mut self, size: usize) {
let overhead = self.output.consume(size);
self.metric_events
.push(self.position.bytes_out_event(overhead));
self.bytes.overhead_bout += overhead;
if self.reads_wait_for_output && self.output.len() < self.output_read_cap() {
self.reads_wait_for_output = false;
self.readiness.interest.insert(Ready::READABLE);
}
}
fn handle_ping_frame(&mut self, ping: parser::Ping) -> MuxResult {
if ping.ack {
self.attribute_bytes_to_overhead();
return MuxResult::Continue;
}
// CVE-2019-9512: track non-ACK PING frame rate
self.flood_detector.record_ping_frame();
check_flood_or_return!(self);
self.attribute_bytes_to_overhead();
let ping_response_size = serializer::PING_ACKNOWLEDGEMENT_HEADER.len() + 8;
match self.output.push_frames(ping_response_size, |buf| {
serializer::gen_ping_acknowledgement(buf, &ping.payload).map(|(_, size)| size)
}) {
Ok(_) => {
self.metric_events.push(MetricEvent::PingAckFrameSent);
}
Err(error) => {
error!(
"{} Could not serialize PingFrame: {:?}",
log_context!(self),
error
);
return self.force_disconnect();
}
};
self.queue_output();
MuxResult::Continue
}
fn handle_goaway_frame<E, L>(
&mut self,
goaway: parser::GoAway,
context: &mut Context<L>,
mut endpoint: E,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
self.attribute_bytes_to_overhead();
// A peer sends at most two GOAWAY frames in a graceful close (RFC
// 9113 §6.8). Each one counts toward the glitch budget, so a peer
// repeating GOAWAY on a connection kept open by its in-flight
// streams is bounded, as is the log line below.
self.flood_detector.record_glitch();
check_flood_or_return!(self);
let error_name =
H2Error::try_from(goaway.error_code).map_or("UNKNOWN_ERROR", |e| e.as_str());
if goaway.error_code == H2Error::NoError as u32 {
debug!(
"{} Received GOAWAY: last_stream_id={}, error={}, debug_data={:?}",
log_context!(self),
goaway.last_stream_id,
error_name,
goaway.additional_debug_data
);
} else {
// Peer-originated failure: no variant of H2Error from a peer
// implies a sozu bug. On a backend connection, impact handling
// is separate (retry above `last_stream_id`, RST_STREAM for
// consumed streams) and logs its own details below; a frontend
// connection retires nothing. Either way the summary drops to
// `warn!`.
warn!(
"{} Received GOAWAY: last_stream_id={}, error={}, debug_data={:?}",
log_context!(self),
goaway.last_stream_id,
error_name,
goaway.additional_debug_data
);
}
self.metric_events
.push(MetricEvent::GoAwayReceived(goaway.error_code));
// RFC 9113 §6.8: begin graceful drain.
self.drain.observe_peer_goaway(goaway.last_stream_id);
let peer_last_stream_id = self
.drain
.peer_last_stream_id()
.expect("observe_peer_goaway just recorded this");
// RFC 9113 §6.8: `last_stream_id` bounds the streams the GOAWAY
// RECEIVER initiated. On a frontend connection (Position::Server)
// that is server-initiated streams, which sozu never opens (no
// push), so every stream here was opened by the GOAWAY sender and
// is unaffected: each runs to completion, `draining` refuses any new
// one (`create_stream`), and the connection closes once the last
// completes (the `draining && streams().is_empty()` finalize step).
//
// On a backend connection (Position::Client) the streams are
// sozu-initiated: those with ID > last_stream_id were NOT processed
// by the peer and are retried (StreamState::Link) on a new
// connection; those at or below it run to completion.
// IMPORTANT: do NOT call endpoint.end_stream() here — that would
// remove the stream from the frontend's H2 stream map and send
// RST_STREAM to the client, killing the request instead of retrying it.
let mut retry_streams = Vec::new();
if matches!(self.position, Position::Client(..)) {
for (&stream_id, &global_stream_id) in self.stream_table.streams() {
if stream_id > peer_last_stream_id {
retry_streams.push((stream_id, global_stream_id));
}
}
}
for (stream_id, global_stream_id) in &retry_streams {
// `endpoint` is the frontend side (`EndpointServer`) only on a
// backend connection: its `readiness_mut` is what the answers
// below must arm so they reach the client.
debug_assert!(
matches!(self.position, Position::Client(..)),
"only a backend connection retires streams on a received GOAWAY"
);
// Remove from reverse index before transitioning away from Linked.
if let StreamState::Linked(token) = context.streams[*global_stream_id].state {
remove_backend_stream(&mut context.backend_streams, token, *global_stream_id);
}
let stream = &mut context.streams[*global_stream_id];
// A stream above `last_stream_id` was not processed (RFC 9113
// §6.8), but it is only replayable if its request can be sent
// again. Once this connection encoded it, `front.out` holds
// frames bound to this connection's stream id and HPACK encoder
// and its header blocks are gone: re-linked, those frames would
// reach another backend connection of this session, possibly one
// this frontend connection's other streams share (sozu#1632).
if stream.front.consumed {
// Request was already sent to this backend — we can't
// replay it. Use the linked token's readiness (via
// endpoint) so the RST_STREAM reaches the client.
debug!(
"{} GOAWAY: stream {} already consumed, cannot retry",
log_context!(self),
stream_id
);
if let StreamState::Linked(token) = stream.state {
let front_readiness = endpoint.readiness_mut(token);
forcefully_terminate_answer(stream, front_readiness, H2Error::RefusedStream);
} else {
warn!(
"{} GOAWAY: stream {} consumed but not Linked, cannot notify frontend",
log_context!(self),
stream_id
);
}
} else if stream.front_bound_to_backend {
// Encoded for this backend, not sent, not processed: answered
// with a whole 503 response rather than an error-terminated
// one. `forcefully_terminate_answer` leaves an empty response
// kawa in `Error`, which an H2 frontend turns into a
// RST_STREAM but an H1 frontend cannot write at all, so its
// client would wait for its timeout; this core cannot tell
// which frontend it serves. 503, not 502: the backend refused
// the request explicitly and did not process it (RFC 9110
// §15.6.4), which is what a draining GOAWAY means.
debug!(
"{} GOAWAY: stream {} encoded for this backend and unsent, answering 503",
log_context!(self),
stream_id
);
if let StreamState::Linked(token) = stream.state {
let answers_rc = stream.answers.clone();
let answers = answers_rc.borrow();
let front_readiness = endpoint.readiness_mut(token);
set_default_answer(stream, front_readiness, 503, &answers);
debug_assert!(
stream.state == StreamState::Unlinked && !stream.back.is_error(),
"a refused, unsent request gets a whole answer, never a silent error"
);
} else {
warn!(
"{} GOAWAY: stream {} encoded but not Linked, cannot notify frontend",
log_context!(self),
stream_id
);
}
} else {
// Pre: a stream re-linked here carries no frame of this
// connection (sozu-proxy/sozu#1632).
debug_assert!(
stream.front.out.is_empty(),
"a stream re-linked after GOAWAY holds no frame encoded for this connection"
);
stream.state = StreamState::Link;
context.pending_links.push_back(*global_stream_id);
}
// Both retry (!consumed) and terminated (consumed) paths remove the
// stream from self.streams without going through Connection::end_stream,
// so decrement Backend.active_requests here to keep load metrics honest.
if let Position::Client(_, backend, BackendStatus::Connected) = &self.position {
context.record_backend_delta(backend, BackendChange::StreamsEnded(1));
}
// Retire from streams/prioriser/stream_last_activity_at and
// invalidate expect_write/expect_read if they reference this gid.
self.remove_dead_stream(*stream_id, *global_stream_id);
}
// If no active streams remain, close immediately
if self.stream_table.streams().is_empty() {
return self.goaway(H2Error::NoError);
}
// Otherwise, let the remaining streams complete: every stream on a
// frontend connection, those at or below `last_stream_id` on a
// backend one. The connection is closed when all streams finish.
MuxResult::Continue
}
fn handle_window_update_frame<E, L>(
&mut self,
wu: WindowUpdate,
context: &mut Context<L>,
endpoint: E,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
let stream_id = wu.stream_id;
let increment = wu.increment;
// RFC 9113 §6.9: increment of 0 MUST be treated as an error.
// Connection-level (stream 0) -> connection error (GOAWAY).
// Stream-level -> stream error (RST_STREAM).
if increment == 0 {
if stream_id == 0 {
error!(
"{} WINDOW_UPDATE with zero increment on connection (stream 0)",
log_context!(self)
);
return self.goaway(H2Error::ProtocolError);
} else {
error!(
"{} WINDOW_UPDATE with zero increment on stream {}",
log_context!(self),
stream_id
);
if let Some(global_stream_id) = self.stream_table.get(stream_id) {
let result = self.reset_stream(
stream_id,
global_stream_id,
context,
endpoint,
H2Error::ProtocolError,
);
self.remove_dead_stream(stream_id, global_stream_id);
return result;
}
// Stream not in map (already closed) — treat as glitch
self.flood_detector.record_glitch();
check_flood_or_return!(self);
self.attribute_bytes_to_overhead();
return MuxResult::Continue;
}
}
// The parser masks the reserved bit (STREAM_ID_MASK), so increment <=
// 2^31-1 and try_from always succeeds. Use try_from rather than `as` to
// guard against a future parser change that drops the mask.
let increment = i32::try_from(increment).unwrap_or(i32::MAX);
// RFC 9113 §6.9: a non-zero WINDOW_UPDATE increment is in [1, 2^31-1].
// Zero was short-circuited above; this asserts the masked value is a
// legal positive increment before we add it to a window.
debug_assert!(
increment > 0,
"WINDOW_UPDATE increment must be strictly positive at this point (zero handled above)"
);
if stream_id == 0 {
// Count connection-level WINDOW_UPDATEs before touching the window
// so a per-window flood stops us before we pay the arithmetic cost
// on a million-frame burst. Zero-increment frames short-circuited
// above, so every increment here is a legal-looking rate consumer.
self.flood_detector.record_window_update_stream0();
check_flood_or_return!(self);
self.attribute_bytes_to_overhead();
// Window arithmetic + its replenish-invariant asserts live on
// `H2FlowControl` now (see its module doc); this arm keeps the
// frame-dispatch orchestration — flood accounting, logging,
// GOAWAY — which needs `self` (flood_detector, readiness,
// log_context!) that the flow-control module deliberately does
// not have.
match self.flow_control.apply_window_update(increment) {
h2_flow_control::ApplyWindowUpdateOutcome::Applied {
new_window,
should_arm_writable,
} => {
if should_arm_writable {
self.readiness.arm_writable();
}
debug!(
"{} WINDOW_UPDATE received: stream=0 increment={} new_connection_window={}",
log_context!(self),
increment,
new_window
);
}
h2_flow_control::ApplyWindowUpdateOutcome::Overflow => {
error!("{} INVALID WINDOW INCREMENT", log_context!(self));
return self.goaway(H2Error::FlowControlError);
}
}
} else if let Some(global_stream_id) = self.stream_table.get(stream_id) {
let stream = &mut context.streams[global_stream_id];
self.attribute_bytes_to_stream(&mut stream.metrics);
// RFC 9113 §6.9: only this connection's leg of the stream.
let stream_window = stream.send_window_mut(&self.position);
let stream_window_before = *stream_window;
if let Some(window) = stream_window.checked_add(increment) {
if *stream_window <= 0 && window > 0 {
self.readiness.arm_writable();
}
*stream_window = window;
// Same replenish invariant as the connection window, applied to
// the per-stream send window (RFC 9113 §6.9.1). Overflow past
// 2^31-1 is rejected by `checked_add` and handled as a
// FLOW_CONTROL_ERROR RST_STREAM below.
debug_assert_eq!(
window,
stream_window_before + increment,
"stream window must increase by exactly the increment"
);
debug_assert!(
window > stream_window_before,
"a positive WINDOW_UPDATE must strictly grow the stream window"
);
debug!(
"{} WINDOW_UPDATE received: stream={} increment={} new_stream_window={}",
log_context!(self),
stream_id,
increment,
window
);
} else {
let result = self.reset_stream(
stream_id,
global_stream_id,
context,
endpoint,
H2Error::FlowControlError,
);
self.remove_dead_stream(stream_id, global_stream_id);
return result;
}
} else {
self.attribute_bytes_to_overhead();
trace!(
"{} Ignoring window update on closed stream {}: {}",
log_context!(self),
stream_id,
increment
);
// Pass 3 Low #5: WINDOW_UPDATE on a closed stream is legal
// (RFC 9113 §6.9.1) but has no useful effect, so a peer that
// keeps sending them is wasting our cycles. Count it as a
// glitch so a flood contributes to `check_flood()` and can
// eventually trigger ENHANCE_YOUR_CALM.
self.flood_detector.record_glitch();
check_flood_or_return!(self);
}
MuxResult::Continue
}
/// Apply a peer's `SETTINGS_INITIAL_WINDOW_SIZE` to every stream window
/// this connection holds (RFC 9113 §6.9.2). `true` when the value is
/// invalid — above 2^31-1, or a change that would push some stream
/// window past it — which the caller answers with
/// GOAWAY(FLOW_CONTROL_ERROR). Every window is checked before any is
/// changed, so a rejected value leaves all of them, and the recorded
/// setting, as they were.
fn update_initial_window_size<L>(&mut self, value: u32, context: &mut Context<L>) -> bool
where
L: ListenerHandler + L7ListenerHandler,
{
if value > FLOW_CONTROL_MAX_WINDOW {
return true;
}
let delta = match i32::try_from(
value as i64 - self.peer_settings.settings_initial_window_size as i64,
) {
Ok(d) => d,
Err(_) => {
error!("{} initial window size delta overflow", log_context!(self));
return true;
}
};
// RFC 9113 §6.9.2: changes to SETTINGS_INITIAL_WINDOW_SIZE can cause
// stream windows to exceed 2^31-1, which is a flow control error.
// Checked for every stream first, so a rejection changes nothing.
// Only streams owned by this connection, and only this connection's
// leg of each (RFC 9113 §6.9): the other connection's window is its
// own.
let overflows = self
.stream_table
.streams()
.values()
.any(|&global_stream_id| {
context.streams[global_stream_id]
.send_window_mut(&self.position)
.checked_add(delta)
.is_none()
});
if overflows {
return true;
}
let mut open_window = false;
for &global_stream_id in self.stream_table.streams().values() {
let stream_window = context.streams[global_stream_id].send_window_mut(&self.position);
let new_window = *stream_window + delta;
open_window |= *stream_window <= 0 && new_window > 0;
*stream_window = new_window;
}
trace!(
"{} UPDATE INIT WINDOW: {} {} {:?}",
log_context!(self),
delta,
open_window,
self.readiness
);
if open_window {
self.readiness.arm_writable();
}
self.peer_settings.settings_initial_window_size = value;
false
}
/// Take the force-disconnect decision as far as it goes without a socket,
/// and park the rest.
///
/// Everything this function can settle by itself, it settles here:
/// `H2State::Error`, and on a `Position::Client` connection the backend
/// status and the `HUP` readiness event. What it cannot settle is the
/// `tls_wants_write` answer `h2_close::force_disconnect_action` decides
/// on, because the socket belongs to the shell — so it raises an
/// [`H2ForceDisconnectTarget`] and answers provisionally.
///
/// The provisional answer is exact everywhere but one case.
/// `Position::Client` always continued. A `Position::Server` whose peer is
/// gone always closes, because `h2_close::force_disconnect_action`
/// answers `CloseSession` on `peer_gone` whatever the socket says. Only a
/// server connection with a live peer genuinely turns on the query, and it
/// answers [`MuxResult::Continue`] until the shell settles it — the
/// conservative direction of the two, since a session held one pass too
/// long is recoverable and a session closed with records still buffered is
/// the truncation `h2_close::force_disconnect_action` exists to prevent.
///
/// Seventeen sites reach this function and every one returns its answer
/// immediately, so on the ordinary paths no core code runs between the
/// raise and the return of the entry point the shell called.
/// [`Self::goaway`] and `Self::send_initial_goaway` are the exception on
/// their GOAWAY-serialisation failure arms, which `Self::enqueue_rst`'s
/// flood escalation can reach from deep in a frame handler; what those
/// paths can observe in the gap is documented on
/// [`Self::force_disconnect_after_query`].
pub fn force_disconnect(&mut self) -> MuxResult {
self.state = H2State::Error;
// Read ONCE above the `match`, for the borrow reason the
// `tls_wants_write` read that used to sit here had: the client arm
// holds `&mut self.position` for its `status` binding, while this
// query borrows all of `self`. Hoisting is behaviour-neutral — it has
// no side effect and the only field it reads that this function writes
// is `self.state`, assigned above — and it is what lets the raised
// target CARRY the answer instead of re-deriving it later. That
// matters: `peer_gone_after_final_goaway` reads `stream_table` and
// `zero.storage`, and `Self::remove_dead_stream` runs between the
// raise and the settlement on the `Self::reset_stream` paths.
let peer_gone = self.peer_gone_after_final_goaway();
match &mut self.position {
Position::Client(_, _, status) => {
*status = BackendStatus::Disconnecting;
self.readiness.event = Ready::HUP;
self.pending_force_disconnect = Some(H2ForceDisconnectTarget::Client);
MuxResult::Continue
}
Position::Server => {
self.pending_force_disconnect = Some(H2ForceDisconnectTarget::Server { peer_gone });
if peer_gone {
MuxResult::CloseSession
} else {
MuxResult::Continue
}
}
}
}
/// Take the [`H2ForceDisconnectTarget`] this connection parked, if it
/// parked one.
///
/// Taking rather than reading is what makes a second settlement
/// impossible: the next call sees `None` and the shell's helper answers
/// with the result it was already holding.
pub fn poll_force_disconnect(&mut self) -> Option<H2ForceDisconnectTarget> {
self.pending_force_disconnect.take()
}
/// Settle a [`Self::force_disconnect`] with the `tls_wants_write` answer
/// the shell read on its behalf.
///
/// `tls_wants_write` arrives as this function's own parameter and the
/// target as the other, which is the same discipline
/// [`H2WritableStateTarget::Flush`] keeps by carrying no `bool`: the
/// pre-raise state and the post-raise socket answer are parameters of one
/// function that never read each other's source, and no binding survives
/// in the raising scope for a later edit to point at the wrong question.
/// The query is read fresh here, exactly as [`Self::force_disconnect`]
/// used to read it fresh at all seventeen sites — including the four whose
/// caller already held an answer, which is why none of them passes its own.
///
/// Between the raise and this call the core cannot change the answer: it
/// holds no socket, so nothing it runs can flush a record. What the gap
/// can change is what the log lines render. On the ordinary paths there is
/// no gap. On [`Self::goaway`]'s serialisation-failure arm, reachable from
/// `Self::enqueue_rst`, `ConnectionH2::remove_dead_stream` and the rest
/// of a frame handler may run first, so `streams=` and `expect_write=`
/// report the connection as the settlement finds it rather than as the
/// raise left it. `peer_gone` is not among them — it is carried.
pub fn force_disconnect_after_query(
&mut self,
target: H2ForceDisconnectTarget,
tls_wants_write: bool,
) -> MuxResult {
match target {
H2ForceDisconnectTarget::Client => {
debug!(
"{} H2 force_disconnect client: state={:?}, streams={}, expect_write={:?}, wants_write={}, readiness={:?}",
log_context!(self),
self.state,
self.stream_table.streams().len(),
self.stream_table.expect_write(),
tls_wants_write,
self.readiness
);
MuxResult::Continue
}
// Don't disconnect immediately if rustls still has buffered TLS
// records. Returning CloseSession here triggers shutdown(Write)
// which sends FIN — but any TLS records still in rustls's buffer
// (not yet flushed to the TCP send buffer) are lost, causing the
// client to see "TLS decode error / unexpected eof".
// Instead, keep WRITABLE interest and let the writable path flush.
// The decision itself is `h2_close::force_disconnect_action`,
// exhaustively unit-tested there.
//
// The answer is this function's parameter, and reported by every
// log line here. A literal `wants_write=` in any arm is a second
// copy of a fact the socket already owns, and the closing arm is
// reached with records still pending whenever the peer is gone —
// an operator diagnosing a truncation under HAProxy chaining would
// read the opposite of the socket's state.
H2ForceDisconnectTarget::Server { peer_gone } => {
if h2_close::force_disconnect_action(peer_gone, tls_wants_write)
== CloseAction::ReArmAndContinue
{
debug!(
"{} H2 force_disconnect delaying close: state={:?}, streams={}, expect_write={:?}, wants_write={}, readiness={:?}",
log_context!(self),
self.state,
self.stream_table.streams().len(),
self.stream_table.expect_write(),
tls_wants_write,
self.readiness
);
self.readiness.interest = Ready::WRITABLE | Ready::HUP | Ready::ERROR;
self.ensure_tls_flushed(tls_wants_write);
MuxResult::Continue
} else {
debug!(
"{} H2 force_disconnect closing session: state={:?}, streams={}, expect_write={:?}, wants_write={}, readiness={:?}",
log_context!(self),
self.state,
self.stream_table.streams().len(),
self.stream_table.expect_write(),
tls_wants_write,
self.readiness
);
MuxResult::CloseSession
}
}
}
}
/// Reset a stream: tear down kawa state, emit `RST_STREAM` on the wire,
/// and record MadeYouReset accounting.
///
/// `wire_stream_id` is the on-wire `StreamId`; `stream_id` is the internal
/// `GlobalStreamId` slot. Callers already carry both so we pass them
/// explicitly rather than scanning `self.streams`. The wire id is threaded
/// into `Self::enqueue_rst` which queues the frame for serialisation in
/// `Self::flush_pending_control_frames` on the next writable tick —
/// independent of whether the caller immediately evicts the slot via
/// `remove_dead_stream` (which they usually do). This is what guarantees
/// the RST reaches the peer for malformed HEADERS / flow-control /
/// content-length violations flagged by h2spec 2.0.
pub fn reset_stream<E, L>(
&mut self,
wire_stream_id: StreamId,
stream_id: GlobalStreamId,
context: &mut Context<L>,
mut endpoint: E,
error: H2Error,
) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
// Compute totals before taking mutable borrows on the target stream.
let reset_byte_totals = self.compute_stream_byte_totals(context);
context.unlink_stream(stream_id);
let stream = &mut context.streams[stream_id];
trace!(
"{} reset H2 stream {}: {:#?}",
log_context!(self),
stream_id,
stream.context
);
let old_state = std::mem::replace(&mut stream.state, StreamState::Unlinked);
forcefully_terminate_answer(stream, &mut self.readiness, error);
let linked_token = if let StreamState::Linked(token) = old_state {
Some(token)
} else {
None
};
let (client_rtt, server_rtt) = self.snapshot_rtts(&endpoint, linked_token);
if let Some(token) = linked_token {
endpoint.end_stream(token, stream_id, context);
}
// Emit access log for server-side resets on streams that had active requests
if self.position.is_server()
&& matches!(old_state, StreamState::Link | StreamState::Linked(_))
{
let stream = &mut context.streams[stream_id];
self.distribute_overhead(&mut stream.metrics, reset_byte_totals);
stream.metrics.backend_stop();
let events = stream.generate_access_log(
true,
Some("H2::Reset"),
context.listener.clone(),
client_rtt,
server_rtt,
);
self.metric_events.extend(events.into_iter().flatten());
stream.metrics.reset();
}
// Queue the RST for wire emission. Independent of the owning stream
// remaining in `self.streams` — callers typically follow this with
// `remove_dead_stream`, which would otherwise evict the slot before
// `write_streams` could run `kawa.prepare` against the converter.
//
// `enqueue_rst` performs every accounting side-effect at queue
// time (per-error counter, global tx counter, CVE-2025-8671
// MadeYouReset cap). Every caller of `reset_stream` answers a frame
// the peer sent (Content-Length mismatch, header parse error,
// PRIORITY self-dependency, zero-increment or overflowing
// WINDOW_UPDATE), so the reset is `RstOrigin::PeerProvoked`; a
// `NoError` reset is still exempt inside the accounting helper.
if let Some(result) = self.enqueue_rst(wire_stream_id, error, RstOrigin::PeerProvoked) {
return result;
}
MuxResult::Continue
}
pub fn end_stream<L>(&mut self, stream_gid: GlobalStreamId, context: &mut Context<L>)
where
L: ListenerHandler + L7ListenerHandler,
{
context.unlink_stream(stream_gid);
let stream_context = context.http_context(stream_gid);
trace!(
"{} end H2 stream {}: {:#?}",
log_context!(self),
stream_gid,
stream_context
);
match self.position {
Position::Client(..) => {
// Resolve the wire StreamId for this gid up front so the
// subsequent cleanup does not hold an iterator borrow on
// `self.streams` while also mutating it.
let wire_stream_id = self
.stream_table
.streams()
.iter()
.find_map(|(&sid, &gid)| (gid == stream_gid).then_some(sid));
if let Some(id) = wire_stream_id {
// Only send RST_STREAM if the stream hasn't fully completed.
// If both request and response are terminated, the stream is
// already in "closed" state (RFC 9113 §5.1) — sending RST_STREAM
// on a closed stream would be a protocol error that could cause
// the H2 peer to close the entire connection.
//
// Nor for a stream the backend has never seen: its id is
// allocated by `start_stream` when the request is linked,
// but its HEADERS leave with a later write pass, and they
// may never have (the preface is not out yet, the pass has
// not run, or the socket took nothing and the block sits
// parked in `front.out`). On `Position::Client`,
// `front.consumed` is exactly "a request byte reached this
// socket", and a stream's first frame is its HEADERS. A
// RST_STREAM on an idle stream is a connection error
// (RFC 9113 §5.1, §6.4), and one queued ahead of the
// preface breaks it (§3.4): either takes down every stream
// this shared connection carries (sozu-proxy/sozu#1631).
// The stream is forgotten instead, as hyperium/h2 drops a
// stream still pending open: its id is burnt, which the
// next HEADERS on a higher id closes implicitly
// (RFC 9113 §5.1.1). A block parked unsent is dropped with
// it and resets the encoder's table on the next pass
// (`ConnectionH2::parked_header_block`).
//
// "Fully completed" means END_STREAM went both ways: the
// response's was read, and the request's was handed to
// the output, which flushes in order. A request received
// whole is not enough: a backend may answer early, before
// the rest of the body reached it (RFC 9113 §8.1), and a
// stream forgotten with its tail unsent stays open on the
// backend, holding one of its MAX_CONCURRENT_STREAMS
// slots (§5.1.2) for this connection's lifetime.
let stream = &context.streams[stream_gid];
let fully_completed = stream.back_received_end_of_stream
&& stream.front.is_terminated()
&& stream.front.is_completed();
let opened_on_the_wire = stream.front.consumed;
#[cfg(debug_assertions)]
let queued_before = self.output.len();
if opened_on_the_wire
&& !fully_completed
&& !self.stream_table.rst_sent_contains(id)
{
// Same path as a PING or SETTINGS ACK: queued whole
// behind any output already waiting, including the
// rest of a stream frame a partial write cut, and
// flushed by the next write pass (#1597, #1604).
if self
.output
.push_frames(RST_STREAM_FRAME_SIZE, |buf| {
serializer::gen_rst_stream(buf, id, H2Error::Cancel)
.map(|(_, size)| size)
})
.is_ok()
{
self.metric_events
.push(MetricEvent::RstStreamSent(H2Error::Cancel));
self.queue_output();
self.stream_table.rst_sent_mut().insert(id);
}
}
// Retire the stream and invalidate expect_write/expect_read
// if they still reference this gid — the slot may be popped
// by `shrink_trailing_recycle` on the next create_stream.
self.remove_dead_stream(id, stream_gid);
// Post: a stream that never opened on the wire leaves no
// frame of its own behind (sozu-proxy/sozu#1631).
#[cfg(debug_assertions)]
debug_assert!(
opened_on_the_wire || self.output.len() == queued_before,
"no frame is queued for a backend stream whose HEADERS never left"
);
debug_assert!(
!self.stream_table.streams().contains_key(&id),
"end_stream must retire the backend stream's wire id"
);
if context.streams[stream_gid].state != StreamState::Recycle {
context.streams[stream_gid].state = StreamState::Unlinked;
}
return;
}
error!(
"{} end_stream called for unknown global_stream_id {}",
log_context!(self),
stream_gid
);
}
Position::Server => {
let stream = &mut context.streams[stream_gid];
let answers_rc = stream.answers.clone();
match end_stream_decision(stream) {
EndStreamAction::ForwardTerminated => {
#[cfg(debug_assertions)]
context
.debug
.push(DebugEvent::Str(format!("Close terminated {stream_gid}")));
debug!(
"{} CLOSING H2 TERMINATED STREAM {} {:?}",
log_context!(self),
stream_gid,
stream
);
stream.state = StreamState::Unlinked;
self.readiness.arm_writable();
context.debug.set_interesting(true);
}
EndStreamAction::CloseDelimited
if stream.back.body_size != kawa::BodySize::Empty =>
{
// A `Content-Length` body short of its length, or a chunked body cut before
// its `0\r\n\r\n` or in its trailers, is truncated (RFC 9112 §6.3, §7.1,
// sozu-proxy/sozu#1633): RST_STREAM, never END_STREAM, as does
// `ConnectionH1::terminate_close_delimited`. The blocks not yet encoded go
// with it, among them a trailer block still waiting for
// its closing flags, which the converter would otherwise
// wait on forever; none of them touched the HPACK table
// (sozu-proxy/sozu#1627).
warn!(
"{} H1 backend closed a {:?} response mid-body on H2 stream {}: emitting RST_STREAM",
log_context!(self),
stream.back.body_size,
stream_gid
);
incr!(names::h1::BACKEND_EOF_BEFORE_MESSAGE_COMPLETE);
stream.back.blocks.clear();
stream
.back
.parsing_phase
.error(kawa::ParsingErrorKind::Processing {
message: "INTERNAL_ERROR",
});
stream.state = StreamState::Unlinked;
self.readiness.arm_writable();
context.debug.set_interesting(true);
debug_assert!(
stream.back.is_error() && stream.back.blocks.is_empty(),
"a truncated response ends in Error with nothing left to encode"
);
}
EndStreamAction::CloseDelimited => {
debug!(
"{} CLOSE DELIMITED H2 STREAM {} {:?}",
log_context!(self),
stream_gid,
stream
);
stream.back.push_block(kawa::Block::Flags(kawa::Flags {
end_body: true,
end_chunk: false,
end_header: false,
end_stream: true,
}));
stream.back.parsing_phase = kawa::ParsingPhase::Terminated;
stream.state = StreamState::Unlinked;
self.readiness.arm_writable();
context.debug.set_interesting(true);
}
EndStreamAction::ForwardUnterminated => {
#[cfg(debug_assertions)]
context
.debug
.push(DebugEvent::Str(format!("Close unterminated {stream_gid}")));
debug!(
"{} CLOSING H2 UNTERMINATED STREAM {} {:?}",
log_context!(self),
stream_gid,
stream
);
forcefully_terminate_answer(
stream,
&mut self.readiness,
H2Error::InternalError,
);
context.debug.set_interesting(true);
}
EndStreamAction::SendDefault(status) => {
#[cfg(debug_assertions)]
context.debug.push(DebugEvent::Str(format!(
"Can't retry, send {status} on {stream_gid}"
)));
let answers = answers_rc.borrow();
set_default_answer(stream, &mut self.readiness, status, &answers);
}
EndStreamAction::ReplayOnFreshBackend => {
// Reachable only through an H1 BACKEND behind this H2
// frontend: `reused_from_pool` — and so the captured
// request — is set by `ConnectionH1::start_stream`
// alone, and an H2 backend never fills it. The action
// is handled here rather than merged into
// `SendDefault` so the H2 frontend re-routes the same
// stale-upstream race the H1 frontend does, with the
// same bytes on the wire.
match stream.queue_upstream_replay() {
Some(len) => {
debug!(
"{} H2 REPLAY {} request bytes on a fresh backend",
log_context!(self),
len
);
// Deliberately still a metric macro, and the
// only one left in this core. It is the one
// labelled site: carrying its cluster and
// backend labels in a `MetricEvent` means
// owned `Option<String>`s, which measures at
// `size_of::<MetricEvent>()` 24 -> 48 bytes —
// paid by every queued event, for a site that
// fires only on a stale-upstream retry — and
// costs the enum its `Copy`. Recording it here
// is the cheaper half of that trade, not an
// oversight.
incr!(
names::backend::RETRY_STALE_UPSTREAM,
stream.context.cluster_id.as_deref(),
stream.context.backend_id.as_deref(),
stream.context.cluster_metrics_incarnation()
);
stream.state = StreamState::Link;
context.pending_links.push_back(stream_gid);
}
None => {
error!(
"{} replay selected with no captured request",
log_context!(self)
);
let answers = answers_rc.borrow();
set_default_answer(stream, &mut self.readiness, 502, &answers);
}
}
}
EndStreamAction::Reconnect => {
debug!("{} H2 RECONNECT", log_context!(self));
#[cfg(debug_assertions)]
context
.debug
.push(DebugEvent::Str(format!("Retry {stream_gid}")));
stream.state = StreamState::Link;
context.pending_links.push_back(stream_gid);
}
}
}
}
}
pub fn start_stream<L>(&mut self, stream: GlobalStreamId, context: &mut Context<L>) -> bool
where
L: ListenerHandler + L7ListenerHandler,
{
// Entry point: a backend connection reaches this from the router
// without necessarily having had `readable`/`writable` called on it
// this pass, so adopt the mux's snapshot before arming the new
// stream's liveness deadline below.
self.now = context.now;
// RFC 9113 §6.8: reject new streams on a draining connection
if self.drain.draining() {
error!(
"{} Cannot open new stream on draining connection (stream {})",
log_context!(self),
stream
);
return false;
}
// RFC 9113 §5.1.2: respect peer's max concurrent streams limit
if self.stream_table.len() >= self.peer_settings.settings_max_concurrent_streams as usize {
error!(
"{} Cannot open new stream: active={} >= peer max_concurrent_streams={}",
log_context!(self),
self.stream_table.len(),
self.peer_settings.settings_max_concurrent_streams
);
return false;
}
trace!(
"{} start new H2 stream {} {:?}",
log_context!(self),
stream,
self.readiness
);
// sozu-proxy/sozu#1632, defense in depth: a request whose front
// already holds output was prepared for another connection — frames
// with its stream id and HPACK state, or H1 bytes — and its blocks
// are gone, so this connection could neither send nor re-encode it.
// `end_stream_decision` and `handle_goaway_frame` never link such a
// request; this refuses one that a future path would, before an id is
// allocated. The caller answers the client with an error.
if !context.streams[stream].front.out.is_empty() {
error!(
"{} Cannot open stream {}: its request was already encoded for another connection",
log_context!(self),
stream
);
return false;
}
let Some(stream_id) = self.new_stream_id() else {
// Pass 4 Medium #5: the client-initiated stream-ID space
// (31 bits, odd only) is exhausted. The backend is now useless
// for new requests — gracefully drain it. Without this
// transition, the Connection lingers in `Connected` state and
// every subsequent request returns 503 because `start_stream`
// keeps returning false.
//
// The session envelope is hoisted to a local because the
// `match &mut self.position` below holds a mutable borrow on
// `self.position`, and `log_context!(self)` reads that field
// for its `position={...}` slot — calling the macro inside the
// match arms would conflict with the active borrow. The
// bidirectional regression guard in `lib/tests/log_layout.rs`
// (and the matching scanner in `lib/build.rs`) recognises this
// shape by scanning backward as well as forward from each log
// call.
let context = log_context!(self);
match &mut self.position {
Position::Client(cluster_id, backend, status) => {
let backend_addr = backend.address;
let cluster = cluster_id.clone();
info!(
"{} H2 backend stream IDs exhausted (cluster={}, backend={:?}) — draining",
context, cluster, backend_addr
);
*status = BackendStatus::Disconnecting;
}
Position::Server => {
error!(
"{} H2 server stream IDs exhausted — sending graceful GOAWAY",
context
);
}
}
self.graceful_goaway(self.now);
return false;
};
// RFC 9113 §6.9: the stream's send window on this connection starts
// at the backend's own SETTINGS_INITIAL_WINDOW_SIZE (the 65535
// default until its SETTINGS arrive), whatever the client granted on
// the frontend connection and whatever a previous request on the
// slot left.
*context.streams[stream].send_window_mut(&self.position) =
i32::try_from(self.peer_settings.settings_initial_window_size).unwrap_or(i32::MAX);
// Pre: the backend leg of the slot starts fresh, whether it comes
// from `Context::create_stream` or from an H1 frontend's keep-alive
// reset (sozu-proxy/sozu#1781): nothing has been received on it yet,
// or `handle_read` would refuse the response as arriving on a closed
// stream. The frontend leg may legitimately be done already: an H2
// client's request ends with END_STREAM before the backend is
// dialled.
debug_assert!(
!context.streams[stream].back_received_end_of_stream
&& context.streams[stream].back_data_received == 0,
"a stream must open on a backend with a fresh backend leg"
);
self.stream_table.register(stream_id, stream, self.now);
// Sōzu opened this stream; the backend peer cannot inflate the count.
self.flood_detector.record_stream_opened();
self.readiness.arm_writable();
true
}
}
/// A [`ConnectionH2`] and the socket it speaks through.
///
/// The split issue #1339 Q10 decided, in the shape it decided: the core is
/// byte-in / byte-out and knows nothing about sockets, while everything that
/// reaches one lives here. `Connection::H2` holds this rather than the core,
/// so `Connection<Front: SocketHandler>` keeps its bound and `Router::backends`
/// keeps its declared type — the parameter did not leave the mux, it moved off
/// the state machine.
///
/// Both halves sit in this module, so this impl reaches the core's private
/// fields exactly as the methods it inherited used to. Nothing was widened to
/// `pub` to let the socket keep working; the `pub` surface that did grow
/// exists for a driver OUTSIDE this crate — `sim/tests/h2_simulation.rs` is
/// the first, and it builds its own shell over the same poll/handle pairs
/// this one uses.
pub struct H2Shell<Front: SocketHandler> {
/// The socket-free state machine.
pub core: ConnectionH2,
/// The only socket in the H2 frontend or backend path.
pub socket: Front,
/// `write_streams`' vectored-write scratch, kept for the connection's
/// lifetime so a transmitting pass reuses its capacity instead of
/// allocating it. Empty between two `h2_transmit::gather` /
/// `h2_transmit::confirm` brackets; private so that bracket cannot be
/// split from outside this module.
io_slices: Vec<IoSlice<'static>>,
/// This connection's one sample of [`Self::socket`]'s round-trip time:
/// `None` until the first stream logged on the connection asks for it
/// through [`ShellEndpoint::local_rtt`] (a frontend's `client_rtt`) or
/// `Connection::rtt` (a backend's `server_rtt`), then `Some(sample)` for
/// the rest of the connection's life — see
/// [`memoized_rtt`](super::memoized_rtt). A `Cell` because
/// `Endpoint::local_rtt` takes `&self`.
pub(super) local_rtt: Cell<Option<Option<Duration>>>,
/// A write to the client failed (reset, broken pipe or another socket
/// error): nothing more can be flushed to it, so the connection reports
/// no pending write and `Self::writable` closes the session instead of
/// waiting for a flush that cannot happen (sozu-proxy/sozu#1792).
write_failed: bool,
}
/// Renders the core and nothing else.
///
/// Deliberately NOT derived. A derive would render `socket` through
/// `Front`'s own `Debug`, which for a `mio::net::TcpStream` is a local
/// address, a peer address and a file descriptor — precisely what
/// [`ConnectionH2`]'s own `Debug` stopped rendering, and for the same reasons
/// it gives. Wrapping the core in a new type must not hand that back through
/// the wrapper.
impl<Front: SocketHandler> std::fmt::Debug for H2Shell<Front> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.core.fmt(f)
}
}
/// Symmetric tear-down for the four aggregate gauges
/// `ConnectionH2::gauge_connection_state` feeds — the three
/// `h2.connection.*` metrics and `h2.streams.ready_incremental.by_urgency`:
/// whatever positive contribution this connection made is subtracted back out
/// when the connection is dropped, and the whole event queue is flushed with
/// it.
///
/// Using `Drop` (rather than wiring decrements into every close path —
/// `graceful_goaway`, `force_disconnect`, `handle_goaway_frame`, `Mux::close`,
/// stream-id exhaustion, panic-unwind) is what guarantees the gauge is
/// arithmetically symmetric regardless of which path teardown took. Past
/// underflow incidents (commits ff401b54, aadb3fa4) were increment/decrement
/// asymmetries. `ff401b54` carried both shapes at once: a `-1` for streams that
/// never ran the `+1`, AND a `-1` that ran twice on one stream (100-Continue,
/// and an H2 reset followed by close). `aadb3fa4` was the doubled `-1` alone,
/// let through by an early return. `Drop` closes both shapes — it is the sole
/// decrement site, and taking `last_gauge_snapshot` makes a second call a
/// no-op.
///
/// It sits on the **shell** rather than on [`ConnectionH2`] because
/// `record_metric` writes the worker-local `METRICS` aggregator, which is
/// exactly the ambient reach the core gave up. RAII is not given up with it:
/// the shell owns the core, so the core cannot outlive this, and because
/// `H2Shell` now implements `Drop` the compiler additionally refuses to move
/// `core` out of it. If this impl were lost, every live connection's
/// contribution would leak upward permanently, with no resync and no
/// underflow to notice — which is what
/// `dropping_a_connection_releases_its_ready_incremental_contribution` pins.
/// The [`Endpoint`] the shell hands its core: every call forwards to `inner`,
/// except [`Endpoint::local_rtt`], which the shell answers from its own socket.
///
/// The core asks for the local round-trip time only when it logs a stream
/// (`ConnectionH2::snapshot_rtts`), so the `getsockopt(TCP_INFO)` happens then
/// and only then, once per connection: the first ask samples and stores into
/// `sample`, every later one on the connection reuses it. Built per core
/// call from disjoint borrows of the shell's fields, so the core never holds
/// the socket. Issue #1590.
struct ShellEndpoint<'a, E, Front> {
inner: &'a mut E,
socket: &'a Front,
sample: &'a Cell<Option<Option<Duration>>>,
}
impl<'a, E, Front> ShellEndpoint<'a, E, Front> {
fn new(
inner: &'a mut E,
socket: &'a Front,
sample: &'a Cell<Option<Option<Duration>>>,
) -> Self {
ShellEndpoint {
inner,
socket,
sample,
}
}
}
impl<E: Endpoint, Front: SocketHandler> std::fmt::Debug for ShellEndpoint<'_, E, Front> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ShellEndpoint")
.field("inner", &self.inner)
.field("sample", &self.sample.get())
.finish()
}
}
impl<E: Endpoint, Front: SocketHandler> Endpoint for ShellEndpoint<'_, E, Front> {
fn readiness(&self, token: mio::Token) -> &Readiness {
self.inner.readiness(token)
}
fn readiness_mut(&mut self, token: mio::Token) -> &mut Readiness {
self.inner.readiness_mut(token)
}
fn peer_rtt(&self, token: mio::Token) -> Option<Duration> {
self.inner.peer_rtt(token)
}
fn local_rtt(&self) -> Option<Duration> {
super::memoized_rtt(self.sample, self.socket.socket_ref())
}
fn end_stream<L: ListenerHandler + L7ListenerHandler>(
&mut self,
token: mio::Token,
stream: GlobalStreamId,
context: &mut Context<L>,
) {
self.inner.end_stream(token, stream, context)
}
fn start_stream<L: ListenerHandler + L7ListenerHandler>(
&mut self,
token: mio::Token,
stream: GlobalStreamId,
context: &mut Context<L>,
) -> bool {
self.inner.start_stream(token, stream, context)
}
}
impl<Front: SocketHandler> Drop for H2Shell<Front> {
fn drop(&mut self) {
self.core.release_connection_gauges();
self.record_pending_metrics();
}
}
impl<Front: SocketHandler> H2Shell<Front> {
/// The TLS layer is holding encrypted records — handshake, alert, session
/// ticket or already-accepted application data — that it must push before
/// this connection can call a pass finished.
///
/// **Not "the socket is writable".** The name of the underlying
/// [`SocketHandler`] method says socket, and that is what made this look
/// like an I/O question for as long as it was spelled out at every site.
/// It is a question about a buffer that happens to live behind the socket:
/// `FrontRustls` is the only production handler that overrides it, the
/// trait's default answer is `false`, and `Router::backends` is declared
/// `Connection<SessionTcpStream>` whatever the frontend is — so every
/// backend H2 connection already resolves every one of these queries
/// statically to `false`. The same core is monomorphised twice today, once
/// with TLS and once without, which is the evidence that the coupling is
/// incidental rather than structural.
///
/// This is a seam, not an abbreviation. It is the single reach where
/// `ConnectionH2` asks that question, and the whole of what a byte-in /
/// byte-out core has to receive as an input instead of querying: the shell
/// that owns the socket is the only layer that can answer it, and it is
/// the layer this method's body moves to. Until then the body is the
/// delegation it always was and the answer is bit-identical.
///
/// The query is free of side effects, so a caller that needs it twice
/// around a [`Self::flush_tls_records`] is asking two genuinely different
/// questions — "does rustls hold records" and "did the kernel take them" —
/// and a caller that needs it twice with nothing in between should bind it
/// once instead. `h2_close::TlsFlushPhase` names the first case;
/// [`ConnectionH2::force_disconnect`] is the second.
fn tls_wants_write(&self) -> bool {
self.socket.socket_wants_write()
}
/// Ask the TLS layer to push whatever it has buffered towards the kernel,
/// offering it no new application bytes.
///
/// The empty slice is the whole point: this is a flush, not a write. It is
/// the only *action* among the three TLS seams, which is why the four call
/// sites are the four places where a byte-in / byte-out core must hand
/// control back to its shell rather than take an input — the answer to
/// [`Self::tls_wants_write`] changes across this call, so no value captured
/// before it can stand in for the query after it.
///
/// Three of the four have made that move and all three are in the shell
/// impl: [`Self::write_streams`] performs it for
/// [`H2FinalizeTarget::Flush`], outside the [`ConnectionH2::finalize_write`] that
/// used to run it inline, and [`Self::writable`] performs both of the
/// connection-level write pass's — its unconditional preamble, and the
/// second one [`H2WritableStateTarget::Flush`] asks for on behalf of the
/// `H2State::GoAway` arm now in [`ConnectionH2::dispatch_writable_state`]. The
/// fourth is [`Self::flush_output_buffer`].
///
/// Returns the handler's `(size, SocketResult)` unchanged. Three of the
/// four callers discard both and re-query [`Self::tls_wants_write`]
/// instead — deliberately, since a flush that moved bytes may still leave
/// records behind — and only [`Self::flush_output_buffer`] consumes the
/// status, through `update_readiness_after_write`.
fn flush_tls_records(&mut self) -> (usize, SocketResult) {
let (size, status) = self.socket.socket_write(&[]);
self.core.note_write_status(status);
self.record_write(status);
(size, status)
}
/// Remember a failed write to the client: an H2 frontend whose socket
/// answered `Error` or `Closed` can flush nothing more. A backend keeps
/// its own failure handling.
fn record_write(&mut self, status: SocketResult) {
if self.core.position.is_server()
&& matches!(status, SocketResult::Error | SocketResult::Closed)
{
self.write_failed = true;
}
}
/// Start the TLS `close_notify` handshake, generating the records that
/// [`Self::tls_wants_write`] will then report as pending.
///
/// Idempotence is the caller's business, not this seam's:
/// [`Self::initiate_close_notify`] gates it on `close_notify_sent` so a
/// second call cannot queue a second alert.
fn begin_tls_close(&mut self) {
self.socket.socket_close()
}
/// Start TLS close_notify on the frontend and keep the session alive until
/// rustls has flushed the generated records.
pub fn initiate_close_notify(&mut self) -> bool {
if !self.core.position.is_server()
|| matches!(
self.core.state,
H2State::ClientPreface | H2State::ClientSettings | H2State::ServerSettings
)
{
return false;
}
if !self.core.close_notify_sent {
trace!("{} H2 initiating CLOSE_NOTIFY", log_context!(self.core));
self.begin_tls_close();
self.core.close_notify_sent = true;
}
let tls_wants_write = self.tls_wants_write();
if tls_wants_write {
self.core.readiness.interest = Ready::WRITABLE | Ready::HUP | Ready::ERROR;
self.core.ensure_tls_flushed(tls_wants_write);
true
} else {
false
}
}
/// Returns `true` if there is data queued waiting to be flushed:
/// - The ordered output queue: control frames (GOAWAY, SETTINGS ACK,
/// etc.) and the rest of a stream frame a stalled write cut
/// - A stream parked by a stalled write (`expect_write`)
/// - Encrypted TLS records in rustls's output buffer not yet flushed to TCP
///
/// The TLS check is critical: `shutting_down()` uses this to prevent
/// premature session close while response DATA is still in rustls's
/// buffer (accepted by `socket_write_vectored` but not yet on the wire).
///
/// Does NOT check per-stream `back.out`/`back.blocks`; use
/// [`Self::has_pending_write_full`] on paths that must honour
/// LIFECYCLE invariant 16 (e.g. shutdown-drain).
pub fn has_pending_write(&self) -> bool {
!self.write_failed && self.core.has_pending_write_with(self.tls_wants_write())
}
/// Connection-level [`Self::has_pending_write`] extended with a per-stream
/// back-buffer probe (LIFECYCLE §9 invariant 16). Used by shutdown-drain
/// paths that must not close while any open stream still has outbound
/// kawa bytes queued — a voluntary scheduler yield can leave `back.out`
/// or `back.blocks` non-empty without `expect_write` being set.
pub fn has_pending_write_full<L>(&self, context: &Context<L>) -> bool
where
L: ListenerHandler + L7ListenerHandler,
{
!self.write_failed
&& (self.has_pending_write()
|| any_stream_has_pending_back(self.core.stream_table.streams(), &context.streams))
}
/// Flush the ordered output queue to the socket, counting its own bytes
/// as connection overhead.
///
/// Returns `true` if the socket stalled (WouldBlock / zero-length write),
/// meaning the caller should stop writing and wait for the next writable event.
/// Returns `false` when the queue has been fully drained.
fn flush_output_to_socket(&mut self) -> bool {
while !self.core.output_pending().is_empty() {
// The final GOAWAY takes the `close_notify` with it: one
// `write_tls` instead of this one and `H2Shell::close`'s.
let (size, status) = if self.core.output_flush_closes_connection() {
let pending = self.core.output_pending().len();
let (size, status) = self
.socket
.socket_write_then_close(self.core.output_pending());
if size == pending {
self.core.close_notify_sent = true;
}
(size, status)
} else {
self.socket.socket_write(self.core.output_pending())
};
self.record_write(status);
#[cfg(debug_assertions)]
trace!(
"{} flush_output_to_socket: written={}, status={:?}, wants_write={}",
log_context!(self.core),
size,
status,
self.tls_wants_write()
);
if self.core.consume_output_flush(size, status) {
return true;
}
}
false
}
/// Directly flush the ordered output queue to the socket without going
/// through the full writable() path. Used during shutdown when the event
/// loop won't deliver new epoll events for this session (edge-triggered).
///
/// Whatever is queued can go at any time: every entry is a whole frame
/// (or the rest of one a stalled write cut), queued in wire order, and
/// the queue shares nothing with the read side (#1604). A stream parked
/// by a stalled write keeps its park; only its already-started frame
/// lives in the queue.
pub fn flush_output_buffer(&mut self) {
if self.flush_output_to_socket() {
return;
}
if self.tls_wants_write() {
let (_size, status) = self.flush_tls_records();
let _ = update_readiness_after_write(0, status, &mut self.core.readiness);
}
}
pub fn close<E, L>(&mut self, context: &mut Context<L>, mut endpoint: E)
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
match self.core.position {
Position::Client(_, _, BackendStatus::KeepAlive) => {
error!(
"{} H2 connections do not use KeepAlive backend status",
log_context!(self.core)
);
return;
}
Position::Client(..) => {}
Position::Server => {
let tls_pending_before = self.tls_wants_write();
if !self.core.stream_table.streams().is_empty()
|| tls_pending_before
|| self.core.stream_table.expect_write().is_some()
{
debug!(
"{} H2 close with active state: state={:?}, streams={}, expect_write={:?}, wants_write={}, readiness={:?}",
log_context!(self.core),
self.core.state,
self.core.stream_table.streams().len(),
self.core.stream_table.expect_write(),
tls_pending_before,
self.core.readiness
);
for (stream_id, global_stream_id) in self.core.stream_table.streams() {
let stream = &context.streams[*global_stream_id];
debug!(
"{} close stream id={} gid={}: state={:?}, front_eos={}, back_eos={}, front_phase={:?}, back_phase={:?}, front_completed={}, back_completed={}",
log_context!(self.core),
stream_id,
global_stream_id,
stream.state,
stream.front_received_end_of_stream,
stream.back_received_end_of_stream,
stream.front.parsing_phase,
stream.back.parsing_phase,
stream.front.is_completed(),
stream.back.is_completed()
);
}
}
if !self.core.close_notify_sent {
trace!("{} H2 SENDING CLOSE NOTIFY", log_context!(self.core));
}
let (tls_pending_after, drain_rounds) =
drain_tls_close_notify(&mut self.socket, &mut self.core.close_notify_sent);
if tls_pending_after {
// Severity tiering: key on stream-count + close-state, not
// peer-vs-operator. Composes with the send-side `H2Error`
// variant tier in `goaway()` — both rules demote benign
// paths and keep loss-bearing paths loud.
//
// - `streams != 0` -> `error!`: live streams at
// close time, response-byte loss is possible.
// - `streams == 0` AND state in {GoAway, Error}
// -> `warn!`: idle close after
// a GOAWAY exchange (peer-initiated abort or our own
// graceful drain). What's stranded is best-effort
// GOAWAY/close_notify; no application data was queued.
// - `streams == 0` from any other state
// -> `error!`: unexpected
// teardown path (no GOAWAY exchange) — keep loud so
// unknown failure modes surface.
if !self.core.stream_table.streams().is_empty() {
error!(
"{} TLS buffer NOT fully drained on close: \
pending_before={}, pending_after={}, drain_rounds={}, \
state={:?}, streams={}, expect_write={:?}, \
close_notify_sent={}, readiness={:?}",
log_context!(self.core),
tls_pending_before,
tls_pending_after,
drain_rounds,
self.core.state,
self.core.stream_table.streams().len(),
self.core.stream_table.expect_write(),
self.core.close_notify_sent,
self.core.readiness
);
} else if matches!(self.core.state, H2State::GoAway | H2State::Error) {
warn!(
"{} TLS buffer NOT fully drained on close: \
pending_before={}, pending_after={}, drain_rounds={}, \
state={:?}, streams={}, expect_write={:?}, \
close_notify_sent={}, readiness={:?}",
log_context!(self.core),
tls_pending_before,
tls_pending_after,
drain_rounds,
self.core.state,
self.core.stream_table.streams().len(),
self.core.stream_table.expect_write(),
self.core.close_notify_sent,
self.core.readiness
);
} else {
error!(
"{} TLS buffer NOT fully drained on close: \
pending_before={}, pending_after={}, drain_rounds={}, \
state={:?}, streams={}, expect_write={:?}, \
close_notify_sent={}, readiness={:?}",
log_context!(self.core),
tls_pending_before,
tls_pending_after,
drain_rounds,
self.core.state,
self.core.stream_table.streams().len(),
self.core.stream_table.expect_write(),
self.core.close_notify_sent,
self.core.readiness
);
}
}
return;
}
}
// reconnection is handled by the server for each stream separately
for global_stream_id in self.core.stream_table.streams().values() {
trace!(
"{} end stream: {}",
log_context!(self.core),
global_stream_id
);
if let StreamState::Linked(token) = context.streams[*global_stream_id].state {
endpoint.end_stream(token, *global_stream_id, context);
}
}
}
/// Wrap a freshly built core around the socket it will speak through.
///
/// The `peer_addr()` call is here because this is the layer that has a
/// socket to ask. It is still the one read of it on the connection's whole
/// lifetime — `ConnectionH2::peer_address` documents why the answer is
/// snapshotted rather than re-read — and it now happens one layer out,
/// before the core exists rather than inside its constructor.
#[allow(clippy::too_many_arguments)]
pub fn new(
session_ulid: Ulid,
socket: Front,
position: super::Position,
buffers: &mut dyn super::buffer_source::BufferSource,
flood_config: H2FloodConfig,
connection_config: H2ConnectionConfig,
stream_idle_timeout: std::time::Duration,
graceful_shutdown_deadline: Option<std::time::Duration>,
timeout_duration: std::time::Duration,
expect_read: Option<(H2StreamId, usize)>,
readiness_interest: sozu_command::ready::Ready,
) -> Option<Self> {
let core = ConnectionH2::new(
session_ulid,
socket.peer_addr(),
position,
buffers,
flood_config,
connection_config,
stream_idle_timeout,
graceful_shutdown_deadline,
timeout_duration,
expect_read,
readiness_interest,
)?;
Some(H2Shell {
core,
socket,
io_slices: Vec::new(),
local_rtt: Cell::new(None),
write_failed: false,
})
}
}
/// The socket shell of the H2 data path.
///
/// Every production `self.socket` in this file is in this block and its
/// sibling above it, and `ConnectionH2` has none. `H2Shell::readable` and
/// `H2Shell::write_streams` are the two byte movers: each is the caller that
/// sits between one of the core's two poll/handle pairs —
/// [`ConnectionH2::poll_read_target`] with [`ConnectionH2::handle_read`], and
/// [`ConnectionH2::poll_write_target`] with [`ConnectionH2::handle_write`].
/// All four are `pub`, and so are the buffers they name
/// ([`ConnectionH2::read_space`], [`ConnectionH2::write_buffer`]), so a driver
/// outside this crate can stand exactly where these two functions stand.
/// `sim/tests/h2_simulation.rs` is that driver, and it is what makes the claim
/// measurable rather than asserted.
///
/// `H2Shell::writable` is the third member, and the only socket it touches it
/// touches through the three TLS seams — `H2Shell::tls_wants_write`,
/// `H2Shell::flush_tls_records` and `H2Shell::begin_tls_close` — whose bodies
/// live here too. It drives two more poll/handle pairs:
/// [`ConnectionH2::flush_pending_control_frames`] with
/// [`ConnectionH2::handle_control_flush`], and
/// [`ConnectionH2::dispatch_writable_state`] with
/// [`ConnectionH2::dispatch_writable_state_after_flush`]. Neither takes a
/// `Front`: the `(H2State, Position)` dispatch and the control-frame walk are
/// both drivable without a socket, which is the whole claim.
///
/// The fourth pair is `ConnectionH2::force_disconnect` with
/// [`ConnectionH2::force_disconnect_after_query`], and it is the one that
/// cannot ride a return value — see [`H2ForceDisconnectTarget`]. `H2Shell::settled`
/// is where it lands, and every `MuxResult` a core step hands this block goes
/// through it.
///
/// # Why `write_streams` stays private, and where the `unsafe` windows are
///
/// `H2Shell::write_streams` drives the `Vec<IoSlice<'static>>` that brackets
/// `h2_transmit::gather` and `h2_transmit::confirm`, and it drives it because
/// the loop that opens and closes that bracket lives with it. The vector
/// itself is the shell's private `io_slices` field, so its capacity outlives
/// the pass while no descriptor does. There are TWO
/// `unsafe` blocks on this path and they nest:
///
/// - the INNER one is `gather`'s `slice::from_raw_parts`, which re-labels
/// borrows of `kawa.storage` as `'static`. It opens and closes inside that
/// function, exactly as it always did.
/// - the OUTER one is the `unsafe { … }` around the `gather` CALL below, which
/// exists because `gather` is `pub unsafe fn`: the lifetime obligation is
/// stated in its signature and discharged by the caller. It wraps the call
/// and nothing else — not the vectored write, not the `confirm`. It is the
/// only `unsafe` block in production code in this file.
///
/// **The window did not widen when `write_streams` moved onto `H2Shell`, nor
/// when the vector moved into a field of it.** It still opens at that `gather`
/// call and closes at the `confirm` three statements later, where
/// `io_slices.clear()` runs before the consume which may relocate the storage.
/// The only thing entered while the descriptors are live is
/// `SocketHandler::socket_write_vectored`, which reborrows them for the
/// duration of the call and cannot retain them. No `IoSlice<'static>`
/// survives one iteration of the loop: what the field keeps between passes is
/// capacity, and the vector is empty whenever `write_streams` is not inside
/// that bracket. `gather` clears it on entry as well, so even a descriptor
/// stranded by a panic out of the vectored write is discarded before anything
/// could read it. What changed is the
/// receiver, from `&mut ConnectionH2<Front>` to `&mut H2Shell<Front>`, and the
/// buffer now arrives through [`ConnectionH2::write_buffer`] instead of the
/// free function — neither of which moves either end of the bracket.
///
/// That the bracket does not span the poll/handle seam matters MORE now, not
/// less: the seam is `pub`, the buffers it names are `pub`, and an
/// out-of-crate driver really does exist, so halves on opposite sides of it
/// could be separated by a caller this crate cannot see. `write_streams` stays
/// private for that reason, on `H2Shell` exactly as it was on `ConnectionH2`.
/// `h2_transmit`'s pair is exported — `gather` as `pub unsafe fn`, `confirm`
/// as a plain `pub fn` that produces no descriptor and so leaves no
/// obligation — which moves the same argument onto that driver's own bracket
/// rather than dissolving it. `H2Harness::write_streams` in
/// `sim/tests/h2_simulation.rs` opens and closes its own bracket inside its
/// own single function, for the same reason and with the same shape.
impl<Front: SocketHandler> H2Shell<Front> {
/// Answer a core step, settling any [`H2ForceDisconnectTarget`] it parked
/// on the way.
///
/// The one place the socket answers a force-disconnect, and the reason
/// `ConnectionH2::force_disconnect` can be raised from seventeen sites
/// that hold no socket. Every `MuxResult` a core step hands this impl
/// block leaves through here, which is what makes the discipline
/// greppable rather than asserted: a core call in this block whose type is
/// [`MuxResult`] and which does not appear as this function's argument is
/// a missed settlement.
///
/// `result` is the core's provisional answer and survives untouched when
/// nothing was parked — the overwhelmingly common case, since only a
/// force-disconnect parks anything.
/// [`ConnectionH2::force_disconnect`], settled.
///
/// The public spelling of the decision: the core raises it and this reads
/// the query it could not. Every out-of-shell caller goes through here.
pub fn force_disconnect(&mut self) -> MuxResult {
let result = self.core.force_disconnect();
self.settled(result)
}
/// [`ConnectionH2::graceful_goaway`], settled.
///
/// Reaches `ConnectionH2::force_disconnect` through
/// `ConnectionH2::send_initial_goaway`'s serialisation-failure arm.
pub fn graceful_goaway(&mut self, now: Instant) -> MuxResult {
let result = self.core.graceful_goaway(now);
self.settled(result)
}
/// [`ConnectionH2::goaway_before_forced_close`], settled.
///
/// Reaches `ConnectionH2::force_disconnect` through the
/// serialisation-failure arm of the final GOAWAY.
pub fn goaway_before_forced_close<L>(&mut self, context: &Context<L>) -> MuxResult
where
L: ListenerHandler + L7ListenerHandler,
{
let result = self.core.goaway_before_forced_close(context);
self.settled(result)
}
/// [`ConnectionH2::start_stream`], settled.
///
/// Answers the core's `bool` unchanged. The settlement is still required
/// and still belongs HERE: the core reaches
/// `ConnectionH2::graceful_goaway` from its stream-id-exhaustion arm, and
/// the readiness that settlement writes is read by `Mux` before it decides
/// whether this connection gets a `writable` at all.
pub fn start_stream<L>(&mut self, stream: GlobalStreamId, context: &mut Context<L>) -> bool
where
L: ListenerHandler + L7ListenerHandler,
{
let started = self.core.start_stream(stream, context);
self.settle();
started
}
/// [`ConnectionH2::cancel_timed_out_streams`], settled.
///
/// The core answers nothing and already discards the flood-violation
/// `MuxResult` its `ConnectionH2::enqueue_rst` sweep can raise — see its
/// own comment. That discard is preserved: what this adds is the readiness
/// half of a force-disconnect that discard used to leave completed.
pub fn cancel_timed_out_streams<E, L>(&mut self, context: &mut Context<L>, endpoint: &mut E)
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
self.core.cancel_timed_out_streams(
context,
&mut ShellEndpoint::new(endpoint, &self.socket, &self.local_rtt),
);
self.settle();
}
/// [`ConnectionH2::end_stream`], forwarded and drained.
///
/// No settlement: `ConnectionH2::end_stream` cannot reach
/// `ConnectionH2::force_disconnect`. It is here only because
/// `Connection`'s `forward!` needs one name on both arms.
///
/// It still has to drain. The core's `end_stream` serialises a
/// RST_STREAM(CANCEL) for a stream the peer abandoned, and that is the one
/// producer this file reaches through a public entry point that settles
/// neither through `H2Shell::settled` nor through `H2Shell::settle`.
pub fn end_stream<L>(&mut self, stream_gid: GlobalStreamId, context: &mut Context<L>)
where
L: ListenerHandler + L7ListenerHandler,
{
self.core.end_stream(stream_gid, context);
self.record_pending_metrics();
}
/// Drive [`ConnectionH2::flush_pending_control_frames`] to a terminal
/// answer, performing the output flush it asks for.
///
/// The loop is the shell's because the socket is: the core walks its
/// stages, queues their frames behind the ordered output, and stops once
/// with the bytes it cannot move, then is told what the write reported.
/// At most two iterations run, and a stall leaves the loop with
/// [`H2ControlFlushTarget::Stalled`], which is the caller's cue to read
/// the TLS query the stalled write just changed the answer to.
fn drive_control_flush(&mut self) -> ControlFlushOutcome {
let mut target = self.core.flush_pending_control_frames();
loop {
match target {
H2ControlFlushTarget::FlushOutput => {
let stalled = self.flush_output_to_socket();
target = self.core.handle_control_flush(stalled);
}
H2ControlFlushTarget::Proceed => return ControlFlushOutcome::Proceed,
H2ControlFlushTarget::Done(result) => return ControlFlushOutcome::Done(result),
H2ControlFlushTarget::Stalled => return ControlFlushOutcome::Stalled,
}
}
}
fn settled(&mut self, result: MuxResult) -> MuxResult {
let settled = match self.core.poll_force_disconnect() {
None => result,
Some(target) => {
let tls_wants_write = self.tls_wants_write();
self.core
.force_disconnect_after_query(target, tls_wants_write)
}
};
self.record_pending_metrics();
settled
}
/// Record every [`MetricEvent`] the core queued during the step that just
/// ended.
///
/// The shell half of the boundary: the core states the arithmetic, this
/// writes it into the worker-local `METRICS` aggregator.
///
/// Three sites, and between them they cover every producer:
///
/// - [`Self::settled`], so after every `readable`, `writable`,
/// `start_stream`, `cancel_timed_out_streams`, `force_disconnect` and
/// `graceful_goaway`, and after `writable_inner`'s
/// [`Self::drive_control_flush`], whose only caller it is;
/// - [`Self::end_stream`], the one public entry point that reaches a
/// producer and settles through neither;
/// - [`Drop`].
///
/// The remaining public entry points — `close`, `initiate_close_notify`,
/// `flush_output_buffer`, `has_pending_write`, `has_pending_write_full` —
/// reach no producer but one: they touch the TLS layer, the output queue
/// and readiness, and of the core methods they call
/// (`consume_output_flush`, `ensure_tls_flushed`, `has_pending_write_with`,
/// `output_pending`) only `consume_output_flush` queues a metric, the
/// overhead byte count, which the next [`Self::settled`] records.
///
/// A step that reached none of the three would still not lose an event:
/// the queue is FIFO, so the next drain records the same values, and
/// `Drop` drains whatever is left. That is what bounds the queue, and what
/// keeps "drain here" a scheduling choice rather than a correctness one.
fn record_pending_metrics(&mut self) {
for event in self.core.drain_metric_events() {
record_metric(event);
}
}
/// [`ConnectionH2::settled`] for a core step whose own answer is not a
/// [`MuxResult`], or is one this layer has always dropped.
///
/// `ConnectionH2::start_stream` answers a `bool` and
/// `ConnectionH2::cancel_timed_out_streams` answers nothing, yet both
/// reach `ConnectionH2::force_disconnect` — the first through
/// `ConnectionH2::graceful_goaway`, the second through
/// `ConnectionH2::enqueue_rst`'s flood escalation, which it already
/// discards with `let _ =`. The settlement still has to happen HERE rather
/// than at the next entry point, because it writes `readiness` and `Mux`
/// reads `Readiness::filter_interest` between entry points to decide
/// whether to call `readable`/`writable` at all.
fn settle(&mut self) {
let _ = self.settled(MuxResult::Continue);
}
/// Drive one frontend read pass.
///
/// The core lives in `Self::poll_read_target` and `Self::handle_read`,
/// both `pub`; this function is the caller that sits between them, and its
/// `self.socket.socket_read` is the only socket touch on the whole H2 read
/// path. It lives in the shell impl rather than among the core impls for
/// the reason stated there: the socket touch sits where the socket does,
/// and the frame state machine between the two calls is drivable without
/// one, exactly as `h2_transmit::gather`/`confirm` already bracket the
/// vectored write.
pub fn readable<E, L>(&mut self, context: &mut Context<L>, endpoint: E) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
if self.core.is_lingering() {
// Entry point: adopt the mux's snapshot, as `poll_read_target`
// would. `graceful_shutdown_deadline_elapsed` reads it, and a
// snapshot frozen at the linger's start would keep a soft stop's
// budget from ever elapsing.
self.core.adopt_now(context.now);
return self.drain_linger(context.now);
}
let result = self.readable_inner(context, endpoint);
self.settled(result)
}
/// Turn a close decided by a writable pass that began after the final
/// GOAWAY ([`ConnectionH2::lingers_instead_of_closing`]) into a lingering
/// close, unless the client hung up meanwhile: queue and
/// flush `close_notify` first (a TLS alert must precede the FIN), then
/// shut the write side down, so the FIN follows everything sent, and
/// read what the client still sends until [`Self::drain_linger`] closes.
/// Any other result is returned unchanged.
fn linger_instead_of_closing(&mut self, result: MuxResult) -> MuxResult {
if !matches!(result, MuxResult::CloseSession) || self.core.frontend_hung_up_while_draining()
{
return result;
}
let (duration, max_bytes) = self.core.linger_budget();
let deadline = match self.core.linger {
Linger::Pending { deadline } => deadline,
_ => match self.core.now.checked_add(duration) {
Some(deadline) => deadline,
None => return MuxResult::CloseSession,
},
};
if self.initiate_close_notify() {
// `close_notify` is pending: the next writable pass flushes it
// and closes again, which lands here once nothing is left. The
// state is `Error` by then, so the pending linger is what keeps
// that pass eligible.
self.core.linger = Linger::Pending { deadline };
return MuxResult::Continue;
}
if let Err(e) = super::shutdown_write(self.socket.socket_ref(), false) {
debug!(
"{} H2 closing without lingering after the final GOAWAY: shutdown failed: {:?}",
log_context!(self.core),
e
);
return MuxResult::CloseSession;
}
debug!(
"{} H2 lingering after the final GOAWAY: draining what the client still sends",
log_context!(self.core)
);
self.core.linger = Linger::Draining {
deadline,
remaining: max_bytes,
};
// Fixed, never re-armed: the deadline bounds the whole drain.
self.core.timeout_deadline = Some(deadline);
self.core.readiness.interest = Ready::READABLE | Ready::HUP | Ready::ERROR;
self.core.readiness.event.remove(Ready::WRITABLE);
// Edge-triggered epoll will not report bytes already queued.
self.core.readiness.event.insert(Ready::READABLE);
MuxResult::Continue
}
/// Read and drop what the client still sends while lingering. Closes on
/// the client's EOF, a socket error, the byte budget, or the deadline;
/// otherwise waits for the next READABLE.
fn drain_linger(&mut self, now: Instant) -> MuxResult {
let Linger::Draining {
deadline,
mut remaining,
} = self.core.linger
else {
unreachable!("drain_linger runs only while lingering");
};
if now >= deadline {
debug!(
"{} H2 lingering close: deadline reached",
log_context!(self.core)
);
return MuxResult::CloseSession;
}
let result = match drain_discard(self.socket.socket_ref(), &mut remaining, |size| {
record_metric(MetricEvent::FrontendBytesIn(size as i64));
}) {
LingerRead::Wait => {
self.core.readiness.event.remove(Ready::READABLE);
MuxResult::Continue
}
LingerRead::Close => {
debug!(
"{} H2 lingering close: EOF, error or {} bytes left of the budget",
log_context!(self.core),
remaining
);
MuxResult::CloseSession
}
};
self.core.linger = Linger::Draining {
deadline,
remaining,
};
result
}
fn readable_inner<E, L>(&mut self, context: &mut Context<L>, mut endpoint: E) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
match self.core.poll_read_target(
context,
&mut ShellEndpoint::new(&mut endpoint, &self.socket, &self.local_rtt),
) {
H2ReadTarget::Done(result) => result,
H2ReadTarget::Skip(stream_id) => self.core.handle_read(
context,
ShellEndpoint::new(&mut endpoint, &self.socket, &self.local_rtt),
stream_id,
H2ReadOutcome::Skipped,
),
H2ReadTarget::Fill { stream_id, amount } => {
let space = self.core.read_space(context, stream_id, amount);
let (size, status) = self.socket.socket_read(space);
self.core.handle_read(
context,
ShellEndpoint::new(&mut endpoint, &self.socket, &self.local_rtt),
stream_id,
H2ReadOutcome::Filled {
amount,
size,
status,
},
)
}
}
}
/// Drive one frontend write pass.
///
/// The core lives in `Self::flush_pending_control_frames`,
/// `Self::dispatch_writable_state` and
/// `Self::dispatch_writable_state_after_flush`; this function is the
/// caller that sits between them, and every TLS seam call this function's
/// own body used to make inline is here. It lives in the shell impl rather
/// than among the core impls for the reason stated there: the socket
/// touches sit where the socket does, and the `(H2State, Position)`
/// dispatch between them is drivable without one.
///
/// The pass performs the query / flush / query triple `h2_close` names
/// TWICE, and the second time only for one arm:
///
/// 1. the preamble — read `Self::tls_wants_write`, flush if it says yes.
/// It pushes bytes for every state, which is why it is unconditional
/// rather than owned by a close arm.
/// 2. a FRESH read of the same query, which is that preamble's post-flush
/// answer and the pre-flush question both close arms decide on. It is
/// handed to `Self::dispatch_writable_state` as an input.
/// 3. for `H2WritableStateTarget::Flush` alone — the `H2State::GoAway`
/// arm — `Self::flush_tls_records` again, a third read, and
/// `Self::dispatch_writable_state_after_flush`.
///
/// Each read is a separate `let`, and the third shadows the second inside
/// the `Flush` arm only. That is the whole guard against the confusion
/// `h2_close::TlsFlushPhase` exists to prevent: "does rustls hold records"
/// and "did the kernel take them" are two questions, the second cannot be
/// read off the first, and a core that reused one answer for both would
/// close a connection with records pending.
///
/// The tick count is load-bearing: the flush and the re-query happen
/// within THIS call and the pass does not return `Continue` to be called
/// again, which `a_flush_that_succeeds_closes_within_one_writable_call`
/// pins. See `h2_close`'s module doc for why.
pub fn writable<E, L>(&mut self, context: &mut Context<L>, endpoint: E) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
if self.core.is_lingering() {
// Entry point: adopt the mux's snapshot, as `writable_inner` does.
self.core.adopt_now(context.now);
// The write side is shut down: nothing is left to write.
self.core.readiness.interest.remove(Ready::WRITABLE);
self.core.readiness.event.remove(Ready::WRITABLE);
return MuxResult::Continue;
}
// Asked before the pass: the close it decides moves the state from
// `GoAway` to `Error` (`ConnectionH2::force_disconnect`).
let after_final_goaway = self.core.lingers_instead_of_closing();
let result = self.writable_inner(context, endpoint);
if self.write_failed {
// The client is gone or its socket can never be flushed: close
// at once, as an H1 frontend does (`ConnectionH1::writable`).
debug!(
"{} H2 closing the frontend after a failed write",
log_context!(self.core)
);
return MuxResult::CloseSession;
}
let result = self.settled(result);
if after_final_goaway {
self.linger_instead_of_closing(result)
} else {
result
}
}
fn writable_inner<E, L>(&mut self, context: &mut Context<L>, endpoint: E) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
// Entry point: adopt the mux's snapshot for this pass.
self.core.adopt_now(context.now);
// Only this pass's writes may withhold its TLS re-arm.
self.core.kernel_refused_write = false;
self.core.prune_inactive_streams_while_closing(context);
match self.drive_control_flush() {
ControlFlushOutcome::Done(result) => return result,
// The stalled-drain tail the stages used to run inline. The
// query belongs here and not there: `flush_output_to_socket` is what
// changed the answer, so it has to be read after the stall, and
// this is the layer that owns the socket.
ControlFlushOutcome::Stalled => {
let tls_wants_write = self.tls_wants_write();
self.core.ensure_tls_flushed(tls_wants_write);
return MuxResult::Continue;
}
ControlFlushOutcome::Proceed => {}
}
// Flush any pending TLS records before state-specific processing.
// This ensures response DATA frames that were accepted by rustls
// (via socket_write_vectored in write_streams) are pushed to the
// TCP socket even when the connection is in GoAway or Error state.
// Without this, the state-specific handlers may call force_disconnect()
// before the response data reaches the kernel's TCP send buffer.
if self.tls_wants_write() {
self.flush_tls_records();
}
// A fresh query, not the binding above: this one asks whether the
// flush between them landed. It is read here rather than inside the
// two close arms that consult it because those arms no longer touch
// the socket, and it is read ONCE because only one of them can run.
let tls_wants_write = self.tls_wants_write();
match self.core.dispatch_writable_state(tls_wants_write) {
H2WritableStateTarget::Done(result) => result,
// The `H2State::GoAway` arm's own flush, the second this pass
// attempts, and the only *action* the close decision cannot take
// for itself.
H2WritableStateTarget::Flush => {
self.flush_tls_records();
// Again a fresh query, and again a different question from
// the one that produced `Flush`: `flush_tls_records`'s
// `(size, status)` is discarded, so this read is the only way
// the pass learns whether the records reached the kernel.
let tls_wants_write = self.tls_wants_write();
self.core
.dispatch_writable_state_after_flush(tls_wants_write)
}
H2WritableStateTarget::WriteStreams => self.write_streams(context, endpoint),
}
}
/// Write application data (request/response bodies, headers) across all
/// active streams, respecting priority ordering and flow control.
///
/// This is the main data-plane write path: it resumes any partially-written
/// stream, prepares new frames via the H2 block converter, flushes them to
/// the socket, and recycles completed streams.
///
/// The core lives in [`ConnectionH2::poll_write_target`] and [`ConnectionH2::handle_write`],
/// both `pub`; this function is the caller that sits between them, and its
/// `self.socket.socket_write_vectored` is the only place a stream's bytes
/// reach the socket. It lives in the shell impl rather than among the core
/// impls for the reason stated there, exactly as `readable()` keeps
/// `socket_read` outside [`ConnectionH2::poll_read_target`].
///
/// The pass's last step is a socket touch too, and for the same reason it
/// sits here: [`H2WriteTarget::Finalize`] is answered by reading
/// [`Self::tls_wants_write`], driving the [`H2FinalizeTarget`]
/// [`ConnectionH2::finalize_write`] answers — performing
/// [`Self::flush_tls_records`] for [`H2FinalizeTarget::Flush`] and not for
/// [`H2FinalizeTarget::SkipFlush`] — and reading the query again for
/// [`ConnectionH2::finalize_write_after_flush`]. That is the query / flush / query
/// triple `h2_close::TlsFlushPhase` names, and the three TLS seams it
/// reaches are what a byte-in / byte-out core receives as inputs instead
/// of asking for.
///
/// Unlike `readable()` this is a LOOP and not a match: one read pass
/// performs one `socket_read`, while one write pass performs an unbounded
/// number of vectored writes — the pre-image's `while !kawa.out.is_empty()`
/// nested inside its walk of the scheduler's order. Every iteration here is
/// one of those rounds.
///
/// The `Vec<IoSlice<'static>>` came here WITH the loop, and is only ever
/// filled inside this one private function, on purpose. It is the shell's
/// private `io_slices` field rather than a local so that a transmitting
/// pass reuses its capacity instead of allocating it: the local cost one
/// heap allocation per pass, at least one per response.
/// [`h2_transmit::gather`] is a `pub unsafe fn` that hands back
/// descriptors carrying a lifetime they do
/// not have, and [`h2_transmit::confirm`] must discharge them before the
/// consume that may relocate `kawa.storage`; bracketing the two around the
/// write in three adjacent statements keeps that window exactly as wide as
/// the pre-image's loop body kept it, and stops it from spanning the
/// poll/handle boundary — now a `pub` one, which is why the bracket's
/// locality is load-bearing rather than tidy. The `unsafe { … }` below
/// wraps the `gather` call alone; see the shell impl's own note.
///
/// The [`converter::H2BlockConverter`] is scoped to a single
/// `kawa.prepare` call rather than to the whole per-stream loop, so the
/// `&mut self.core.hpack` borrow it takes for the connection's HPACK encoder
/// never spans the loop. Every `&self` / `&mut self` method is therefore
/// callable inside the loop body; what remains deferred to after it —
/// RST accounting and stream retirement — is deferred for its own
/// ordering reasons, documented at each site.
/// [`converter::H2ConverterPass`] carries the scratch buffers and the
/// RFC 7541 §6.3 size-update signal from one stream's `prepare` to the
/// next by moving them, so the narrower scope costs no copy.
fn write_streams<E, L>(&mut self, context: &mut Context<L>, mut endpoint: E) -> MuxResult
where
E: Endpoint,
L: ListenerHandler + L7ListenerHandler,
{
// No `arm_timeout()` here. `writable()` reaches this function on every
// proxying-state pass, including one whose only output was the PING or
// SETTINGS acknowledgement `flush_pending_control_frames` drained a few
// statements earlier, so arming at pass entry renewed the session for a
// peer that had sent nothing but keepalives. The write side's share of
// LIFECYCLE §9 invariant 9 now lives in `ConnectionH2::handle_write`,
// where the pass knows a real stream's bytes reached the socket.
// Pre-compute byte totals for proportional overhead distribution.
let mut pass = H2WritePass::new(self.core.compute_stream_byte_totals(context));
loop {
match self.core.poll_write_target(
context,
&mut ShellEndpoint::new(&mut endpoint, &self.socket, &self.local_rtt),
&mut pass,
) {
H2WriteTarget::Done(result) => return result,
H2WriteTarget::Finalize {
socket_write,
bytes_written,
} => {
let tls_wants_write = self.tls_wants_write();
match self.core.finalize_write(
tls_wants_write,
socket_write,
bytes_written,
context,
) {
H2FinalizeTarget::Done(result) => return result,
// The only *action* of the three TLS seams, and the
// one step the core cannot take for itself.
H2FinalizeTarget::Flush => {
self.flush_tls_records();
}
// The vectored writes above already attempted this
// pass's flush; a second empty-buffer write would be a
// syscall for nothing.
H2FinalizeTarget::SkipFlush => {}
}
// A fresh query, not the binding above: this one asks
// whether the step between them landed.
let tls_wants_write = self.tls_wants_write();
return self.core.finalize_write_after_flush(tls_wants_write);
}
H2WriteTarget::Transmit { stream_id } => {
// Gather / write / measure / confirm. The gather borrows
// the queued output and `kawa.storage`, and hands back
// descriptors with an extended lifetime; `confirm`
// discharges that obligation before the consume.
//
// SAFETY: neither the output queue nor `kawa` is dropped
// or mutated between this call and the `confirm` below,
// and that `confirm` clears `self.io_slices` before the
// `Kawa::consume` which may relocate `kawa.storage`. The
// only things entered while the descriptors are live are
// the vectored write, which reborrows them for the
// duration of the call and cannot retain them, and
// `frame_tail`, which only reads them.
let (prefix, offered) = unsafe {
self.core
.gather_transmit(context, stream_id, &mut self.io_slices)
};
let (size, status) = self.socket.socket_write_vectored(&self.io_slices);
self.record_write(status);
debug_assert!(
size <= offered,
"the socket reported {size} bytes written for an offer of {offered}"
);
let stream_size = size.saturating_sub(prefix);
// A write that took everything cut nothing: the walk is
// only for a partial write.
let tail = if size < offered {
h2_transmit::frame_tail(
&self.io_slices[usize::from(prefix > 0)..],
stream_size,
)
} else {
0
};
let kawa = self.core.write_buffer(context, stream_id);
h2_transmit::confirm(kawa, &mut self.io_slices, stream_size);
self.core.handle_write(
context,
stream_id,
H2Written {
size,
from_output: size - stream_size,
tail,
},
status,
&mut pass,
);
}
}
}
}
}
#[cfg(test)]
pub(super) mod tests {
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use super::*;
use crate::{
pool::Pool,
protocol::{
kawa_h1::editor::HttpContext,
mux::{
BackendChange, BackendDelta, Connection,
buffer_source::PoolBufferSource,
connection::EndpointClient,
router::Router,
test_support::{TestListener, connected_socket, test_context},
},
},
socket::SessionTcpStream,
};
// ── H2FloodDetector / H2FloodViolation ─────────────────────────────
//
// H2FloodDetector's own unit tests (threshold trips, half-decay,
// lifetime ceilings, metric-key uniqueness) moved to
// `h2_flood_detector.rs`'s `#[cfg(test)] mod tests` alongside the type —
// its fields are private to that module now, matching `HpackState`,
// `H2FlowControl` and `H2StreamTable`. The old test comparing
// default-constructed vs. explicitly-constructed detectors did not
// move: it compared `H2FloodDetector::default()` against
// `H2FloodDetector::new(...)`, and this extraction removed `impl Default
// for H2FloodDetector` entirely (LIFECYCLE.md invariant 20's last
// self-sampling-clock exception), so there is no second constructor left
// for it to compare against.
// ── Prioriser / scheduler ───────────────────────────────────────────
//
// `Prioriser`'s own unit tests (defaults, the RFC 9218 urgency clamp,
// the `MAX_PRIORITIES` cap, the standalone-PRIORITY acceptance filter,
// the same-urgency incremental rotation) moved to `h2_scheduler.rs`'s
// `#[cfg(test)] mod tests` alongside the type — its fields are private
// to that module now, matching `HpackState`, `H2FlowControl`,
// `H2StreamTable` and `H2FloodDetector`. The pass-scoped scheduling
// tests that used to re-implement `ready_incremental_by_urgency`'s
// arithmetic inline in the test body moved there too, and now drive
// `ReadyIncrementalCensus`'s production methods instead of a copy of
// them.
// ── H2FlowControl ───────────────────────────────────────────────────
//
// H2FlowControl's own unit tests (initial state, coalescing, saturation,
// negative window, the ordering-determinism regression test) moved to
// `h2_flow_control.rs`'s `#[cfg(test)] mod tests` alongside the type —
// its fields are private to that module now, matching `HpackState`.
// ── H2FloodConfig ───────────────────────────────────────────────────
//
// Moved to `h2_flood_detector.rs`'s `#[cfg(test)] mod tests` alongside
// `H2FloodConfig`, `H2FloodViolation` and `H2FloodDetector`.
// ── distribute_overhead ─────────────────────────────────────────────
#[test]
fn test_distribute_overhead_proportional() {
let mut metrics = SessionMetrics::new(None);
let mut overhead_bin = 1000;
let mut overhead_bout = 500;
// Stream transferred 60% of total bytes (not last stream)
distribute_overhead(
&mut metrics,
&mut overhead_bin,
&mut overhead_bout,
(600, 300), // stream_bytes
(1000, 500), // total_bytes
2, // active_streams
false, // is_last_stream
);
assert_eq!(metrics.bin, 600); // 60% of 1000
assert_eq!(metrics.bout, 300); // 60% of 500
assert_eq!(overhead_bin, 400); // 1000 - 600
assert_eq!(overhead_bout, 200); // 500 - 300
}
#[test]
fn test_distribute_overhead_even_split_when_no_bytes() {
let mut metrics = SessionMetrics::new(None);
let mut overhead_bin = 100;
let mut overhead_bout = 200;
// No bytes transferred -> even distribution (not last stream)
distribute_overhead(
&mut metrics,
&mut overhead_bin,
&mut overhead_bout,
(0, 0), // stream_bytes
(0, 0), // total_bytes
4, // active_streams
false, // is_last_stream
);
assert_eq!(metrics.bin, 25); // 100 / 4
assert_eq!(metrics.bout, 50); // 200 / 4
assert_eq!(overhead_bin, 75);
assert_eq!(overhead_bout, 150);
}
#[test]
fn test_distribute_overhead_clamps_to_remaining() {
let mut metrics = SessionMetrics::new(None);
let mut overhead_bin = 10;
let mut overhead_bout = 10;
// Stream claims 100% of bytes but overhead is small (last stream)
distribute_overhead(
&mut metrics,
&mut overhead_bin,
&mut overhead_bout,
(1000, 1000), // stream_bytes
(1000, 1000), // total_bytes
1, // active_streams
true, // is_last_stream
);
assert_eq!(metrics.bin, 10);
assert_eq!(metrics.bout, 10);
assert_eq!(overhead_bin, 0);
assert_eq!(overhead_bout, 0);
}
#[test]
fn test_distribute_overhead_zero_active_streams() {
let mut metrics = SessionMetrics::new(None);
let mut overhead_bin = 100;
let mut overhead_bout = 100;
// 0 active streams (edge case) — last stream gets all remainder
distribute_overhead(
&mut metrics,
&mut overhead_bin,
&mut overhead_bout,
(0, 0),
(0, 0),
0,
true,
);
assert_eq!(metrics.bin, 100); // last stream gets all remaining
assert_eq!(metrics.bout, 100);
assert_eq!(overhead_bin, 0);
assert_eq!(overhead_bout, 0);
}
#[test]
fn test_distribute_overhead_last_stream_gets_remainder() {
let mut metrics1 = SessionMetrics::new(None);
let mut metrics2 = SessionMetrics::new(None);
let mut overhead_bin = 120;
let mut overhead_bout = 120;
// First stream (not last): gets proportional share
distribute_overhead(
&mut metrics1,
&mut overhead_bin,
&mut overhead_bout,
(100, 100), // stream_bytes
(300, 300), // total_bytes
3, // active_streams
false, // is_last_stream
);
let remaining_bin = overhead_bin;
let remaining_bout = overhead_bout;
// Last stream: gets ALL remaining overhead (no rounding loss)
distribute_overhead(
&mut metrics2,
&mut overhead_bin,
&mut overhead_bout,
(100, 100), // stream_bytes
(300, 300), // total_bytes
3, // active_streams
true, // is_last_stream
);
assert_eq!(metrics2.bin, remaining_bin);
assert_eq!(metrics2.bout, remaining_bout);
assert_eq!(overhead_bin, 0, "no remainder bytes should be lost");
assert_eq!(overhead_bout, 0, "no remainder bytes should be lost");
}
// ── H2FlowControl (additional edge cases) ─────────────────────────
#[test]
fn test_flow_control_queue_window_update_cap() {
// Verify DEFAULT_MAX_PENDING_WINDOW_UPDATES reflects 1 + 4*MAX_CONCURRENT_STREAMS
assert_eq!(DEFAULT_MAX_PENDING_WINDOW_UPDATES, 1 + 100 * 4);
// Simulate queue reaching capacity
let cap = DEFAULT_MAX_PENDING_WINDOW_UPDATES;
let mut updates: HashMap<u32, u32> = HashMap::new();
for i in 0..cap as u32 {
updates.insert(i, 1000);
}
assert_eq!(updates.len(), cap);
// A new stream ID beyond capacity should be rejected
let next_stream = cap as u32;
let at_cap = updates.len() >= cap;
assert!(at_cap);
assert!(!updates.contains_key(&next_stream));
// Verify custom max_concurrent_streams produces proportional cap
let custom_cap = 1 + 500_usize * 4;
assert_eq!(custom_cap, 2001);
}
#[test]
fn test_h2_connection_config_defaults() {
let config = H2ConnectionConfig::default();
assert_eq!(config.initial_connection_window, ENLARGED_CONNECTION_WINDOW);
assert_eq!(
config.max_concurrent_streams,
DEFAULT_MAX_CONCURRENT_STREAMS
);
assert_eq!(config.stream_shrink_ratio, 2);
}
#[test]
fn test_h2_connection_config_clamp_window_lower_bound() {
// Below minimum: clamped to DEFAULT_INITIAL_WINDOW_SIZE (65535)
let config = H2ConnectionConfig::new(100, 100, 2);
assert_eq!(
config.initial_connection_window,
DEFAULT_INITIAL_WINDOW_SIZE
);
}
#[test]
fn test_h2_connection_config_clamp_window_upper_bound() {
// Above maximum: clamped to FLOW_CONTROL_MAX_WINDOW (2^31-1)
let config = H2ConnectionConfig::new(u32::MAX, 100, 2);
assert_eq!(config.initial_connection_window, FLOW_CONTROL_MAX_WINDOW);
}
#[test]
fn test_h2_connection_config_clamp_window_exact_minimum() {
// Exactly minimum: no clamping, no zero-increment WINDOW_UPDATE risk
let config = H2ConnectionConfig::new(DEFAULT_INITIAL_WINDOW_SIZE, 100, 2);
assert_eq!(
config.initial_connection_window,
DEFAULT_INITIAL_WINDOW_SIZE
);
// Increment to send would be 0 — the code guards this with `if increment > 0`
let increment = config
.initial_connection_window
.saturating_sub(DEFAULT_INITIAL_WINDOW_SIZE);
assert_eq!(increment, 0);
}
#[test]
fn test_h2_connection_config_clamp_shrink_ratio() {
// Below minimum: clamped to 2 (1 would defeat recycling)
let config = H2ConnectionConfig::new(ENLARGED_CONNECTION_WINDOW, 100, 0);
assert_eq!(config.stream_shrink_ratio, 2);
let config = H2ConnectionConfig::new(ENLARGED_CONNECTION_WINDOW, 100, 1);
assert_eq!(config.stream_shrink_ratio, 2);
}
#[test]
fn test_h2_connection_config_clamp_concurrent_streams() {
let config = H2ConnectionConfig::new(ENLARGED_CONNECTION_WINDOW, 0, 2);
assert_eq!(config.max_concurrent_streams, 1);
}
#[test]
fn test_h2_connection_config_from_optional_uses_defaults() {
let config = H2ConnectionConfig::from_optional(None, None, None);
let defaults = H2ConnectionConfig::default();
assert_eq!(config, defaults);
}
#[test]
fn test_h2_connection_config_from_optional_overrides() {
let config = H2ConnectionConfig::from_optional(Some(2_000_000), Some(500), Some(4));
assert_eq!(config.initial_connection_window, 2_000_000);
assert_eq!(config.max_concurrent_streams, 500);
assert_eq!(config.stream_shrink_ratio, 4);
}
// The old settings-change-negative-window test was removed here, not
// moved: its docstring premise was wrong. `update_initial_window_size`
// (below) only ever adjusts *per-stream* windows on a
// SETTINGS_INITIAL_WINDOW_SIZE change, never the connection-level
// `flow_control.window` that test constructed and mutated directly — so
// it never exercised a real code path. `h2_flow_control.rs`'s test
// `consume_send_window_saturates_at_i32_min_without_wrapping` covers a
// genuinely different, previously-untested boundary of the same
// `consume_send_window` API (the `saturating_sub` floor at `i32::MIN`);
// it does not restore the deleted test's coverage, because the deleted
// test covered nothing real.
#[test]
fn test_flow_control_coalesce_saturates_at_max_increment() {
let max_increment = i32::MAX as u32;
let mut updates: HashMap<u32, u32> = HashMap::new();
// Insert at max and try to coalesce more
updates.insert(1, max_increment);
if let Some(existing) = updates.get_mut(&1) {
*existing = existing.saturating_add(1000).min(max_increment);
}
assert_eq!(*updates.get(&1).unwrap(), max_increment);
}
// ── H2FloodConfig (additional) ───────────────────────────────────
//
// Moved to `h2_flood_detector.rs`'s `#[cfg(test)] mod tests`
// (`test_flood_config_default_matches_constants`, `test_flood_config_equality`).
// ── distribute_overhead (additional edge cases) ───────────────────
#[test]
fn test_distribute_overhead_asymmetric_in_out() {
let mut metrics = SessionMetrics::new(None);
let mut overhead_bin = 1000;
let mut overhead_bout = 1000;
// Stream transferred 100% inbound, 0% outbound (not last stream)
distribute_overhead(
&mut metrics,
&mut overhead_bin,
&mut overhead_bout,
(500, 0), // stream_bytes
(500, 100), // total_bytes
2, // active_streams
false, // is_last_stream
);
assert_eq!(metrics.bin, 1000); // 100% of inbound overhead
assert_eq!(metrics.bout, 0); // 0% of outbound overhead
assert_eq!(overhead_bin, 0);
assert_eq!(overhead_bout, 1000);
}
#[test]
fn test_distribute_overhead_many_streams_accumulate() {
let mut metrics = SessionMetrics::new(None);
let mut overhead_bin = 120;
let mut overhead_bout = 120;
// Three equal streams, each calling distribute_overhead.
// With is_last_stream on the third call, the last stream gets all
// remaining overhead, so no rounding loss occurs.
// call 1: 120 * 100/300 = 40 -> remaining 80
// call 2: 80 * 100/300 = 26 -> remaining 54
// call 3: last stream gets all remaining = 54
// Total distributed: 40 + 26 + 54 = 120 (no loss)
for i in 0..3 {
distribute_overhead(
&mut metrics,
&mut overhead_bin,
&mut overhead_bout,
(100, 100), // stream_bytes
(300, 300), // total_bytes
3, // active_streams
i == 2, // is_last_stream on final call
);
}
assert_eq!(metrics.bin, 120);
assert_eq!(metrics.bout, 120);
// No rounding residual — last stream absorbed the remainder
assert_eq!(overhead_bin, 0);
assert_eq!(overhead_bout, 0);
}
// ── Hex chunk formatting ────────────────────────────────────────────
/// Verify that the Vec<u8> + write!() hex formatting used in
/// handle_data_frame produces output identical to format!("{:x}").
#[test]
fn test_hex_chunk_length_formatting() {
use std::io::Write as _;
let cases: &[(usize, &[u8])] = &[
(1, b"1"),
(15, b"f"),
(16, b"10"),
(255, b"ff"),
(256, b"100"),
(4096, b"1000"),
(65535, b"ffff"),
(65536, b"10000"),
];
for &(payload_len, expected) in cases {
let mut buf = Vec::with_capacity(16);
let _ = write!(buf, "{payload_len:x}");
assert_eq!(
buf, expected,
"hex formatting mismatch for payload_len={payload_len}"
);
}
// usize::MAX tested separately to avoid temporary lifetime issue
let max_expected = format!("{:x}", usize::MAX);
let mut buf = Vec::with_capacity(16);
let _ = write!(buf, "{:x}", usize::MAX);
assert_eq!(buf, max_expected.as_bytes());
}
// ── Stream-ID allocation / exhaustion ──────────────────────────────────
/// A fresh client connection starts with `last_stream_id == 0`. The first
/// call MUST issue stream `1` (odd, RFC 9113 §5.1.1) and advance the
/// watermark to `2`.
#[test]
fn test_next_stream_id_client_first_allocation() {
let (issued, next) = next_stream_id(0, true).expect("fresh client must allocate");
assert_eq!(issued, 1);
assert_eq!(next, 2);
}
/// Client allocation yields strictly increasing odd identifiers
/// (1, 3, 5, ...) as required by RFC 9113 §5.1.1.
#[test]
fn test_next_stream_id_client_sequence_is_odd_and_monotonic() {
let mut last = 0u32;
let mut issued_ids = Vec::with_capacity(8);
for _ in 0..8 {
let (id, next) = next_stream_id(last, true).expect("unexhausted");
assert_eq!(id & 1, 1, "client stream ids must be odd (RFC 9113 §5.1.1)");
assert!(issued_ids.last().is_none_or(|prev: &u32| id > *prev));
issued_ids.push(id);
last = next;
}
assert_eq!(issued_ids, vec![1, 3, 5, 7, 9, 11, 13, 15]);
}
/// Server-side allocation yields even identifiers. The helper
/// convention is `watermark - 2` for server, `watermark - 1` for client,
/// so both sides share the same monotonically-increasing even watermark.
/// Sōzu never server-pushes, but the helper must be symmetric so push
/// could be enabled without a regression.
#[test]
fn test_next_stream_id_server_is_even() {
// `last = 2` means the most recent allocation advanced the watermark
// to 2; server then issues `2 - 2 = 0`. This is an artefact of the
// shared watermark and only matters in tests — server never uses it.
let (issued, next) = next_stream_id(2, false).expect("server allocation");
assert_eq!(issued & 1, 0, "server stream ids must be even");
assert_eq!(next, 4);
assert_eq!(issued, 2);
let (issued, next) = next_stream_id(next, false).expect("second slot");
assert_eq!(issued, 4);
assert_eq!(issued & 1, 0);
assert_eq!(next, 6);
}
/// The last client-issuable odd stream ID is `STREAM_ID_MAX = 0x7FFF_FFFF`.
/// To issue it the watermark must advance to `STREAM_ID_MAX + 1 = 2³¹`;
/// the caller therefore supplies `last = STREAM_ID_MAX - 1 = 0x7FFF_FFFE`.
/// That call MUST succeed and return the max ID; the post-call watermark
/// sits at `2³¹`, which is the sentinel that makes the next call fail.
#[test]
fn test_next_stream_id_client_final_slot_allocates() {
let last = STREAM_ID_MAX - 1;
let (issued, next) = next_stream_id(last, true).expect("final slot still allocates");
assert_eq!(issued, STREAM_ID_MAX);
assert_eq!(next, STREAM_ID_MAX + 1);
// And the very next call MUST refuse rather than wrap.
assert!(next_stream_id(next, true).is_none());
}
/// Exhaustion case: once the client has issued stream ID `STREAM_ID_MAX`,
/// the watermark sits at `STREAM_ID_MAX + 1`. The next request MUST return
/// `None` — without this guard the helper would issue `STREAM_ID_MAX + 2`
/// (wrapped down to an even id), which would (a) use the reserved
/// high bit and (b) violate the odd-parity invariant for client streams.
#[test]
fn test_next_stream_id_client_exhausted_returns_none() {
let last = STREAM_ID_MAX + 1;
assert!(next_stream_id(last, true).is_none());
}
/// Exhaustion via `checked_add` saturation: defence in depth in case a
/// caller jumps `last_stream_id` close to `u32::MAX`. The helper must
/// not panic nor overflow — it must return `None`.
#[test]
fn test_next_stream_id_saturates_near_u32_max() {
assert!(next_stream_id(u32::MAX, true).is_none());
assert!(next_stream_id(u32::MAX - 1, true).is_none());
}
/// Server-side exhaustion: same guard, even-parity identifier space.
#[test]
fn test_next_stream_id_server_exhausted_returns_none() {
let last = STREAM_ID_MAX + 1;
assert!(next_stream_id(last, false).is_none());
}
/// Regression guard: the helper must never issue a stream ID that
/// exceeds `STREAM_ID_MAX` for either side, no matter where the
/// watermark sits. This walks every value in a neighbourhood of the
/// boundary to rule out off-by-one errors.
#[test]
fn test_next_stream_id_never_exceeds_stream_id_max() {
for last in (STREAM_ID_MAX - 4)..=(STREAM_ID_MAX + 4) {
for is_client in [true, false] {
if let Some((issued, next)) = next_stream_id(last, is_client) {
assert!(
issued <= STREAM_ID_MAX,
"issued id {issued} exceeds STREAM_ID_MAX (last={last}, is_client={is_client})"
);
// `next` is the post-allocation watermark and may sit at
// STREAM_ID_MAX + 1 — the very next call must then return None.
if next > STREAM_ID_MAX {
assert!(
next_stream_id(next, is_client).is_none(),
"second call after final slot must report exhaustion"
);
}
}
}
}
}
/// The helper's `is_client` flag must cleanly split the ID space so that
/// a client and a server peered on the same connection cannot collide.
/// Given the same `last_stream_id`, the two parities must differ by 1.
#[test]
fn test_next_stream_id_client_server_parities_disjoint() {
for last in [0u32, 2, 4, 10, 100, 1_000_000, STREAM_ID_MAX - 3] {
let (client_id, _) = next_stream_id(last, true).unwrap();
let (server_id, _) = next_stream_id(last, false).unwrap();
assert_eq!(client_id & 1, 1);
assert_eq!(server_id & 1, 0);
assert_eq!(client_id.abs_diff(server_id), 1);
}
}
// ── LIFECYCLE §9 invariant 16: any_stream_id_matches ─────────────────
//
// Covers the iteration dispatch used by `any_stream_has_pending_back`.
// Testing the probe directly against a synthetic closure keeps the
// tests independent of the full `Stream` fixture (which requires a
// `Pool` and a fully-built `HttpContext`).
#[test]
fn test_any_stream_id_matches_empty_map_is_false() {
let streams: BTreeMap<StreamId, GlobalStreamId> = BTreeMap::new();
assert!(!any_stream_id_matches(&streams, |_| true));
}
#[test]
fn test_any_stream_id_matches_all_probe_false_is_false() {
let mut streams: BTreeMap<StreamId, GlobalStreamId> = BTreeMap::new();
streams.insert(1, 0);
streams.insert(3, 1);
streams.insert(5, 2);
assert!(!any_stream_id_matches(&streams, |_| false));
}
#[test]
fn test_any_stream_id_matches_any_probe_true_is_true() {
let mut streams: BTreeMap<StreamId, GlobalStreamId> = BTreeMap::new();
streams.insert(1, 0);
streams.insert(3, 1);
streams.insert(5, 2);
// Probe is true only for GlobalStreamId == 1 (i.e. StreamId 3).
assert!(any_stream_id_matches(&streams, |gid| gid == 1));
}
#[test]
fn test_any_stream_id_matches_single_entry() {
let mut streams: BTreeMap<StreamId, GlobalStreamId> = BTreeMap::new();
streams.insert(42, 7);
assert!(any_stream_id_matches(&streams, |gid| gid == 7));
assert!(!any_stream_id_matches(&streams, |gid| gid == 8));
}
#[test]
fn test_any_stream_id_matches_short_circuits() {
let mut streams: BTreeMap<StreamId, GlobalStreamId> = BTreeMap::new();
streams.insert(1, 0);
streams.insert(3, 1);
streams.insert(5, 2);
streams.insert(7, 3);
let mut calls = 0usize;
let result = any_stream_id_matches(&streams, |_| {
calls += 1;
true
});
assert!(result);
// `Iterator::any` short-circuits on the first `true` — so the probe
// must fire at most once in this construction.
assert_eq!(calls, 1);
}
// ── cumulative-stall budget decision (fc_stall_budget_decision) ──
#[test]
fn test_fc_stall_budget_open_window_always_clears() {
// A genuinely open send window is a real un-stall, regardless of prior
// accumulated progress or this pass's drain.
assert_eq!(
fc_stall_budget_decision(false, 0, None),
FcStallAction::Clear
);
assert_eq!(
fc_stall_budget_decision(false, 1, Some(5)),
FcStallAction::Clear
);
assert_eq!(
fc_stall_budget_decision(false, i32::MAX, Some(FC_STALL_CLEAR_FLOOR)),
FcStallAction::Clear
);
}
#[test]
fn test_fc_stall_budget_blocked_arms_and_accumulates() {
// First blocked pass arms with this pass's drain.
assert_eq!(
fc_stall_budget_decision(true, 1, None),
FcStallAction::Arm { progress: 1 }
);
// A blocked pass with no drain keeps the accumulator unchanged, so the
// deadline keeps aging (a window-0 stall makes consumed == 0).
assert_eq!(
fc_stall_budget_decision(true, 0, Some(42)),
FcStallAction::Arm { progress: 42 }
);
// Negative `consumed` is clamped to 0 (defensive; converter.window only
// shrinks, so consumed is >= 0 in practice).
assert_eq!(
fc_stall_budget_decision(true, -10, Some(7)),
FcStallAction::Arm { progress: 7 }
);
}
#[test]
fn test_fc_stall_budget_floor_clears() {
// Reaching the floor in a single pass (a full DATA frame of real
// delivery) clears the deadline.
assert_eq!(
fc_stall_budget_decision(true, FC_STALL_CLEAR_FLOOR as i32, None),
FcStallAction::Clear
);
// Exactly one byte below the floor still arms.
assert_eq!(
fc_stall_budget_decision(true, (FC_STALL_CLEAR_FLOOR - 1) as i32, None),
FcStallAction::Arm {
progress: FC_STALL_CLEAR_FLOOR - 1
}
);
// Prior progress plus this pass crossing the floor clears.
assert_eq!(
fc_stall_budget_decision(true, 1, Some(FC_STALL_CLEAR_FLOOR - 1)),
FcStallAction::Clear
);
}
#[test]
fn test_fc_stall_budget_wu_drip_ages_until_floor() {
// The WINDOW_UPDATE(+1) closure: a 1-byte-per-pass drip must keep the
// deadline armed (aging) for the whole run up to the floor and only
// clear on the pass that reaches it — so a drip granting < floor bytes
// per idle period is reaped, never kept alive. This is the unit-level
// proof that the budget closes the WINDOW_UPDATE-drip vector.
let mut progress: Option<usize> = None;
for pass in 1..FC_STALL_CLEAR_FLOOR {
match fc_stall_budget_decision(true, 1, progress) {
FcStallAction::Arm { progress: p } => {
assert_eq!(p, pass, "drip accumulator off at pass {pass}");
progress = Some(p);
}
FcStallAction::Clear => panic!("drip cleared the deadline early at pass {pass}"),
}
}
// The pass that reaches the floor finally clears.
assert_eq!(
fc_stall_budget_decision(true, 1, progress),
FcStallAction::Clear
);
}
// ── flow-control-stall reaper union (h2_stream_table::collect_timed_out) ──
//
// The two-guard union (bidirectional-silence + flow-control-stall),
// its dedup, its rst_sent/untracked filtering, and its determinism
// (ascending stream_id order — the regression test for this
// extraction's fix) are now unit-tested directly against
// `H2StreamTable::collect_timed_out` in `h2_stream_table.rs`'s own test
// module, without needing a full `ConnectionH2` fixture.
// ── LIFECYCLE §9 invariant 16: any_stream_has_pending_back ───────────
/// Build a minimal `Stream` for invariant-16 probing. Uses the pool
/// plumbing so `back.blocks` / `back.out` exist; every other field is
/// default-valued because the predicate only reads the back buffer.
fn make_stream_for_invariant_16(pool: &Rc<RefCell<Pool>>, session_ulid: Ulid) -> Stream {
let http_ctx = HttpContext {
keep_alive_backend: true,
keep_alive_frontend: true,
sticky_session_found: None,
method: None,
authority: None,
path: None,
status: None,
reason: None,
user_agent: None,
x_request_id: None,
xff_chain: None,
#[cfg(feature = "opentelemetry")]
otel: None,
closing: false,
session_id: session_ulid,
id: Ulid::generate(),
backend_id: None,
cluster_id: None,
cluster_metrics_incarnation: Default::default(),
affinity_key: None,
protocol: Protocol::HTTPS,
public_address: "127.0.0.1:0".parse().unwrap(),
session_address: None,
sticky_name: String::new(),
sticky_session: None,
backend_address: None,
tls_server_name: None,
tls_cert_names: None,
strict_sni_binding: false,
elide_x_real_ip: false,
send_x_real_ip: false,
forwarded_headers: sozu_command_lib::proto::command::ForwardedHeaders::Both,
max_trailer_fields: 128,
trailer_fields: 0,
tls_version: None,
tls_cipher: None,
tls_alpn: None,
sozu_id_header: String::from("Sozu-Id"),
redirect_location: None,
www_authenticate: None,
original_authority: None,
headers_response: Vec::new(),
retry_after_seconds: None,
frontend_redirect_template: None,
redirect_status: None,
tags: None,
forwarding_hop: None,
access_log_message: None,
backends_unavailable: false,
};
Stream::new(
&mut PoolBufferSource::new(Rc::downgrade(pool)),
http_ctx,
crate::protocol::mux::test_support::test_answers(),
65_535,
)
.expect("pool should have capacity for two buffers")
}
fn make_pool_for_invariant_16() -> Rc<RefCell<Pool>> {
// Two buffer slots per stream (front + back), ten stream slots is
// plenty for the tests below.
Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)))
}
/// What `handle_data_frame` does with the DATA payload of a backend
/// response: a 204 or a 304 cannot carry content and is reset, even to
/// HEAD; a response to HEAD only SHOULD NOT and its payload is
/// discarded (RFC 9110 §6.4.1, §9.3.2, §15.3.5, §15.4.5).
#[test]
fn a_backend_response_content_is_forbidden_for_204_and_304_and_discarded_for_head() {
use crate::protocol::kawa_h1::parser::Method;
let pool = make_pool_for_invariant_16();
let mut stream = make_stream_for_invariant_16(&pool, Ulid::generate());
let cases = [
(
Some(Method::Head),
Some(200),
BackendResponseContent::Discarded,
),
(
Some(Method::Head),
Some(204),
BackendResponseContent::Forbidden,
),
(
Some(Method::Get),
Some(204),
BackendResponseContent::Forbidden,
),
(
Some(Method::Get),
Some(304),
BackendResponseContent::Forbidden,
),
(
Some(Method::Get),
Some(200),
BackendResponseContent::Forwarded,
),
(
Some(Method::Post),
Some(205),
BackendResponseContent::Forwarded,
),
(None, None, BackendResponseContent::Forwarded),
];
for (method, status, expected) in cases {
stream.context.method = method.clone();
stream.context.status = status;
assert_eq!(
ConnectionH2::backend_response_content(&stream.context),
expected,
"{method:?} {status:?}"
);
}
}
#[test]
fn test_any_stream_has_pending_back_empty_map_is_false() {
let pool = make_pool_for_invariant_16();
let ulid = Ulid::generate();
let streams_map: BTreeMap<StreamId, GlobalStreamId> = BTreeMap::new();
let context_streams = vec![make_stream_for_invariant_16(&pool, ulid)];
assert!(!any_stream_has_pending_back(&streams_map, &context_streams));
}
#[test]
fn test_any_stream_has_pending_back_all_drained_is_false() {
let pool = make_pool_for_invariant_16();
let ulid = Ulid::generate();
let context_streams = vec![
make_stream_for_invariant_16(&pool, ulid),
make_stream_for_invariant_16(&pool, ulid),
];
let mut streams_map: BTreeMap<StreamId, GlobalStreamId> = BTreeMap::new();
streams_map.insert(1, 0);
streams_map.insert(3, 1);
// Both freshly-built streams have empty back.out and back.blocks
// (Kawa::new starts with empty deques).
assert!(!any_stream_has_pending_back(&streams_map, &context_streams));
}
#[test]
fn test_any_stream_has_pending_back_unknown_gid_is_false() {
// LIFECYCLE invariant 16 defence-in-depth: an unknown
// `GlobalStreamId` during a stream-removal race must not panic;
// `.get()` must short-circuit to `false`.
let pool = make_pool_for_invariant_16();
let ulid = Ulid::generate();
let context_streams = vec![make_stream_for_invariant_16(&pool, ulid)];
let mut streams_map: BTreeMap<StreamId, GlobalStreamId> = BTreeMap::new();
// GlobalStreamId 42 is out of range for the 1-element slice above.
streams_map.insert(7, 42);
assert!(!any_stream_has_pending_back(&streams_map, &context_streams));
}
#[test]
fn test_any_stream_has_pending_back_with_pending_blocks_is_true() {
let pool = make_pool_for_invariant_16();
let ulid = Ulid::generate();
let mut stream = make_stream_for_invariant_16(&pool, ulid);
// Push one dummy block — any Block variant is fine; the predicate
// only checks `blocks.is_empty()`.
stream.back.blocks.push_back(kawa::Block::StatusLine);
let mut streams_map: BTreeMap<StreamId, GlobalStreamId> = BTreeMap::new();
streams_map.insert(1, 0);
assert!(any_stream_has_pending_back(&streams_map, &[stream]));
}
#[test]
fn test_any_stream_has_pending_back_with_pending_out_is_true() {
let pool = make_pool_for_invariant_16();
let ulid = Ulid::generate();
let mut stream = make_stream_for_invariant_16(&pool, ulid);
// Non-empty out buffer with no blocks.
stream
.back
.out
.push_back(kawa::OutBlock::Store(kawa::Store::Static(b"partial frame")));
let mut streams_map: BTreeMap<StreamId, GlobalStreamId> = BTreeMap::new();
streams_map.insert(1, 0);
assert!(any_stream_has_pending_back(&streams_map, &[stream]));
}
// ── Mid-pass ready-incremental census consistency ───────────────────
//
// The three scalar bucket-decrement tests moved to `h2_scheduler.rs`.
// They were named after `ready_incremental_by_urgency`, the local
// `HashMap` this step replaced with `ReadyIncrementalCensus`; no symbol
// of that name exists anywhere in the tree any more.
// They used to re-implement the `saturating_sub` + bucket-scoped
// decrement inline in the test body — a copy of the scheduler's
// arithmetic, which could not catch the scheduler getting it wrong.
// They now call `ReadyIncrementalCensus::note_ineligible` and
// `::incremental_peer_count`, which is the code `write_streams` runs.
// ── h2.streams.ready_incremental.by_urgency aggregate ────────────────
/// Read the process-local `h2.streams.ready_incremental.by_urgency` gauge,
/// treating an absent key as 0. `dump_local_proxy_metrics` is a
/// non-draining filter over the proxy `MetricsMap`, so repeated reads are
/// side-effect free and the key is the raw metric name.
fn ready_incremental_gauge() -> i64 {
use sozu_command::proto::command::filtered_metrics::Inner;
crate::metrics::METRICS.with(|metrics| {
metrics
.borrow_mut()
.dump_local_proxy_metrics()
.get(names::h2::STREAMS_READY_INCREMENTAL_BY_URGENCY)
.and_then(|fm| fm.inner.as_ref())
.and_then(|inner| match inner {
Inner::Gauge(v) => Some(*v as i64),
_ => None,
})
.unwrap_or(0)
})
}
/// A [`BufferSource`] that hands over pooled wire buffers exactly as the
/// real one does but refuses every scratch request.
///
/// It exists to separate the two halves of the contract. Refusing
/// `checkout` is already reachable by sizing a pool, and the H2
/// simulator's exhaustion scenario does exactly that. Refusing `scratch`
/// is reachable ONLY through a caller-supplied source: the proxy's own
/// [`PoolBufferSource`] hands out a fresh `Vec` and never says no. That
/// asymmetry is the point of the trait being caller-implemented rather
/// than a method on `Pool`, so it needs a test that could not be written
/// without it.
struct ScratchStarvedSource(PoolBufferSource);
impl super::super::buffer_source::BufferSource for ScratchStarvedSource {
fn checkout(&mut self) -> Option<crate::pool::Checkout> {
self.0.checkout()
}
fn scratch(&mut self) -> Option<Vec<u8>> {
None
}
}
fn loopback_socket_handle() -> mio::net::TcpStream {
mio::net::TcpStream::connect(
format!("127.0.0.1:{}", crate::testing::provide_port())
.parse()
.expect("loopback address must parse"),
)
.expect("mio connect must return a socket handle")
}
fn connection_from_source(
buffers: &mut dyn super::super::buffer_source::BufferSource,
) -> Option<H2Shell<mio::net::TcpStream>> {
H2Shell::new(
Ulid::generate(),
loopback_socket_handle(),
Position::Server,
buffers,
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, CLIENT_PREFACE_SIZE)),
Ready::READABLE | Ready::HUP | Ready::ERROR,
)
}
/// The HPACK half of the ownership contract is real, not decorative: the
/// codec pair and its scratch buffers are acquired from the connection's
/// buffer source, and a source that refuses one refuses the connection.
///
/// Both arms run against the SAME pool, with enough buffers for the
/// connection's stream-0 checkout. So the only difference between the two
/// outcomes is the source's answer to `scratch`, which is what stops this
/// from passing for the unrelated reason that the pool ran dry.
#[test]
fn a_source_that_refuses_scratch_refuses_the_connection() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
assert!(
connection_from_source(&mut PoolBufferSource::new(Rc::downgrade(&pool))).is_some(),
"the pool has free buffers, so a source that grants scratch must build a connection",
);
assert!(
connection_from_source(&mut ScratchStarvedSource(PoolBufferSource::new(
Rc::downgrade(&pool)
)))
.is_none(),
"a source that refuses the HPACK scratch must refuse the connection: the codec pair \
is under the same ownership contract as the wire buffers, not allocated behind it",
);
}
/// `h2.streams.ready_incremental.by_urgency` is an aggregate across every
/// live H2 connection, so a connection must hand back exactly what it
/// contributed, on every close path. `H2Shell`'s [`Drop`] is the single
/// decrement site; this pins the round trip.
///
/// Before this was folded into `last_gauge_snapshot`, the metric was an
/// absolute `gauge!` set from inside `write_streams`: connection B
/// overwrote connection A's value, nothing was ever released, and the
/// dashboard read "whatever the last writer wrote" instead of a sum.
///
/// `METRICS` is a thread-local, so this asserts the DELTA around one
/// connection's lifetime — robust to any starting value.
///
/// To SEE THIS RED: delete `self.core.release_connection_gauges();`
/// from `impl Drop for H2Shell`, leaving the
/// `self.record_pending_metrics();` below it. The live-delta assertion
/// still passes — the shell still records what the core queued — and the
/// post-drop one fails with `left: 3, right: 0`: the connection's
/// contribution outliving the connection, which is the
/// aggregate drifting upward by 3 for the rest of the worker's life.
#[test]
fn dropping_a_connection_releases_its_ready_incremental_contribution() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
// A non-blocking connect to a (likely unused) loopback port returns a
// real socket handle immediately, regardless of whether the connection
// completes; nothing below reads or writes it.
let socket = mio::net::TcpStream::connect(
format!("127.0.0.1:{}", crate::testing::provide_port())
.parse()
.expect("loopback address must parse"),
)
.expect("mio connect must return a socket handle");
let before = ready_incremental_gauge();
let mut connection = H2Shell::new(
Ulid::generate(),
socket,
Position::Server,
&mut PoolBufferSource::new(Rc::downgrade(&pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, CLIENT_PREFACE_SIZE)),
Ready::READABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection");
// What the tail of `write_streams` writes once the pass's bucket map
// is final, published by the `gauge_connection_state` call that
// follows it — then handed to the aggregator by the shell, which is
// what `H2Shell::settled` does at the end of every real pass.
connection.core.ready_incremental_streams = 3;
connection.core.gauge_connection_state();
connection.record_pending_metrics();
assert_eq!(
ready_incremental_gauge() - before,
3,
"gauge_connection_state must add this connection's ready-incremental \
count to the aggregate as a signed delta"
);
drop(connection);
assert_eq!(
ready_incremental_gauge(),
before,
"Drop must subtract exactly the contribution the connection made, \
returning the aggregate to its prior value"
);
}
/// The core computes the gauge arithmetic and stops there: writing the
/// worker-local `METRICS` aggregator is the shell's step, through
/// [`H2Shell::record_pending_metrics`].
///
/// This is the property that makes the H2 core drivable by a deterministic
/// simulator, which is the whole point of returning the metric rather than
/// emitting it: a driver with no aggregator sees the same
/// [`MetricEvent`]s that production folds into `METRICS`, and can fold
/// them into a shadow model instead. `protocol/udp/`'s core already works
/// this way and `sim/tests/udp_simulation.rs` already reads it that way.
///
/// The sibling test above pins the arithmetic; this one pins *who writes*,
/// which no assertion on the aggregate's value can distinguish — a core
/// that emitted directly and a shell that records what the core queued
/// leave the aggregate identical.
///
/// To SEE THIS RED: in [`ConnectionH2::gauge_connection_state`], put
/// `gauge_add!(names::h2::STREAMS_READY_INCREMENTAL_BY_URGENCY, dr);`
/// back in place of the
/// `self.metric_events.push(MetricEvent::StreamsReadyIncrementalByUrgency(dr));`
/// push. The first assertion then fails with `left: <before + 5>, right:
/// <before>` — the core reaching the aggregator behind the shell's back.
#[test]
fn the_core_queues_gauge_deltas_instead_of_writing_the_metrics_registry() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let before = ready_incremental_gauge();
let mut connection =
connection_from_source(&mut PoolBufferSource::new(Rc::downgrade(&pool)))
.expect("a pool with free buffers must yield an H2 connection");
connection.core.ready_incremental_streams = 5;
connection.core.gauge_connection_state();
assert_eq!(
ready_incremental_gauge(),
before,
"the core must not write METRICS: `gauge_connection_state` queues \
a MetricEvent and the shell decides when it is recorded"
);
let queued: Vec<MetricEvent> = connection.core.drain_metric_events().collect();
assert!(
queued.contains(&MetricEvent::StreamsReadyIncrementalByUrgency(5)),
"the queued events must carry the signed delta the core computed, \
got {queued:?}"
);
assert_eq!(
ready_incremental_gauge(),
before,
"draining is not recording: taking the events out of the core must \
not move the aggregate either"
);
for event in queued {
record_metric(event);
}
assert_eq!(
ready_incremental_gauge() - before,
5,
"recording the drained events must produce exactly the aggregate \
the old in-core `gauge_add!` produced"
);
drop(connection);
assert_eq!(
ready_incremental_gauge(),
before,
"the released snapshot must travel the same queue and rebalance to \
the prior value"
);
}
/// Read a process-local counter by its raw metric key, treating an absent
/// key as 0. Counter sibling of [`ready_incremental_gauge`], and
/// `dump_local_proxy_metrics` is non-draining here too, so repeated reads
/// are side-effect free.
fn proxy_counter(key: &str) -> i64 {
use sozu_command::proto::command::filtered_metrics::Inner;
crate::metrics::METRICS.with(|metrics| {
metrics
.borrow_mut()
.dump_local_proxy_metrics()
.get(key)
.and_then(|fm| fm.inner.as_ref())
.and_then(|inner| match inner {
Inner::Count(v) => Some(*v),
_ => None,
})
.unwrap_or(0)
})
}
/// A counter the core queues is recorded when the entry point that
/// produced it RETURNS — not whenever the connection is eventually
/// dropped.
///
/// This is the property the gauges did not need. A gauge delta recorded
/// late still sums to the same value, so slice one could be correct with a
/// drain only in `settled` and `Drop`. A counter recorded late is a
/// counter attributed to the wrong minute: `LocalDrain` is cumulative and
/// scraped periodically, so deferring one to connection teardown moves it
/// past however many scrapes the connection outlives. `Drop` remains the
/// backstop that stops it being *lost*; this pins that the backstop is not
/// where it normally happens.
///
/// `graceful_goaway` is the cheapest real producer to drive: one public
/// call on a fresh connection reaches `ConnectionH2::send_initial_goaway`,
/// which queues both `MetricEvent::GoAwaySent` and
/// `MetricEvent::GoAwayFrameSent`.
///
/// To SEE THIS RED: delete `self.record_pending_metrics();` from
/// `H2Shell::settled`. Both assertions before `drop` fail with
/// `left: 0, right: 1` — the counters sitting in the core's queue,
/// arriving only when the connection is torn down.
#[test]
fn a_queued_counter_is_recorded_when_its_entry_point_returns() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let sent_before = proxy_counter("h2.goaway.sent.no_error");
let frames_before = proxy_counter(names::h2::FRAMES_TX_GOAWAY);
let mut connection =
connection_from_source(&mut PoolBufferSource::new(Rc::downgrade(&pool)))
.expect("a pool with free buffers must yield an H2 connection");
connection.graceful_goaway(Instant::now());
assert_eq!(
proxy_counter("h2.goaway.sent.no_error") - sent_before,
1,
"the GOAWAY decision must reach METRICS when `graceful_goaway` \
returns, not at teardown"
);
assert_eq!(
proxy_counter(names::h2::FRAMES_TX_GOAWAY) - frames_before,
1,
"so must the frame the serializer actually wrote"
);
}
/// One `MetricEvent::RstStreamSent` records BOTH counters its two macro
/// lines used to record: the frame-type total and the per-error-code
/// breakdown.
///
/// Folding two macro lines into one event is only safe where both were
/// unconditional and adjacent, which is why the GOAWAY pair was NOT folded
/// — `h2.frames.tx.goaway` sits inside `serializer::gen_goaway`'s `Ok`
/// arm while `h2.goaway.sent.*` fires before it, so one event would record
/// a frame on the wire that a serialisation failure never wrote. The RST
/// pair has no such gap, and this pins that folding it kept both counters.
///
/// To SEE THIS RED: delete either line from the
/// `MetricEvent::RstStreamSent` arm of `record_metric`. The surviving
/// assertion passes and the other fails with `left: 0, right: 1` — half a
/// merge, which is exactly what a merged pair can silently become.
#[test]
fn one_rst_stream_sent_event_records_both_of_its_counters() {
let frames_before = proxy_counter(names::h2::FRAMES_TX_RST_STREAM);
let by_code_before = proxy_counter(metric_for_rst_stream_sent(H2Error::Cancel));
record_metric(MetricEvent::RstStreamSent(H2Error::Cancel));
assert_eq!(
proxy_counter(names::h2::FRAMES_TX_RST_STREAM) - frames_before,
1,
"the frame-type total must still tick"
);
assert_eq!(
proxy_counter(metric_for_rst_stream_sent(H2Error::Cancel)) - by_code_before,
1,
"and so must the per-error-code breakdown"
);
}
// ── Clock hygiene: every deadline reads `ConnectionH2::now` ──────────
//
// `Mux` is the only clock sampler; the H2 core reads the snapshot it
// mirrors into `ConnectionH2::now`. A reintroduced `Instant::now()` in
// the core would still "work" in production — it would just be reading a
// second clock — so these two tests advance ONLY the connection's
// snapshot and prove the decision moves with it. Neither sleeps.
// ── TLS backpressure: the GoAway truncation vector (#1454) ──────────
//
// Until this harness existed, NO test anywhere instantiated a
// `ConnectionH2` over a handler whose `socket_wants_write()` could return
// `true`. `mio::net::TcpStream` and `SessionTcpStream` both take the
// trait's `false` default and only `FrontRustls` overrides it, so every
// branch that asks "does rustls still hold records?" was statically dead
// in the test suite — including the one whose own comment calls it the
// primary truncation vector. These are the first tests that branch class
// has ever had against a handler that answers `true`.
/// A `SocketHandler` that models rustls-over-a-blocked-kernel: it holds
/// `pending` records, and each empty-buffer flush drains `drain_per_flush`
/// of them. `drain_per_flush = 0` is a kernel that accepts nothing, which
/// is the backpressure case; a positive value is a kernel that takes them.
///
/// Modelling the state rather than scripting an answer sequence is
/// deliberate: the close path queries `socket_wants_write()` a number of
/// times that depends on which branches it takes, so a positional script
/// would pin the query COUNT instead of the behaviour and would have to be
/// rewritten by anyone who added a query.
struct BackpressuredTlsSocket {
stream: mio::net::TcpStream,
pending: std::cell::Cell<usize>,
drain_per_flush: usize,
flushes: std::cell::Cell<usize>,
/// An empty-buffer flush that leaves records behind answers
/// `WouldBlock`, as `FrontRustls` does when the kernel refused them,
/// instead of `Continue`. Off by default, so every test written
/// before this field is byte-for-byte unaffected.
refuse_flush: bool,
/// Scripted `(size, status)` answers for `socket_write_vectored`,
/// consumed front to back. An EMPTY script — the default, and what
/// every test written before this field had — delegates to the real
/// loopback socket, so those tests are byte-for-byte unaffected.
///
/// The record model above cannot express the pair this scripts.
/// `pending`/`drain_per_flush` answer "does rustls still hold
/// records?"; they say nothing about what a *vectored* write
/// reported, because that method delegated to the kernel and the
/// kernel only ever answers `(0, WouldBlock)` when it blocks.
/// `FrontRustls` does not: `socket_write_vectored` accumulates
/// `buffered_size` from `session.writer().write(..)` — plaintext
/// rustls took off the caller's hands — and separately sets
/// `can_write = false` when `write_tls` hits `WouldBlock` against the
/// kernel (`socket.rs`). The two are independent, so
/// `(size > 0, WouldBlock)` is a shape ONLY the TLS handler returns.
/// That is why the script lives on this fixture rather than on a
/// second one: it is the same handler answering the same question the
/// record model already answers, one layer down.
///
/// Each `size` is a CAP, clamped to what the gather actually offered:
/// a scripted count above the offer would trip the write shell's own
/// `debug_assert!(size <= offered)`, and reddening through a
/// production assertion is the tree noticing rather than the test.
vectored_script: std::collections::VecDeque<(usize, SocketResult)>,
/// Scripted `(size, status)` answers for a NON-EMPTY `socket_write`,
/// consumed front to back exactly like `vectored_script`. An empty
/// script — the default — delegates to the real loopback socket, so
/// every test written before this field is byte-for-byte unaffected.
///
/// The record model cannot express this either. `pending` /
/// `drain_per_flush` answer "does rustls still hold records?", which
/// is the EMPTY-buffer flush question; this one is "did the kernel
/// take the bytes `flush_output_to_socket` handed it?". A loopback
/// socket with default buffers always takes 13 bytes of
/// WINDOW_UPDATE, so the stalled branch of the control-frame drains
/// is unreachable without scripting the answer.
///
/// Each `size` is a CAP, clamped to what the caller actually offered:
/// `flush_output_to_socket` feeds the answer straight to
/// `H2Output::consume`, which asserts it never passes the queue.
write_script: std::collections::VecDeque<(usize, SocketResult)>,
/// How many times `socket_write_vectored` was called, whether the
/// answer came from the script or from the delegated loopback socket.
///
/// Counting BOTH is what makes this a falsifiable witness. Counting
/// only the scripted answers bounds the total by the script's length,
/// so an assertion that the loop did NOT go round again could only
/// ever deviate downwards and could never fail — and the extra
/// delegated rounds are exactly what such an assertion has to be able
/// to see.
vectored_calls: usize,
/// How many NON-EMPTY `socket_write` calls reached this handler. On
/// `FrontRustls` each one ends in its own `write_tls`, so each one is
/// one `writev(2)` on the wire; an empty-buffer flush is counted by
/// `flushes` instead.
writes: usize,
}
impl BackpressuredTlsSocket {
fn new(stream: mio::net::TcpStream, pending: usize, drain_per_flush: usize) -> Self {
Self {
stream,
pending: std::cell::Cell::new(pending),
drain_per_flush,
flushes: std::cell::Cell::new(0),
refuse_flush: false,
vectored_script: std::collections::VecDeque::new(),
write_script: std::collections::VecDeque::new(),
vectored_calls: 0,
writes: 0,
}
}
}
impl SocketHandler for BackpressuredTlsSocket {
fn socket_read(&mut self, buf: &mut [u8]) -> (usize, SocketResult) {
self.stream.socket_read(buf)
}
fn socket_write(&mut self, buf: &[u8]) -> (usize, SocketResult) {
if buf.is_empty() {
self.flushes.set(self.flushes.get() + 1);
let drained = self.drain_per_flush.min(self.pending.get());
self.pending.set(self.pending.get() - drained);
if self.refuse_flush && self.pending.get() > 0 {
return (0, SocketResult::WouldBlock);
}
return (0, SocketResult::Continue);
}
self.writes += 1;
match self.write_script.pop_front() {
Some((cap, status)) => (cap.min(buf.len()), status),
None => self.stream.socket_write(buf),
}
}
fn socket_write_vectored(&mut self, bufs: &[IoSlice]) -> (usize, SocketResult) {
self.vectored_calls += 1;
match self.vectored_script.pop_front() {
Some((cap, status)) => {
let offered: usize = bufs.iter().map(|slice| slice.len()).sum();
(cap.min(offered), status)
}
None => self.stream.socket_write_vectored(bufs),
}
}
/// The override that makes this harness worth having.
fn socket_wants_write(&self) -> bool {
self.pending.get() > 0
}
fn socket_ref(&self) -> &mio::net::TcpStream {
&self.stream
}
fn socket_mut(&mut self) -> &mut mio::net::TcpStream {
&mut self.stream
}
fn peer_addr(&self) -> Option<std::net::SocketAddr> {
mio::net::TcpStream::peer_addr(&self.stream).ok()
}
fn protocol(&self) -> crate::socket::TransportProtocol {
crate::socket::TransportProtocol::Tls1_3
}
fn read_error(&self) {}
fn write_error(&self) {}
}
/// `state` is a parameter rather than a second copy of this fixture: the
/// close decision reads it as `H2State::GoAway` and the write-pass
/// finalization reads it as `H2State::Header`, and both need the same
/// handler underneath.
fn connection_with_backpressure(
pool: &Rc<RefCell<Pool>>,
pending: usize,
drain_per_flush: usize,
state: H2State,
) -> (H2Shell<BackpressuredTlsSocket>, std::net::TcpStream) {
let (socket, peer) = connected_socket();
let mut connection = H2Shell::new(
Ulid::generate(),
BackpressuredTlsSocket::new(socket, pending, drain_per_flush),
Position::Server,
&mut PoolBufferSource::new(Rc::downgrade(pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, CLIENT_PREFACE_SIZE)),
Ready::WRITABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection");
connection.core.state = state;
(connection, peer)
}
/// Records that survive the flush hold the connection open.
///
/// This is the branch whose own comment calls it the primary truncation
/// vector: closing here sends FIN and destroys the records rustls is still
/// holding, which the client reads as a truncated response.
///
/// TO SEE THIS RED: delete the `self.ensure_tls_flushed(tls_wants_write);`
/// call in the `CloseAction::ReArmAndContinue` arm of
/// `ConnectionH2::dispatch_writable_state_after_flush`, which is where
/// `H2Shell::writable`'s `H2State::GoAway` branch settles. The WRITABLE
/// *event* bit is then never re-signalled and this test fails on its OWN
/// assertion, `the WRITABLE event must be re-signalled so the event loop
/// retries the flush`. The recipe deliberately does not swap
/// `TlsFlushPhase::AfterFlush` for `BeforeFlush`: that reddens through the
/// production `unreachable!`, which is someone else's assertion and would
/// pass whatever this test claimed.
#[test]
fn a_flush_that_does_not_drain_keeps_the_connection_open() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
// A kernel that accepts nothing: every flush leaves the records.
let (mut connection, _peer) = connection_with_backpressure(&pool, 2, 0, H2State::GoAway);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
// Premise: the handler really does report buffered records. Without
// this the assertions below would pass against a handler taking the
// trait's `false` default, which is exactly the blind spot #1454 names.
assert!(
connection.socket.socket_wants_write(),
"premise: this harness must report buffered TLS records"
);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
matches!(result, MuxResult::Continue),
"records still buffered: the session must stay open, got {result:?}"
);
// `Continue` alone does not discriminate: `force_disconnect`'s server
// arm also returns it for a live peer with records pending. The re-arm
// path returns from the GoAway arm BEFORE reaching `force_disconnect`,
// so the state is still `GoAway`; the fall-through would have gone
// through `force_disconnect`, which sets `H2State::Error` first.
assert!(
matches!(connection.core.state, H2State::GoAway),
"the GoAway arm must re-arm and return, not fall through to \
force_disconnect, got {:?}",
connection.core.state
);
// Not `readiness.interest`: that bit is this fixture's own argument to
// `ConnectionH2::new` and nothing on this path clears it, so asserting
// on it would assert on the harness. The edge-triggered re-arm is the
// EVENT bit, set by `ensure_tls_flushed` -> `signal_pending_write`.
assert!(
connection.core.readiness.event.is_writable(),
"the WRITABLE event must be re-signalled so the event loop retries \
the flush, got {:?}",
connection.core.readiness
);
// Not `socket_wants_write()`: with `drain_per_flush = 0` that can never
// change, so it is a tautology of the harness no production edit can
// falsify. The flush COUNT is falsifiable — a GoAway arm that skipped
// its own flush would leave it at 1.
assert!(
connection.socket.flushes.get() >= 2,
"the preamble and the GoAway arm must each attempt a flush, got {}",
connection.socket.flushes.get()
);
}
/// A flush the kernel accepts closes within ONE `writable()` call.
///
/// This is the tick-count assertion. The pre-image asked its second
/// question inline, immediately after the flush; a split that returned
/// `Continue` and waited to be called again would still deliver the bytes,
/// but would double the latency of every close under backpressure and
/// would strand the connection against a peer that never re-arms.
///
/// TO SEE THIS RED: in `H2Shell::writable`, add
/// `return MuxResult::Continue;` immediately after the
/// `self.flush_tls_records();` inside the `H2WritableStateTarget::Flush`
/// arm — the flush the `H2State::GoAway` branch asks for, performed and
/// then deferred on. The first call then
/// returns `Continue` and the final assertion fails with `a drained flush
/// must reach the disconnect in the SAME writable call`. Measured: `1115
/// passed; 2 failed`, the sibling being
/// `a_flush_that_does_not_drain_keeps_the_connection_open`, which never
/// reaches its own post-flush answer under the deferral either. Dropping
/// the flush as well would redden the premise at the top of the test
/// instead, with a different message.
#[test]
fn a_flush_that_succeeds_closes_within_one_writable_call() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
// Two records, one drained per flush: the preamble takes one and the
// GoAway arm's own flush takes the other, so the post-flush query is
// the first one that can answer `false`.
let (mut connection, _peer) = connection_with_backpressure(&pool, 2, 1, H2State::GoAway);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert!(
connection.socket.socket_wants_write(),
"premise: this harness must report buffered TLS records"
);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
!connection.socket.socket_wants_write(),
"premise: both flushes landed, so nothing is pending any more"
);
assert!(
connection.socket.flushes.get() >= 2,
"the preamble and the GoAway arm must each attempt a flush, got {}",
connection.socket.flushes.get()
);
assert!(
!matches!(result, MuxResult::Continue),
"a drained flush must reach the disconnect in the SAME writable \
call, not defer it to another tick: got {result:?}"
);
}
/// A GoAway flush the kernel refuses keeps the connection open WITHOUT
/// re-raising WRITABLE: the kernel owes the socket its next edge, and a
/// synthetic one made `Mux::ready_inner` repeat the refused flush on
/// every inner iteration until `MAX_LOOP_ITERATIONS`
/// (sozu-proxy/sozu#1788). Once the kernel drains, the next writable
/// pass flushes the records and reaches the disconnect, so no record is
/// lost on the way to the close.
///
/// TO SEE THIS RED: drop `&& !self.kernel_refused_write` from
/// `ConnectionH2::ensure_tls_flushed`. The refused pass then re-raises
/// the WRITABLE event.
#[test]
fn a_refused_goaway_flush_waits_for_the_kernel_edge_then_closes() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = connection_with_backpressure(&pool, 2, 0, H2State::GoAway);
connection.socket.refuse_flush = true;
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.readiness.event.insert(Ready::WRITABLE);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
matches!(result, MuxResult::Continue),
"records still buffered: the session must stay open, got {result:?}"
);
assert!(
matches!(connection.core.state, H2State::GoAway),
"the GoAway arm must keep the connection, got {:?}",
connection.core.state
);
assert!(
connection.core.readiness.interest.is_writable(),
"WRITABLE interest must stay so the kernel edge is delivered"
);
assert!(
!connection.core.readiness.event.is_writable(),
"a refused flush must leave WRITABLE to the kernel edge, got {:?}",
connection.core.readiness
);
// The peer reads: the kernel takes the records and raises its edge.
connection.socket.drain_per_flush = 2;
connection.core.readiness.event.insert(Ready::WRITABLE);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
!connection.socket.socket_wants_write(),
"the kernel edge must flush the pending records"
);
assert!(
!matches!(result, MuxResult::Continue),
"a drained GoAway flush must reach the disconnect, got {result:?}"
);
}
// ── The write pass's own flush triple (`finalize_write`) ────────────
//
// `ConnectionH2::finalize_write` ends every write pass with the same
// query / flush / query shape the close sites use, and its decision now
// lives beside theirs in `h2_close::finalize_action`. The two tests below
// are the CALLER half — that `h2.rs` performs the step each answer names
// and wires the inputs to the right parameters. The answers themselves are
// enumerated exhaustively in `h2_close`'s own tables, which no socket can
// reach: `SkipFlush`, `Parked` and `RetainPendingBack` are covered there
// only, because reaching them through `writable()` needs a stream carrying
// response bytes through the scheduler and none of this module's fixtures
// builds one. Same boundary `h2_close`'s `force_disconnect` re-arm branch
// already sits on.
/// The pass flushes once of its own and re-arms when records survive it.
///
/// Three records, one drained per flush: `writable`'s preamble takes the
/// first, `finalize_write`'s own flush takes the second, and the third is
/// still pending when the post-flush query runs — so the re-arm branch is
/// reached with the socket genuinely still holding data, not by a fixture
/// that can only answer one way.
///
/// TO SEE THIS RED, either half independently:
/// (a) answer `H2FinalizeTarget::SkipFlush` from the
/// `FinalizeAction::Flush` arm of `finalize_write` (the `SkipFlush`
/// behaviour, which is what a caller that dropped the `socket_write`
/// input would do for every pass). `write_streams` then performs no
/// empty-buffer flush of its own and the flush count assertion fails
/// with `finalize_write must attempt exactly one empty-buffer flush
/// of its own`, `left: 1, right: 2`.
/// (b) empty the `FinalizeAction::ReArm` arm of
/// `finalize_write_after_flush`. The event assertion fails with `the
/// WRITABLE event must be re-signalled`.
/// Measured: `1114 passed; 1 failed` for each half, this test and nothing
/// else. Neither recipe reddens through a production `unreachable!`.
#[test]
fn a_finalized_write_pass_flushes_once_and_re_arms_while_records_survive() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = connection_with_backpressure(&pool, 3, 1, H2State::Header);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert!(
connection.socket.socket_wants_write(),
"premise: this harness must report buffered TLS records"
);
// Not the interest bit: `signal_pending_write` touches `event` only,
// and this fixture hands `Ready::WRITABLE` to `ConnectionH2::new` as
// its INTEREST, so an assertion written against interest would be
// reading the fixture's own argument back and could not see the
// re-arm at all.
assert!(
!connection.core.readiness.event.is_writable(),
"premise: the WRITABLE event must start clear so the re-arm below is the only thing that can set it, got {:?}",
connection.core.readiness
);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
matches!(result, MuxResult::Continue),
"a write pass with records still buffered continues, got {result:?}"
);
assert_eq!(
connection.socket.flushes.get(),
2,
"finalize_write must attempt exactly one empty-buffer flush of its own on top of the preamble's"
);
assert!(
connection.socket.socket_wants_write(),
"premise: one record must survive both flushes, or the post-flush query could only answer one way"
);
assert!(
connection.core.readiness.event.is_writable(),
"the WRITABLE event must be re-signalled so the event loop retries the flush, got {:?}",
connection.core.readiness
);
}
/// A pass that owes nothing withdraws `Ready::WRITABLE` interest.
///
/// This is the other side of the same decision: no TLS records (a plain
/// `mio::net::TcpStream` takes `socket_wants_write()`'s `false` default),
/// no parked `expect_write`, no bytes written, no queued control frame.
/// Forward progress must come from an external trigger, so the connection
/// relinquishes the bit rather than busy-spinning against the dispatcher.
///
/// TO SEE THIS RED: delete `self.readiness.interest.remove(Ready::WRITABLE);`
/// from the `FinalizeAction::Quiesce` arm of `finalize_write`, leaving its
/// debug narration in place. The final assertion fails with `a pass that
/// owes nothing must relinquish WRITABLE interest`. The interest bit is
/// asserted here rather than the event bit because withdrawal is what this
/// branch does, and it is set by the test rather than by the fixture —
/// `test_h2_connection` arms `READABLE | HUP | ERROR` only.
#[test]
fn a_write_pass_that_owes_nothing_withdraws_writable_interest() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection.core.readiness.interest.insert(Ready::WRITABLE);
assert!(
!connection.socket.socket_wants_write(),
"premise: a plain TcpStream holds no TLS records, so the readiness policy is reached at all"
);
assert!(
connection.core.stream_table.expect_write().is_none(),
"premise: no parked partial write, or the policy is suppressed"
);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
matches!(result, MuxResult::Continue),
"an empty write pass continues, got {result:?}"
);
assert!(
!connection.core.readiness.interest.is_writable(),
"a pass that owes nothing must relinquish WRITABLE interest, got {:?}",
connection.core.readiness
);
}
// ── The control-frame drains' own edge-triggered re-arm ─────────────
//
// `ConnectionH2::flush_pending_control_frames` queues the WINDOW_UPDATE
// and RST_STREAM frames behind the ordered output and asks for ONE flush
// (#1604). When that flush stalls, `update_readiness_after_write`
// (`mux/mod.rs`) has just REMOVED the WRITABLE event bit, and the
// `Stalled` answer puts it back when the socket still holds records it
// could not hand to the kernel — unless the kernel refused the write
// (`WouldBlock`): it then owes the socket its next edge, and the re-arm
// would only repeat the refused write (sozu-proxy/sozu#1788,
// `a_refused_goaway_flush_waits_for_the_kernel_edge_then_closes`). The
// two tests below therefore stall the flush on a zero-length write the
// kernel did NOT refuse, the case the re-arm still exists for. Before #1604 each of the two stages had a
// stall tail of its own; the two tests below cover the one that remains.
//
// Until the two tests below existed, nothing in the suite reached either
// stalled branch. Measured by replacing each re-arm with a `panic!` and
// running the whole `sozu-lib` suite against `98745bba`: 1092 passed, 0
// failed, neither panic observed. That is what these tests are for —
// `Readiness::signal_pending_write`'s two `debug_assert!`s only guard a
// path some test actually walks, and a stage that spells the re-arm by
// hand carries no assertion at all.
//
// Neither test can see WHICH spelling a stage uses:
// `readiness.event.insert(Ready::WRITABLE)` and `signal_pending_write()`
// are indistinguishable from outside, by construction — `insert` cannot
// clear a neighbouring bit, so the two are the same state transition.
// What they detect is the re-arm going missing, which strands the
// connection: the serialized frame waits in the output queue and no
// further epoll edge is coming for it.
/// The WINDOW_UPDATE drain re-signals WRITABLE when its flush stalls.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_control_flush`, replace
/// `H2ControlFlushTarget::Stalled` with
/// `H2ControlFlushTarget::Done(MuxResult::Continue)` — the pass still
/// ends on `Continue`, and the only thing lost is the TLS re-arm
/// `H2Shell::writable` performs for `Stalled`. The last assertion fails
/// with `the stalled WINDOW_UPDATE drain must re-signal the WRITABLE
/// event`: `flush_output_to_socket` cleared the bit on the way in and
/// nothing else on this path sets it. Measured 2026-09-27: this test and
/// its RST_STREAM sibling fail, and no other test of the pair's filter.
#[test]
fn a_stalled_window_update_drain_re_signals_the_writable_event() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
// One record rustls still holds and a kernel that accepts nothing, so
// `socket_wants_write()` answers true for the whole test.
let (mut connection, _peer) = connection_with_backpressure(&pool, 1, 0, H2State::Header);
// The output flush stalls on its first round. A zero-length write
// is what `update_readiness_after_write` reads as a stall, and it
// removes the WRITABLE event bit before the drain stage can re-arm.
// `Continue`, not `WouldBlock`: no kernel edge is owed for it.
connection
.socket
.write_script
.push_back((0, SocketResult::Continue));
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert!(
connection.socket.socket_wants_write(),
"premise: this harness must report buffered TLS records, or the \
re-arm is gated off and the assertion below could not fail"
);
// Connection-level replenishment: ordinary housekeeping, queued the
// way `handle_frame` queues it.
connection.core.queue_window_update(0, 65_535);
assert!(
connection.core.stream_table.expect_write().is_none(),
"premise: no parked partial write, or the drain stage is skipped"
);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
matches!(result, MuxResult::Continue),
"a stalled control-frame flush parks the pass and continues, got \
{result:?}"
);
assert!(
!connection.core.output.is_empty(),
"premise: the pass must have taken the STALLED branch of the \
output flush, which leaves the WINDOW_UPDATE queued"
);
assert!(
connection.core.readiness.event.is_writable(),
"the stalled WINDOW_UPDATE drain must re-signal the WRITABLE \
event: under edge-triggered epoll the parked frame has no other \
wake-up, got {:?}",
connection.core.readiness
);
}
/// The RST_STREAM drain re-signals WRITABLE when its flush stalls.
///
/// The sibling of the WINDOW_UPDATE test above, on the stage beneath it.
/// No WINDOW_UPDATE is queued on purpose: that stage returns early, so
/// queueing one would make this test exercise the wrong drain.
///
/// TO SEE THIS RED: the `handle_control_flush` mutation described on the
/// WINDOW_UPDATE test above. The last assertion fails with `the stalled
/// RST_STREAM drain must re-signal the WRITABLE event`. Measured
/// 2026-09-27.
#[test]
fn a_stalled_rst_stream_drain_re_signals_the_writable_event() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = connection_with_backpressure(&pool, 1, 0, H2State::Header);
connection
.socket
.write_script
.push_back((0, SocketResult::Continue));
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert!(
connection.socket.socket_wants_write(),
"premise: this harness must report buffered TLS records, or the \
re-arm is gated off and the assertion below could not fail"
);
// One RST, well under the pending-queue bound, so the overflow check
// above this stage does not escalate to GOAWAY(ENHANCE_YOUR_CALM) and
// swallow the drain.
assert!(
connection
.core
.enqueue_rst(1, H2Error::Cancel, RstOrigin::Local)
.is_none(),
"premise: a single RST must not trip the flood detector"
);
assert!(
connection
.core
.flow_control
.pending_window_updates_is_empty(),
"premise: no queued WINDOW_UPDATE, or the stage above returns \
first and this test exercises the wrong drain"
);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
matches!(result, MuxResult::Continue),
"a stalled control-frame flush parks the pass and continues, got \
{result:?}"
);
assert!(
!connection.core.output.is_empty(),
"premise: the pass must have taken the STALLED branch of the \
output flush, which leaves the RST_STREAM queued"
);
assert!(
connection.core.readiness.event.is_writable(),
"the stalled RST_STREAM drain must re-signal the WRITABLE event: \
under edge-triggered epoll the parked frame has no other wake-up, \
got {:?}",
connection.core.readiness
);
}
/// The kernel-refused sibling of the two drain tests above: the output
/// flush stalls on `(0, WouldBlock)`, and the `Stalled` arm must leave
/// WRITABLE to the kernel's next edge instead of re-raising it. A
/// synthetic edge here made `Mux::ready_inner` repeat the refused flush
/// on every inner iteration (sozu-proxy/sozu#1788).
///
/// TO SEE THIS RED: drop `self.note_write_status(status);` from
/// `ConnectionH2::consume_output_flush`. The refusal is then never
/// recorded and `ensure_tls_flushed` re-raises the event for both drains.
#[test]
fn a_refused_control_frame_drain_leaves_writable_to_the_kernel() {
for drain in ["WINDOW_UPDATE", "RST_STREAM"] {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) =
connection_with_backpressure(&pool, 1, 0, H2State::Header);
connection
.socket
.write_script
.push_back((0, SocketResult::WouldBlock));
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
if drain == "WINDOW_UPDATE" {
connection.core.queue_window_update(0, 65_535);
} else {
assert!(
connection
.core
.enqueue_rst(1, H2Error::Cancel, RstOrigin::Local)
.is_none(),
"premise: a single RST must not trip the flood detector"
);
}
connection.core.readiness.event.insert(Ready::WRITABLE);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
matches!(result, MuxResult::Continue),
"a refused {drain} drain parks the pass and continues, got {result:?}"
);
assert!(
!connection.core.output.is_empty(),
"premise: the {drain} flush must have stalled, leaving the frame queued"
);
assert!(
connection.socket.socket_wants_write(),
"premise: rustls must still hold records, or the re-arm is gated off"
);
assert!(
!connection.core.readiness.event.is_writable(),
"a {drain} drain the kernel refused must leave WRITABLE to the \
kernel's next edge, got {:?}",
connection.core.readiness
);
}
}
/// A stream write the kernel refuses, while rustls still holds records,
/// ends the pass without a synthetic WRITABLE. The pass already wrote
/// through `socket_write_vectored`, so `finalize_write` skips its own
/// flush and decides on the refusal `handle_write` recorded: the preamble
/// flush before it answered `Continue`, so without that record the
/// `ReArm` would re-raise the event.
///
/// TO SEE THIS RED: drop `self.note_write_status(status);` from
/// `ConnectionH2::handle_write`.
#[test]
fn a_refused_stream_write_leaves_writable_to_the_kernel() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut context, gid, _peer) = writable_fixture(
&pool,
&[FIRST_BLOCK, SECOND_BLOCK],
&[(0, SocketResult::WouldBlock)],
);
connection.socket.pending.set(1);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert_prepare_gate_is_shut(&context, gid);
connection.core.readiness.event.insert(Ready::WRITABLE);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
matches!(result, MuxResult::Continue),
"a refused stream write continues, got {result:?}"
);
assert_eq!(
connection.socket.vectored_calls, 1,
"premise: the pass must have reached the stream write"
);
assert!(
connection.socket.socket_wants_write(),
"premise: rustls must still hold records"
);
assert!(
!connection.core.readiness.event.is_writable(),
"a stream write the kernel refused must leave WRITABLE to the \
kernel's next edge, got {:?}",
connection.core.readiness
);
}
// ── The vectored write loop: a partial write that reports WouldBlock ──
//
// First, which shape each neighbour targets, because #1454 asks for that
// and the shapes are near-identical. `h2.rs` has FOUR `socket_write(&[])`
// sites. Three are the `query / socket_write(&[]) / query` TRIPLE, where
// the middle call discards both `size` and `status` and only the second
// query says whether the flush landed:
//
// - `finalize_write`, the BeforeFlush/AfterFlush pair — covered by
// `a_finalized_write_pass_flushes_once_and_re_arms_while_records_survive`.
// - the `H2State::GoAway` arm of `dispatch_writable_state`, whose flush
// `writable` performs for it — covered by
// `a_flush_that_does_not_drain_keeps_the_connection_open` and
// `a_flush_that_succeeds_closes_within_one_writable_call`.
// - `writable`'s preamble paired with the `error_close_action` query of
// `dispatch_writable_state`'s `(H2State::Error, Position::Server)`
// arm. The preamble half is asserted by the two GoAway tests above,
// and #1454's
// `a_rustls_frontend_in_error_state_stays_open_until_its_records_drain`
// drives the second query over the production handler — this line
// said **Uncovered** until that test existed, and it was already
// wrong before this changeset renamed what it points at.
//
// The fourth, in `flush_output_buffer`, is NOT a triple: it keeps the
// `status` the flush returned instead of re-querying. Also uncovered.
//
// The tests below target none of those. They target **the vectored loop
// the triples bracket** — the pre-image's `while !kawa.out.is_empty()`,
// now `poll_write_target`'s round-again over `H2WritePhase::Flush` — so
// naming a triple for them would be naming the wrong shape.
//
// What had never run against production code: a `socket_write_vectored`
// that moves bytes AND reports `WouldBlock`. `update_readiness` (`mux/mod.rs`)
// classifies a pass as stalled **iff `size == 0`** — `status` only clears
// the event bit — so `size > 0 && status == WouldBlock` is NOT a stall and
// the loop must go round again. A write machine that treated
// `status != Continue` as a pass terminator would drop that second write
// and truncate the response, and would pass every test that existed.
//
// Why no test could see it. `BackpressuredTlsSocket::socket_write_vectored`
// delegated to a real loopback socket, and a kernel that blocks answers
// `(0, WouldBlock)` — never `(size > 0, WouldBlock)`. Only `FrontRustls`
// returns that pair, and it does so structurally: `buffered_size` counts
// plaintext `session.writer().write(..)` accepted, while `can_write` goes
// false when `write_tls` blocks against the kernel. Two independent
// quantities, one return value.
//
// `h2_transmit`'s `qc_partial_writes_preserve_the_byte_stream` does drive
// partial writes, but through `drive`, a loop written in its own test
// module that calls neither `ConnectionH2::handle_write` nor
// `update_readiness_after_write`; its `WritePlan` carries an accept COUNT
// and no `SocketResult` at all. A mirror cannot constrain the thing it
// mirrors, and that plan cannot express this pair in the first place.
/// What the pre-image's `FlushOutcome` said about one stream's flush,
/// re-derived here because production no longer has the type.
///
/// The inversion deleted it along with `flush_stream_out`: the flush loop
/// is now the `Transmit`/`handle_write` round trip, whose exit condition
/// is read by `poll_write_target` from `kawa.out` and `pass.stalled`
/// rather than reported as a return value. `Drained` is therefore derived
/// from the queue being empty — the pre-image's own
/// `while !kawa.out.is_empty()` exit condition, verbatim — and NOT from
/// `expect_write` being parked. The distinction matters: the park has its
/// own dedicated tests below, and deriving the outcome from it would make
/// this classification move under edits those tests already cover.
///
/// **It is therefore the same fact as the `out_is_empty` element of
/// [`drive_flush_stream_out`]'s tuple, and the three tests below assert
/// both.** Stated rather than removed: their bodies are unchanged from
/// before the inversion, which is what makes them evidence that spans it,
/// and the alternatives were measured worse. Deriving this from
/// `expect_write` would couple it to the park, which the stall tests
/// already pin — the double-assertion would become a second test reddening
/// under the `set_expect_write` recipe. Deriving it from the WRITABLE
/// event bit cannot work either: `update_readiness` clears that bit on
/// every non-`Continue` round, so a drained two-round pass and a stalled
/// one leave it identical.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FlushOutcome {
/// All queued bytes were drained to the socket.
Drained,
/// The socket blocked before the queue was drained.
Stalled,
}
/// Drive one write pass over `blocks` with `script` as the socket's
/// answers, returning what the flush did.
///
/// Retargeted by the poll/handle inversion. There is no associated
/// function left to call: the loop this drives is spread across
/// `poll_write_target`'s round-again, the shell's gather / write / confirm
/// and `handle_write`, and the smallest production seam that contains all
/// three is `write_streams`. It is called directly rather than through
/// `writable()` so that the preamble's pending-control-frame flush and TLS
/// flush stay out of the measurement; `pending = 0` keeps
/// `socket_wants_write()` false for the whole pass, and nothing below
/// asserts on time, so skipping `writable`'s `self.now = context.now` is
/// inert.
///
/// What is unchanged is the point of the harness: `vectored_script` still
/// lets the `(size, status)` pair be CHOSEN rather than negotiated with a
/// kernel, which is the only way `(size > 0, WouldBlock)` is expressible
/// at all.
fn drive_flush_stream_out(
pool: &Rc<RefCell<Pool>>,
blocks: &[&'static [u8]],
script: &[(usize, SocketResult)],
) -> (FlushOutcome, usize, usize, bool, Readiness) {
let (mut connection, mut context, gid, _peer) = writable_fixture(pool, blocks, script);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.write_streams(&mut context, EndpointClient(&mut router));
let out_is_empty = context.streams[gid].back.out.is_empty();
(
if out_is_empty {
FlushOutcome::Drained
} else {
FlushOutcome::Stalled
},
context.streams[gid].metrics.bout,
connection.socket.vectored_calls,
out_is_empty,
connection.core.readiness.clone(),
)
}
/// Bytes the two blocks below carry, and the whole response as far as
/// this stream is concerned: ONE DATA frame on stream 1, split across two
/// blocks, 22 + 21 bytes. A stream's `kawa.out` only ever holds whole H2
/// frames, and since #1604 the write pass relies on it to find where the
/// frame a partial write cut ends (`h2_transmit::frame_tail`).
const FIRST_BLOCK: &[u8] = b"\x00\x00\x22\x00\x00\x00\x00\x00\x01HTTP/2 respon";
const SECOND_BLOCK: &[u8] = b" and its continuation";
const TOTAL_QUEUED: usize = FIRST_BLOCK.len() + SECOND_BLOCK.len();
/// A write that moved bytes but reported `WouldBlock` does NOT end the
/// pass: the loop goes round again and delivers the rest.
///
/// This is the truncation vector of #1454 at the vectored loop — not at
/// any of the three `socket_wants_write()` triples. `FrontRustls` returns
/// `(buffered_size, WouldBlock)` with `buffered_size > 0` whenever rustls
/// took plaintext off our hands while the kernel was full, and
/// `update_readiness` deliberately calls that NOT stalled. A machine that
/// terminated the pass on `status != Continue` would leave
/// `SECOND_BLOCK` queued and send the response short.
///
/// TO SEE THIS RED: in `handle_write`, change
/// `pass.stalled = update_readiness_after_write(size, status, &mut self.readiness);`
/// to
/// `pass.stalled = update_readiness_after_write(size, status, &mut self.readiness)
/// || !matches!(status, SocketResult::Continue);`
/// — the pass-terminator shape the `write_streams` inversion could
/// introduce, and the reason `SocketResult` is confined to this one
/// statement and this one parameter. Measured on that mutation: this test
/// fails on its own first assertion, `a vectored write that moved bytes
/// but reported WouldBlock must not end the pass`, with
/// `left: 5, right: 43`. It is one of THREE failures, the siblings being
/// `a_partial_write_reporting_would_block_continues_the_pass_through_writable`
/// and `a_yielded_incremental_stream_is_prepared_once_per_pass`; every
/// other test in the suite passes, including both controls below. No
/// production `debug_assert!` fires: every scripted size is clamped to the
/// gather's offer.
#[test]
fn a_partial_write_reporting_would_block_continues_the_pass() {
let pool = make_pool_for_invariant_16();
// Round 1 accepts 5 of the 43 offered and reports WouldBlock; round 2
// takes everything still queued and reports Continue.
// The event bit is deliberately not read here: `update_readiness`
// removes WRITABLE on the WouldBlock round and the Continue round does
// not put it back, so its final value is identical under the correct
// and the broken machine. An assertion on it could not fail.
let (outcome, bytes_written, vectored_calls, out_is_empty, _readiness) =
drive_flush_stream_out(
&pool,
&[FIRST_BLOCK, SECOND_BLOCK],
&[
(5, SocketResult::WouldBlock),
(TOTAL_QUEUED, SocketResult::Continue),
],
);
assert_eq!(
bytes_written, TOTAL_QUEUED,
"a vectored write that moved bytes but reported WouldBlock must \
not end the pass: the loop must go round again and deliver the \
remaining bytes, or the response is truncated"
);
assert_eq!(
vectored_calls, 2,
"the loop must issue a SECOND socket_write_vectored after the \
partial write, not settle for the first"
);
assert!(
out_is_empty,
"every queued block must be consumed once the pass drains"
);
assert!(
matches!(outcome, FlushOutcome::Drained),
"a queue emptied within the pass is Drained, got {outcome:?}"
);
}
/// A write the socket takes whole, in one round, reporting `Continue`.
///
/// **This test does not discriminate, and that is its job.** It is the
/// `status == Continue` path — the one every existing test already takes,
/// because a real loopback socket with room answers exactly this. It
/// passes against the correct loop AND against the pass-terminator
/// mutation in the test above, since a terminator keyed on
/// `status != Continue` never fires here. Kept as the standing
/// demonstration that coverage of this shape proves nothing about the
/// truncation vector: the suite was full of it and #1454 was open anyway.
#[test]
fn a_fully_accepted_write_drains_in_one_round() {
let pool = make_pool_for_invariant_16();
let (outcome, bytes_written, vectored_calls, out_is_empty, _readiness) =
drive_flush_stream_out(
&pool,
&[FIRST_BLOCK, SECOND_BLOCK],
&[(TOTAL_QUEUED, SocketResult::Continue)],
);
assert_eq!(
bytes_written, TOTAL_QUEUED,
"a socket that takes the whole offer delivers the whole queue"
);
assert_eq!(
vectored_calls, 1,
"nothing is left to write, so the loop must not go round again"
);
assert!(out_is_empty, "the queue must be empty after a full accept");
assert!(
matches!(outcome, FlushOutcome::Drained),
"a fully accepted offer is Drained, got {outcome:?}"
);
}
/// A write that moves NO bytes stalls the pass and drops WRITABLE.
///
/// The other side of `update_readiness`'s single decision: `size == 0` is
/// the stall, whatever the status. Together with the partial-write test
/// above this pins that the discriminator is the SIZE and not the status —
/// both rounds there reported a non-`Continue` status, and only this one
/// stops.
///
/// Like `a_fully_accepted_write_drains_in_one_round`, this passes under
/// the pass-terminator mutation too: `(0, WouldBlock)` stalls either way.
/// The size arm is the covered one; the partial arm was not.
///
/// TO SEE THIS RED: in `update_readiness` (`mux/mod.rs`), change the
/// `else` arm's trailing `true` to `false`, so no size ever reports a
/// stall. Measured: the first assertion fails with `a write that moved no
/// bytes must stall the pass, got Drained`. It does not hang — the script
/// here is a single round, so the loop re-enters, finds the script spent,
/// delegates to the live loopback socket and drains.
#[test]
fn a_write_that_moves_no_bytes_stalls_the_pass() {
let pool = make_pool_for_invariant_16();
let (outcome, bytes_written, vectored_calls, out_is_empty, readiness) =
drive_flush_stream_out(
&pool,
&[FIRST_BLOCK, SECOND_BLOCK],
&[(0, SocketResult::WouldBlock)],
);
assert!(
matches!(outcome, FlushOutcome::Stalled),
"a write that moved no bytes must stall the pass, got {outcome:?}"
);
assert_eq!(
vectored_calls, 1,
"a stalled pass must not retry the socket within the same pass"
);
assert_eq!(bytes_written, 0, "a stalled write delivered nothing");
assert!(
!out_is_empty,
"the undelivered blocks must stay queued for the next pass, or \
they are lost"
);
assert!(
!readiness.event.is_writable(),
"a stalled pass must clear the WRITABLE event so the pass is not \
re-entered before the socket says it can take more, got {readiness:?}"
);
}
// ── The two write-path sites `writable()` had never reached ──────────
//
// A stalled flush has ONE producer — the `pass.stalled` assignment in
// `handle_write`, `FlushOutcome::Stalled` before the inversion — and
// exactly two consumers, and both consumers were dead code as far as the
// suite was concerned:
//
// - the resume path's `H2WriteTarget::Done(MuxResult::Continue)`, taken
// in `H2WritePhase::Resume` for the stream `expect_write` parked, whose
// transmits carry `debug_site = 2`;
// - `H2WritePhase::Flush`'s
// `set_expect_write(Some(H2StreamId::Other { .. }))` followed by the
// move to `H2WritePhase::End` — `break 'outer` before the inversion.
//
// Measured on `38fe5851`, against the linear pre-image these tests were
// written for: a `panic!` planted at BOTH consumers at once still leaves
// the whole suite green — neither is reached by any test. A `panic!` on
// the `return FlushOutcome::Stalled` they both read fails exactly ONE
// test, `a_write_that_moves_no_bytes_stalls_the_pass` above, which called
// `flush_stream_out` directly and never entered `write_streams` at all.
// A `panic!` on the first line of the resume block leaves the suite green
// as well: `debug_site = 2` had no caller in it. Those measurements are a
// record of the revision named, not a claim about this one — the three
// tests above now reach `write_streams` too, since `flush_stream_out` is
// gone and there is no associated function left to call.
//
// No absolute pass count appears in this block or in any recipe below, on
// purpose. A count of the whole `sozu-lib` suite is falsified by a test
// added anywhere in the crate, and nothing checks it —
// `check_doc_citations.py` reads only `doc/` and `**/LIFECYCLE.md`. The
// failing test NAMES and the `left:`/`right:` values are what survive.
//
// Why the gap existed. A stall needs `size == 0` against a non-empty
// `kawa.out`. The two `BackpressuredTlsSocket` `writable()` tests above
// sit in `H2State::GoAway` and take that arm of `writable`, never
// `write_streams`; the two after them do reach `write_streams`, but with
// an empty stream table, so the scheduler loop visits no stream. Every
// other `writable()` test writes to a live loopback socket with room, and
// a kernel with room never answers 0.
//
// The missing step was never the socket — `vectored_script` already
// scripts any `(size, status)` pair, including ones no kernel produces.
// It was REGISTERING a stream that carries response bytes, so the
// scheduler reaches it through `writable()`. That is what
// `writable_fixture` below adds. It is now what `drive_flush_stream_out`
// builds on too: the difference left between the two is only that this
// group enters through `writable()` and that one through `write_streams`
// directly.
/// The stream id every test below registers. Odd, because a server's peer
/// opens odd-numbered streams (RFC 9113 §5.1.1).
const REGISTERED_STREAM_ID: StreamId = 1;
/// A server connection in `H2State::Header` plus a context holding ONE
/// registered stream whose response buffer already carries `blocks`, with
/// the socket's `(size, status)` answers scripted.
///
/// `pending = 0` keeps `socket_wants_write()` false for the whole pass, so
/// `writable`'s preamble flush and both of `finalize_write`'s TLS branches
/// stay out of the way: the readiness bits these tests read are decided by
/// the write pass alone.
///
/// The script is assigned to the field directly: a builder taking `self`
/// by value cannot be applied to a handler already moved into
/// `ConnectionH2::new`.
///
/// The peer end of the loopback pair is returned for the caller to hold:
/// dropping it would close the connection under the socket being tested.
fn writable_fixture(
pool: &Rc<RefCell<Pool>>,
blocks: &[&'static [u8]],
script: &[(usize, SocketResult)],
) -> (
H2Shell<BackpressuredTlsSocket>,
Context<TestListener>,
GlobalStreamId,
std::net::TcpStream,
) {
let (mut connection, peer) = connection_with_backpressure(pool, 0, 0, H2State::Header);
connection.socket.vectored_script = script.iter().copied().collect();
let mut context = test_context(pool);
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
connection
.core
.stream_table
.register(REGISTERED_STREAM_ID, gid, connection.core.now);
for block in blocks {
context.streams[gid]
.back
.out
.push_back(kawa::OutBlock::Store(kawa::Store::Static(block)));
}
(connection, context, gid, peer)
}
/// A fresh `Stream`'s response buffer is in `ParsingPhase::StatusLine`,
/// which `is_main_phase()` answers `false` for, so the `write_streams`
/// prepare gate stays shut and the pass is the flush and nothing else.
///
/// Every `writable_fixture` test rests on that, because each one counts
/// either bytes or socket calls and a prepare would add both. Asserted as
/// a premise rather than assumed: if a future `Stream::new` started life
/// in `Body`, those tests would keep passing while measuring something
/// else. `a_yielded_incremental_stream_is_prepared_once_per_pass` below
/// deliberately opens the gate instead, and builds its own fixture.
fn assert_prepare_gate_is_shut(context: &Context<TestListener>, gid: GlobalStreamId) {
let back = &context.streams[gid].back;
assert!(
!back.is_main_phase() && !back.is_terminated() && !back.is_error(),
"premise: the prepare gate must stay shut so this pass is a pure \
flush, got parsing_phase {:?}",
back.parsing_phase
);
}
/// A write that moved bytes but reported `WouldBlock` does not end the
/// pass — driven through `writable()`, over a registered stream.
///
/// `a_partial_write_reporting_would_block_continues_the_pass` above pins
/// the same contract one layer down, entering at `write_streams` and
/// reading the flush's own outcome. This one pins that the SCHEDULER
/// honours it: the `(size > 0, WouldBlock)` answer has to travel back out
/// through `poll_write_target`'s round-again at `H2WritePhase::Flush`,
/// past the `set_expect_write` park, and through `finalize_write` without
/// anything on the way deciding the pass is over. A write machine that
/// treated `status != Continue` as a pass terminator would park
/// `expect_write`, leave `SECOND_BLOCK` queued, and truncate the response.
///
/// TO SEE THIS RED: in `handle_write`, change
/// `pass.stalled = update_readiness_after_write(size, status, &mut self.readiness);`
/// to
/// `pass.stalled = update_readiness_after_write(size, status, &mut self.readiness)
/// || !matches!(status, SocketResult::Continue);`
/// Measured: this test fails on its own first assertion, `every queued
/// byte must reach the socket within the pass`, with `left: 5, right: 43`.
/// That edit also reddens two siblings, so THREE tests fail and not one:
/// `a_partial_write_reporting_would_block_continues_the_pass`, the same
/// contract on the direct path, documenting the same recipe (`left: 5,
/// right: 43`); and
/// `a_yielded_incremental_stream_is_prepared_once_per_pass` below, whose
/// first stream stalls on its `WouldBlock` round so the pass goes to
/// `H2WritePhase::End` before the second stream is ever prepared
/// (`left: 16384, right: 32768`). Three witnesses to one truncation vector, at three different
/// depths — not a duplicate. No production `debug_assert!` fires: every
/// scripted size is clamped to the gather's offer.
#[test]
fn a_partial_write_reporting_would_block_continues_the_pass_through_writable() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut context, gid, _peer) = writable_fixture(
&pool,
&[FIRST_BLOCK, SECOND_BLOCK],
&[
(5, SocketResult::WouldBlock),
(TOTAL_QUEUED, SocketResult::Continue),
],
);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert_eq!(
connection.core.stream_table.expect_write(),
None,
"premise: no parked write, so the pass takes the main loop and not \
the resume path"
);
assert_prepare_gate_is_shut(&context, gid);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert_eq!(
context.streams[gid].metrics.bout, TOTAL_QUEUED,
"every queued byte must reach the socket within the pass: a write \
that moved bytes under WouldBlock is not a stall, so the loop must \
go round again instead of ending the pass short"
);
assert_eq!(
connection.socket.vectored_calls, 2,
"the pass must issue a SECOND socket_write_vectored after the \
partial write, not settle for the first"
);
assert!(
context.streams[gid].back.out.is_empty(),
"every queued block must be consumed once the pass drains"
);
assert_eq!(
connection.core.stream_table.expect_write(),
None,
"a pass that drained must not park expect_write: parking here \
would defer a stream that owes nothing to the next writable tick"
);
assert!(
matches!(result, MuxResult::Continue),
"a drained write pass continues, got {result:?}"
);
}
/// A write pass that transmits allocates nothing once the connection is
/// warm: the vectored descriptors live in `H2Shell::io_slices`, which
/// keeps its capacity from one pass to the next instead of being built
/// and freed by every `write_streams` call.
///
/// The first pass is the warm-up and may grow the scratch; the second
/// queues the same two blocks again and is the measurement. Both go
/// through the loopback socket (empty script), so the write is a real
/// `writev(2)` and the count covers the whole gather / write / confirm
/// round trip plus `poll_write_target` and `handle_write` around it.
///
/// TO SEE THIS RED: in `H2Shell::write_streams`, declare
/// `let mut io_slices: Vec<IoSlice<'static>> = Vec::new();` above the
/// loop and hand it to `gather`, the write and `confirm` in place of
/// `self.io_slices`. Measured: this test fails with
/// `left: 2, right: 1` under `cargo test`, and `left: 1, right: 0`
/// under `cargo test --release` — one allocation per transmitting pass.
#[test]
fn a_warm_write_pass_allocates_nothing() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut context, gid, _peer) =
writable_fixture(&pool, &[FIRST_BLOCK, SECOND_BLOCK], &[]);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert_prepare_gate_is_shut(&context, gid);
connection.write_streams(&mut context, EndpointClient(&mut router));
assert!(
context.streams[gid].back.out.is_empty(),
"premise: the warm-up pass must drain, or the measured pass would \
start from a different state"
);
for block in [FIRST_BLOCK, SECOND_BLOCK] {
context.streams[gid]
.back
.out
.push_back(kawa::OutBlock::Store(kawa::Store::Static(block)));
}
let calls_before = connection.socket.vectored_calls;
let before = crate::test_allocations::allocations();
connection.write_streams(&mut context, EndpointClient(&mut router));
let allocations = crate::test_allocations::allocations() - before;
assert!(
connection.socket.vectored_calls > calls_before,
"premise: the measured pass must reach socket_write_vectored"
);
assert!(
context.streams[gid].back.out.is_empty(),
"premise: the measured pass must drain the stream"
);
// `finalize_write`'s `Quiesce` arm formats a `DebugEvent::Str` under
// `#[cfg(debug_assertions)]` only; a release build does not compile
// it. Everything else the pass does must be allocation-free.
let debug_instrumentation = usize::from(cfg!(debug_assertions));
assert_eq!(
allocations, debug_instrumentation,
"a warm transmitting write pass must not allocate"
);
}
/// A zero-byte write parks `expect_write` on the stalled stream and ends
/// the pass, so the next tick resumes exactly it.
///
/// This is `poll_write_target`'s stall consumer — the
/// `set_expect_write(Some(write_stream));` plus the move to
/// `H2WritePhase::End` in the `H2WritePhase::Flush` arm, `break 'outer`
/// before the inversion. Without the park, the next `writable()` would
/// re-enter the scheduler from the top and re-run the priority ordering
/// for a stream that is merely waiting on the socket, and `finalize_write`
/// would read `expect_write_parked == false` and fall through to
/// `Quiesce`, stripping `Ready::WRITABLE` from a connection that has bytes
/// to deliver.
///
/// TO SEE THIS RED: delete the
/// `self.stream_table.set_expect_write(Some(write_stream));` statement
/// from that arm, keeping the `pass.phase = H2WritePhase::End;` that
/// follows it. Measured: this test fails on its own first
/// assertion, `a stalled stream must park expect_write so the next pass
/// resumes it`, with `left: None, right: Some(Other { id: 1, gid: 0 })`,
/// and it is the ONLY failure in the suite.
#[test]
fn a_stalled_stream_parks_expect_write_for_the_next_pass() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut context, gid, _peer) = writable_fixture(
&pool,
&[FIRST_BLOCK, SECOND_BLOCK],
&[(0, SocketResult::WouldBlock)],
);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert_eq!(
connection.core.stream_table.expect_write(),
None,
"premise: nothing parked on entry, so any park below is this \
pass's doing"
);
assert_prepare_gate_is_shut(&context, gid);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert_eq!(
connection.core.stream_table.expect_write(),
Some(H2StreamId::Other {
id: REGISTERED_STREAM_ID,
gid
}),
"a stalled stream must park expect_write so the next pass resumes \
it instead of re-running the scheduler over a socket-blocked stream"
);
assert!(
!context.streams[gid].back.out.is_empty(),
"the undelivered blocks must stay queued for the next pass, or \
they are lost"
);
assert_eq!(
connection.socket.vectored_calls, 1,
"a stalled pass must not retry the socket within the same pass"
);
assert_eq!(
context.streams[gid].metrics.bout, 0,
"a stalled write delivered nothing"
);
assert!(
connection.core.readiness.interest.is_writable(),
"a parked pass leaves every readiness bit alone — stripping \
WRITABLE here would strand the parked stream, got {:?}",
connection.core.readiness
);
assert!(
matches!(result, MuxResult::Continue),
"a parked write pass continues, got {result:?}"
);
}
/// A stalled resume returns before the scheduler pass begins.
///
/// This is the OTHER stall consumer — `H2WritePhase::Resume`'s
/// `if pass.stalled { return H2WriteTarget::Done(MuxResult::Continue); }`.
/// The park it leaves standing is the point: falling through would clear
/// `expect_write` on a stream the socket has just refused, and then run
/// the whole priority ordering, converter pass and census for a
/// connection whose socket is known to be full.
///
/// The stream is registered even though the resume path reads its gid
/// straight out of `expect_write` and needs no registration. That is what
/// makes `vectored_calls` a witness: with the stream in the table, a pass
/// that fell through into the scheduler phases would reach it again and
/// issue a second `socket_write_vectored`.
///
/// TO SEE THIS RED: delete the
/// `return H2WriteTarget::Done(MuxResult::Continue);` from that arm,
/// leaving the `if pass.stalled {}` with an empty body so control falls
/// through to `self.stream_table.set_expect_write(None);`. Measured: this test fails
/// on its own first assertion, `a stalled resume must leave expect_write
/// parked`, with `left: None, right: Some(Other { id: 1, gid: 0 })`, and
/// it is the ONLY failure in the suite. The
/// second assertion moves under the same edit (`left: 2, right: 1`), the
/// extra call being the main loop re-flushing the same stream through the
/// delegated loopback socket once the one-entry script is spent.
#[test]
fn a_stalled_resume_does_not_begin_a_scheduler_pass() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut context, gid, _peer) = writable_fixture(
&pool,
&[FIRST_BLOCK, SECOND_BLOCK],
&[(0, SocketResult::WouldBlock)],
);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let parked = H2StreamId::Other {
id: REGISTERED_STREAM_ID,
gid,
};
connection.core.stream_table.set_expect_write(Some(parked));
assert_prepare_gate_is_shut(&context, gid);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert_eq!(
connection.core.stream_table.expect_write(),
Some(parked),
"a stalled resume must leave expect_write parked: clearing it \
would hand a socket-blocked stream back to the scheduler"
);
assert_eq!(
connection.socket.vectored_calls, 1,
"a stalled resume must return before the scheduler pass begins, so \
the registered stream must not be flushed a second time"
);
assert!(
!context.streams[gid].back.out.is_empty(),
"the refused blocks must stay queued for the next pass"
);
assert_eq!(
context.streams[gid].metrics.bout, 0,
"a stalled resume delivered nothing"
);
assert!(
matches!(result, MuxResult::Continue),
"a stalled resume continues the session, got {result:?}"
);
}
/// A socket that takes at most `budget` bytes, then answers
/// `WouldBlock`, and keeps every byte it took in `wire`, in order: what
/// the peer would read. Reads are served from `inbound`.
struct PacedSocket {
stream: mio::net::TcpStream,
budget: usize,
wire: Vec<u8>,
inbound: std::collections::VecDeque<u8>,
}
impl PacedSocket {
fn take(&mut self, buf: &[u8]) -> usize {
let taken = self.budget.min(buf.len());
self.wire.extend_from_slice(&buf[..taken]);
self.budget -= taken;
taken
}
}
impl SocketHandler for PacedSocket {
fn socket_read(&mut self, buf: &mut [u8]) -> (usize, SocketResult) {
let size = buf.len().min(self.inbound.len());
for (slot, byte) in buf.iter_mut().zip(self.inbound.drain(..size)) {
*slot = byte;
}
if size < buf.len() {
(size, SocketResult::WouldBlock)
} else {
(size, SocketResult::Continue)
}
}
fn socket_write(&mut self, buf: &[u8]) -> (usize, SocketResult) {
let taken = self.take(buf);
if taken < buf.len() {
(taken, SocketResult::WouldBlock)
} else {
(taken, SocketResult::Continue)
}
}
fn socket_write_vectored(&mut self, bufs: &[IoSlice]) -> (usize, SocketResult) {
let mut total = 0;
for buf in bufs {
let taken = self.take(buf);
total += taken;
if taken < buf.len() {
return (total, SocketResult::WouldBlock);
}
}
(total, SocketResult::Continue)
}
fn socket_ref(&self) -> &mio::net::TcpStream {
&self.stream
}
fn socket_mut(&mut self) -> &mut mio::net::TcpStream {
&mut self.stream
}
fn peer_addr(&self) -> Option<std::net::SocketAddr> {
None
}
fn protocol(&self) -> crate::socket::TransportProtocol {
crate::socket::TransportProtocol::Tcp
}
fn read_error(&self) {}
fn write_error(&self) {}
}
/// One frame as the peer reads it: `(type, flags, stream id, payload)`.
type PeerFrame = (u8, u8, u32, Vec<u8>);
/// Split `wire` into frames the way the peer's parser does, failing on a
/// frame the bytes cannot hold.
fn peer_frames(mut wire: &[u8]) -> Result<Vec<PeerFrame>, String> {
let mut frames = Vec::new();
while !wire.is_empty() {
if wire.len() < 9 {
return Err(format!("{} trailing bytes: {wire:?}", wire.len()));
}
let len = u32::from_be_bytes([0, wire[0], wire[1], wire[2]]) as usize;
let stream_id = u32::from_be_bytes([wire[5], wire[6], wire[7], wire[8]]) & 0x7fff_ffff;
if wire.len() < 9 + len {
return Err(format!(
"frame type {:#x} announces {len} bytes, {} remain",
wire[3],
wire.len() - 9
));
}
frames.push((wire[3], wire[4], stream_id, wire[9..9 + len].to_vec()));
wire = &wire[9 + len..];
}
Ok(frames)
}
/// The payload of the DATA frame `a_ping_during_a_half_written_frame_*`
/// queues on stream 1, after its header below.
const HALF_WRITTEN_PAYLOAD: &[u8] = &[b'd'; 32];
const HALF_WRITTEN_HEADER: &[u8] = &[0, 0, 32, 0, 0, 0, 0, 0, 1];
/// Park a 41-byte DATA frame on stream 1 after the socket took 20 bytes
/// of it, feed `inbound` (a PING or SETTINGS), and read it. `between`
/// then runs before the socket is unthrottled, and the connection is
/// driven the way `Mux` drives it, `writable()` and `readable()` when
/// the filtered readiness allows them. Returns the connection, whose
/// socket holds every byte the peer would read.
fn ack_after_half_written_frame(
inbound: &[u8],
between: impl FnOnce(
&mut H2Shell<PacedSocket>,
&mut Context<TestListener>,
&mut Router,
GlobalStreamId,
),
) -> H2Shell<PacedSocket> {
let (_pool, mut connection, mut context, mut router, gid) = park_half_written_frame();
connection.socket.inbound.extend(inbound);
for _ in 0..8 {
connection.core.readiness.event.insert(Ready::READABLE);
if !connection.core.readiness.filter_interest().is_readable() {
break;
}
connection.readable(&mut context, EndpointClient(&mut router));
}
assert!(
connection.socket.inbound.is_empty() && !connection.core.output.is_empty(),
"premise: the frame was read and its ACK queued"
);
between(&mut connection, &mut context, &mut router, gid);
drive_unthrottled(&mut connection, &mut context, &mut router);
connection
}
/// Drive `connection` the way `Mux` does, `writable()` and `readable()`
/// when the filtered readiness allows them, with the socket unthrottled.
fn drive_unthrottled(
connection: &mut H2Shell<PacedSocket>,
context: &mut Context<TestListener>,
router: &mut Router,
) {
connection.socket.budget = usize::MAX;
for _ in 0..16 {
connection.core.readiness.event.insert(Ready::WRITABLE);
if connection.core.readiness.filter_interest().is_writable() {
connection.writable(context, EndpointClient(router));
}
connection.core.readiness.event.insert(Ready::READABLE);
if connection.core.readiness.filter_interest().is_readable() {
connection.readable(context, EndpointClient(router));
}
}
}
/// Park a 41-byte DATA frame on stream 1 after the socket took 20 bytes
/// of it, and return the buffer pool, which must outlive the rest, the
/// connection, its context and router, and the stream's global id.
fn park_half_written_frame() -> (
Rc<RefCell<Pool>>,
H2Shell<PacedSocket>,
Context<TestListener>,
Router,
GlobalStreamId,
) {
park_cut_frames(&[HALF_WRITTEN_HEADER, HALF_WRITTEN_PAYLOAD], 20)
}
/// [`park_half_written_frame`] for any whole frames of stream 1, queued
/// as `blocks`, of which the socket takes `budget` bytes.
fn park_cut_frames(
blocks: &[&'static [u8]],
budget: usize,
) -> (
Rc<RefCell<Pool>>,
H2Shell<PacedSocket>,
Context<TestListener>,
Router,
GlobalStreamId,
) {
let pool = make_pool_for_invariant_16();
let (socket, _peer) = connected_socket();
let mut connection = H2Shell::new(
Ulid::generate(),
PacedSocket {
stream: socket,
budget,
wire: Vec::new(),
inbound: std::collections::VecDeque::new(),
},
Position::Server,
&mut PoolBufferSource::new(Rc::downgrade(&pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, 9)),
Ready::READABLE | Ready::WRITABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection");
connection.core.state = H2State::Header;
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
connection
.core
.stream_table
.register(REGISTERED_STREAM_ID, gid, connection.core.now);
for &block in blocks {
context.streams[gid]
.back
.out
.push_back(kawa::OutBlock::Store(kawa::Store::Static(block)));
}
assert_prepare_gate_is_shut(&context, gid);
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(&mut context, EndpointClient(&mut router));
assert_eq!(
(
connection.socket.wire.len(),
connection.core.stream_table.expect_write()
),
(
budget,
Some(H2StreamId::Other {
id: REGISTERED_STREAM_ID,
gid
})
),
"premise: the frames are cut and their stream parked"
);
(pool, connection, context, router, gid)
}
const HALF_WRITTEN_PING: &[u8] = b"\x00\x00\x08\x06\x00\x00\x00\x00\x00pingpong";
const SECOND_PING: &[u8] = b"\x00\x00\x08\x06\x00\x00\x00\x00\x00pingpon2";
/// `(type, flags, stream id)` of every frame the peer reads, or the
/// parse failure.
fn peer_frame_kinds(wire: &[u8]) -> Result<Vec<(u8, u8, u32)>, String> {
peer_frames(wire).map(|frames| {
frames
.into_iter()
.map(|(kind, flags, stream_id, _)| (kind, flags, stream_id))
.collect()
})
}
/// A PING that arrives while a stream frame is half-written must be
/// acknowledged AFTER that frame, never inside it (#1600).
///
/// The socket takes 20 bytes of a 41-byte DATA frame, so the stream is
/// parked in `expect_write` and the 21-byte rest of its frame is adopted
/// by the ordered output queue (#1604). The peer then sends a PING. Its
/// ACK is queued behind that rest, so the peer reads the whole DATA
/// frame, then the ACK, and never 17 ACK bytes in the middle of the
/// frame.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_write`, replace
/// `self.output.adopt_tail(kawa, tail);` by `let _ = (kawa, tail);`, so
/// the rest of the cut frame stays with its stream. Every test on this
/// fixture then fails: this one and the other #1601 tests on
/// `frame_tail`'s `a frame runs past the gathered bytes` debug assertion
/// (the stream's `kawa.out` no longer starts on a frame boundary), the
/// three #1604 tests below on their wire assertion. Verified 2026-09-27.
/// Before #1604 the equivalent mutation was in `queue_zero_output` and
/// failed with `left: Err("frame type 0x64 announces 6579300 bytes, 8
/// remain")` (verified 2026-09-26).
#[test]
fn a_ping_during_a_half_written_frame_is_acknowledged_after_it() {
let connection = ack_after_half_written_frame(HALF_WRITTEN_PING, |_, _, _, _| {});
assert_eq!(
peer_frames(&connection.socket.wire),
Ok(vec![
(0, 0, REGISTERED_STREAM_ID, HALF_WRITTEN_PAYLOAD.to_vec()),
(6, parser::FLAG_ACK, 0, b"pingpong".to_vec()),
]),
"the peer must read the whole DATA frame, then the PING ACK"
);
assert!(
connection.core.readiness.interest.is_readable(),
"once the ACK is out the connection must read again"
);
}
/// The SETTINGS ACK takes the same path as the PING ACK above (#1600).
#[test]
fn a_settings_during_a_half_written_frame_is_acknowledged_after_it() {
let connection =
ack_after_half_written_frame(b"\x00\x00\x00\x04\x00\x00\x00\x00\x00", |_, _, _, _| {});
assert_eq!(
peer_frames(&connection.socket.wire),
Ok(vec![
(0, 0, REGISTERED_STREAM_ID, HALF_WRITTEN_PAYLOAD.to_vec()),
(4, parser::FLAG_ACK, 0, Vec::new()),
]),
"the peer must read the whole DATA frame, then the SETTINGS ACK"
);
assert!(
connection.core.readiness.interest.is_readable(),
"once the ACK is out the connection must read again"
);
}
/// An ACK queued behind a half-written frame whose stream is removed
/// before the frame resumes still leaves, and the connection keeps
/// reading: the removal takes nothing that was queued. What the peer
/// reads is asserted by
/// `a_removed_half_written_stream_still_completes_its_frame` (#1604).
///
/// TO SEE THIS RED: in `ConnectionH2::flush_pending_control_frames`,
/// answer `H2ControlFlushTarget::Proceed` unconditionally at its end. The
/// test then fails with `an ACK behind a removed stream's frame must not
/// be stranded`.
#[test]
fn a_deferred_ack_does_not_strand_reads_when_its_parked_stream_is_removed() {
let connection =
ack_after_half_written_frame(HALF_WRITTEN_PING, |connection, _, _, gid| {
connection
.core
.remove_dead_stream(REGISTERED_STREAM_ID, gid);
});
assert!(
connection.core.output.is_empty(),
"an ACK behind a removed stream's frame must not be stranded"
);
assert!(
connection.core.readiness.interest.is_readable(),
"once the output is flushed the connection must read again"
);
}
/// Two PINGs read while one frame stays parked are both acknowledged,
/// in order, after that frame.
#[test]
fn two_pings_during_one_park_are_acknowledged_after_the_frame() {
let connection = ack_after_half_written_frame(HALF_WRITTEN_PING, |connection, _, _, _| {
connection.socket.inbound.extend(SECOND_PING);
});
assert_eq!(
peer_frames(&connection.socket.wire),
Ok(vec![
(0, 0, REGISTERED_STREAM_ID, HALF_WRITTEN_PAYLOAD.to_vec()),
(6, parser::FLAG_ACK, 0, b"pingpong".to_vec()),
(6, parser::FLAG_ACK, 0, b"pingpon2".to_vec()),
]),
"both PING ACKs must follow the whole DATA frame, in order"
);
}
/// A SETTINGS then a PING during one park: both ACKs, in order.
#[test]
fn settings_then_ping_during_one_park_are_acknowledged_after_the_frame() {
let connection = ack_after_half_written_frame(
b"\x00\x00\x00\x04\x00\x00\x00\x00\x00",
|connection, _, _, _| {
connection.socket.inbound.extend(SECOND_PING);
},
);
assert_eq!(
peer_frames(&connection.socket.wire),
Ok(vec![
(0, 0, REGISTERED_STREAM_ID, HALF_WRITTEN_PAYLOAD.to_vec()),
(4, parser::FLAG_ACK, 0, Vec::new()),
(6, parser::FLAG_ACK, 0, b"pingpon2".to_vec()),
]),
"the SETTINGS ACK then the PING ACK must follow the whole DATA frame"
);
}
/// The shutdown drive (`Mux::shutting_down`) calls `flush_output_buffer`,
/// then forces READABLE and calls `readable()`. That read must not eat
/// the queued ACK. Since #1604 it cannot by construction: the read lands
/// in `zero`, which is input only, and the ACK waits in the output queue
/// behind the rest of the half-written frame.
///
/// TO SEE THIS RED: the `adopt_tail` mutation described on
/// `a_ping_during_a_half_written_frame_is_acknowledged_after_it` fails it
/// on `a frame runs past the gathered bytes`. Verified 2026-09-27. Before
/// #1604, deleting the `zero_holds_output()` block of `poll_read_target`
/// failed it on `no output may be pending in zero when a frame header is
/// read` (verified 2026-09-27).
#[test]
fn a_forced_shutdown_read_does_not_eat_a_deferred_ack() {
let connection =
ack_after_half_written_frame(HALF_WRITTEN_PING, |connection, context, router, _| {
connection.socket.inbound.extend(SECOND_PING);
connection.flush_output_buffer();
connection.core.readiness.interest.insert(Ready::READABLE);
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(context, EndpointClient(router));
});
assert_eq!(
peer_frames(&connection.socket.wire),
Ok(vec![
(0, 0, REGISTERED_STREAM_ID, HALF_WRITTEN_PAYLOAD.to_vec()),
(6, parser::FLAG_ACK, 0, b"pingpong".to_vec()),
(6, parser::FLAG_ACK, 0, b"pingpon2".to_vec()),
]),
"the forced shutdown read must not eat the deferred ACK"
);
}
/// A final GOAWAY while a frame is parked and an ACK queued is sent after
/// both: `goaway()` appends to the ordered output queue, behind the rest
/// of the frame and the ACK.
///
/// TO SEE THIS RED: in `ConnectionH2::goaway`, call `self.output.clear()`
/// before queueing the GOAWAY. The test then fails with `the whole DATA
/// frame, the PING ACK, then the GOAWAY`. Verified 2026-09-27.
#[test]
fn a_goaway_during_a_half_written_frame_follows_it_and_the_ack() {
let connection =
ack_after_half_written_frame(HALF_WRITTEN_PING, |connection, context, _, gid| {
// A stream being answered is linked; an idle one would be
// pruned by the closing connection before its frame resumes.
context.streams[gid].state = StreamState::Linked(mio::Token(1));
let _ = connection.core.goaway(H2Error::NoError);
});
assert_eq!(
peer_frame_kinds(&connection.socket.wire),
Ok(vec![
(0, 0, REGISTERED_STREAM_ID),
(6, parser::FLAG_ACK, 0),
(7, 0, 0)
]),
"the whole DATA frame, the PING ACK, then the GOAWAY"
);
}
/// The initial graceful GOAWAY (`graceful_goaway` -> `send_initial_goaway`,
/// the call `Mux::shutting_down` makes) behaves like the final one.
///
/// TO SEE THIS RED: in `ConnectionH2::send_initial_goaway`, call
/// `self.output.clear()` before queueing the GOAWAY. The test then fails
/// with `the whole DATA frame, the PING ACK, then the initial GOAWAY`.
/// Verified 2026-09-27.
#[test]
fn an_initial_goaway_during_a_half_written_frame_follows_it_and_the_ack() {
let connection = ack_after_half_written_frame(HALF_WRITTEN_PING, |connection, _, _, _| {
let now = connection.core.now;
let _ = connection.core.graceful_goaway(now);
});
assert_eq!(
peer_frame_kinds(&connection.socket.wire),
Ok(vec![
(0, 0, REGISTERED_STREAM_ID),
(6, parser::FLAG_ACK, 0),
(7, 0, 0)
]),
"the whole DATA frame, the PING ACK, then the initial GOAWAY"
);
}
/// A peer RST_STREAM for stream 1, whose DATA frame the fixture parks.
const RST_STREAM_1: &[u8] = b"\x00\x00\x04\x03\x00\x00\x00\x00\x01\x00\x00\x00\x08";
/// A stream parked half-way through a frame and then removed must not
/// leave that frame truncated on the wire (#1604): the bytes written
/// after the removal would land inside what the peer still parses as
/// that frame. Here the removal is `remove_dead_stream` itself, the
/// chokepoint of every removal path, and the next frame is a PING ACK
/// read after the removal.
///
/// TO SEE THIS RED: on the pre-image, `a0785505` (stream frames written
/// from `kawa.out` and serialised only through `expect_write`), this test
/// and its two siblings below fail on their wire assertion with, in
/// order, `left: Err("frame type 0x0 announces 65536 bytes, 4 remain")`,
/// `... 0 remain")` and `left: Err("frame type 0x0 announces 32 bytes,
/// 28 remain")`: the peer reads the next frame's bytes as the rest of the
/// truncated one. On this tree, the `adopt_tail` mutation described on
/// `a_ping_during_a_half_written_frame_is_acknowledged_after_it` fails
/// all three with the same messages. Verified 2026-09-27.
#[test]
fn a_removed_half_written_stream_still_completes_its_frame() {
let connection =
ack_after_half_written_frame(HALF_WRITTEN_PING, |connection, _, _, gid| {
connection
.core
.remove_dead_stream(REGISTERED_STREAM_ID, gid);
connection.socket.inbound.extend(SECOND_PING);
});
assert_eq!(
peer_frames(&connection.socket.wire),
Ok(vec![
(0, 0, REGISTERED_STREAM_ID, HALF_WRITTEN_PAYLOAD.to_vec()),
(6, parser::FLAG_ACK, 0, b"pingpong".to_vec()),
(6, parser::FLAG_ACK, 0, b"pingpon2".to_vec()),
]),
"the removed stream's frame must be completed before the ACKs"
);
assert!(
connection.core.readiness.interest.is_readable(),
"once the output is flushed the connection must read again"
);
}
/// The same removal followed by another stream's frame (#1604): the
/// next stream's DATA frame must follow the whole parked frame.
#[test]
fn a_removed_half_written_stream_does_not_corrupt_the_next_stream_frame() {
const NEXT_STREAM_ID: StreamId = 3;
const NEXT_HEADER: &[u8] = &[0, 0, 4, 0, 1, 0, 0, 0, 3];
const NEXT_PAYLOAD: &[u8] = b"next";
let connection =
ack_after_half_written_frame(HALF_WRITTEN_PING, |connection, context, _, gid| {
connection
.core
.remove_dead_stream(REGISTERED_STREAM_ID, gid);
let next = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a second stream");
connection
.core
.stream_table
.register(NEXT_STREAM_ID, next, connection.core.now);
for block in [NEXT_HEADER, NEXT_PAYLOAD] {
context.streams[next]
.back
.out
.push_back(kawa::OutBlock::Store(kawa::Store::Static(block)));
}
});
assert_eq!(
peer_frames(&connection.socket.wire),
Ok(vec![
(0, 0, REGISTERED_STREAM_ID, HALF_WRITTEN_PAYLOAD.to_vec()),
(6, parser::FLAG_ACK, 0, b"pingpong".to_vec()),
(0, 1, NEXT_STREAM_ID, NEXT_PAYLOAD.to_vec()),
]),
"the next stream's frame must follow the whole parked frame"
);
}
/// HEADERS without END_HEADERS, then its CONTINUATION, on stream 1.
const CUT_BLOCK_HEADERS: &[u8] = b"\x00\x00\x10\x01\x00\x00\x00\x00\x01hpack-fragment-1";
const CUT_BLOCK_CONTINUATION: &[u8] = b"\x00\x00\x10\x09\x04\x00\x00\x00\x01hpack-fragment-2";
/// A header block cut by a partial write keeps everything else out until
/// END_HEADERS (RFC 9113 §6.10): the PING ACK read meanwhile follows the
/// CONTINUATION, whether the write stopped inside the HEADERS frame or
/// exactly on the boundary between the two frames, where no frame is
/// half-written but the block still is (#1604).
///
/// TO SEE THIS RED: in `h2_transmit::frame_tail`, append `&& false` to
/// the `in_block` expression, so the rest stops at the cut frame's end.
/// The test then fails with `the ACK must follow END_HEADERS`, the ACK
/// sitting between the HEADERS and the CONTINUATION. Verified
/// 2026-09-27.
#[test]
fn a_cut_header_block_is_completed_before_the_ack() {
for budget in [20, CUT_BLOCK_HEADERS.len()] {
let (_pool, mut connection, mut context, mut router, _gid) =
park_cut_frames(&[CUT_BLOCK_HEADERS, CUT_BLOCK_CONTINUATION], budget);
connection.socket.inbound.extend(HALF_WRITTEN_PING);
for _ in 0..4 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
}
assert!(
connection.socket.inbound.is_empty(),
"premise: the PING was read while the block is cut"
);
drive_unthrottled(&mut connection, &mut context, &mut router);
assert_eq!(
peer_frame_kinds(&connection.socket.wire),
Ok(vec![
(1, 0, REGISTERED_STREAM_ID),
(9, parser::FLAG_END_HEADERS, REGISTERED_STREAM_ID),
(6, parser::FLAG_ACK, 0),
]),
"the ACK must follow END_HEADERS (cut at {budget})"
);
}
}
/// The peer resets the parked stream (#1604). Its RST_STREAM is read
/// while the frame is half-written, and retires the stream; the rest of
/// that frame must still reach the wire before the PING ACK behind it.
#[test]
fn a_peer_reset_of_a_half_written_stream_still_completes_its_frame() {
let (_pool, mut connection, mut context, mut router, _gid) = park_half_written_frame();
connection.socket.inbound.extend(RST_STREAM_1);
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
assert!(
connection.socket.inbound.is_empty()
&& !connection
.core
.stream_table
.streams()
.contains_key(®ISTERED_STREAM_ID),
"premise: the RST was read and retired the parked stream"
);
connection.socket.inbound.extend(HALF_WRITTEN_PING);
drive_unthrottled(&mut connection, &mut context, &mut router);
assert_eq!(
peer_frames(&connection.socket.wire),
Ok(vec![
(0, 0, REGISTERED_STREAM_ID, HALF_WRITTEN_PAYLOAD.to_vec()),
(6, parser::FLAG_ACK, 0, b"pingpong".to_vec()),
]),
"the reset stream's frame must be completed before the ACK"
);
}
/// Park stream 1's encoded 404 unsent behind a socket that takes nothing,
/// let `drop_block` discard it, then drive the connection unthrottled and
/// decode every HEADERS the peer reads with ONE decoder, as the peer does.
/// Answers `(stream id, decoded)` per HEADERS and the `:status` values read.
///
/// Both requests get the same 404, so stream 1's block inserts the
/// answer's fields into the connection's encoder table and stream 3's
/// identical block names them by index: a peer that missed stream 1's
/// block cannot read stream 3's without a table reset.
fn peer_reads_after_a_dropped_parked_block(
drop_block: impl FnOnce(&mut H2Shell<PacedSocket>, &mut Context<TestListener>, &mut Router),
) -> (Vec<(u32, bool)>, Vec<Vec<u8>>) {
let (_pool, mut connection, mut context, mut router, _peer) = two_requests_read(0);
let answers = context.listener.borrow().get_answers().clone();
let open: Vec<GlobalStreamId> = connection
.core
.stream_table
.streams()
.values()
.copied()
.collect();
for gid in open {
crate::protocol::mux::answers::set_default_answer(
&mut context.streams[gid],
&mut connection.core.readiness,
404,
&answers.borrow(),
);
}
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(&mut context, EndpointClient(&mut router));
assert!(
connection.socket.wire.is_empty()
&& connection.core.stream_table.expect_write().is_some(),
"premise: stream 1's block was encoded and parked unsent"
);
drop_block(&mut connection, &mut context, &mut router);
connection.socket.budget = usize::MAX;
drive_both_ways(&mut connection, &mut context, &mut router);
peer_header_blocks(&connection.socket.wire)
}
/// An H2 server connection over a [`PacedSocket`] taking `budget` bytes,
/// with GET requests on streams 1 and 3 read and not answered.
fn two_requests_read(
budget: usize,
) -> (
Rc<RefCell<Pool>>,
H2Shell<PacedSocket>,
Context<TestListener>,
Router,
std::net::TcpStream,
) {
let pool = make_pool_for_invariant_16();
let (socket, peer) = connected_socket();
let mut connection = H2Shell::new(
Ulid::generate(),
PacedSocket {
stream: socket,
budget,
wire: Vec::new(),
inbound: std::collections::VecDeque::new(),
},
Position::Server,
&mut PoolBufferSource::new(Rc::downgrade(&pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, 9)),
Ready::READABLE | Ready::WRITABLE | Ready::HUP | Ready::ERROR,
)
.expect("pool");
connection.core.state = H2State::Header;
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let mut peer_encoder = crate::protocol::mux::hpack::Encoder::new();
for stream_id in [1u32, 3] {
let block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/repro"[..]),
]);
let mut frame = (block.len() as u32).to_be_bytes()[1..].to_vec();
frame.push(1);
frame.push(parser::FLAG_END_STREAM | parser::FLAG_END_HEADERS);
frame.extend_from_slice(&stream_id.to_be_bytes());
frame.extend_from_slice(&block);
connection.socket.inbound.extend(frame);
}
for _ in 0..8 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
}
assert_eq!(
connection.core.stream_table.len(),
2,
"premise: both requests were read"
);
(pool, connection, context, router, peer)
}
/// Drive the connection the way `Mux` does, `writable()` and
/// `readable()` when the filtered readiness allows them.
fn drive_both_ways(
connection: &mut H2Shell<PacedSocket>,
context: &mut Context<TestListener>,
router: &mut Router,
) {
for _ in 0..16 {
connection.core.readiness.event.insert(Ready::WRITABLE);
if connection.core.readiness.filter_interest().is_writable() {
connection.writable(context, EndpointClient(router));
}
connection.core.readiness.event.insert(Ready::READABLE);
if connection.core.readiness.filter_interest().is_readable() {
connection.readable(context, EndpointClient(router));
}
}
}
/// Decode every HEADERS on `wire` with ONE decoder, as the peer does.
/// Answers `(stream id, decoded)` per HEADERS and the `:status` values read.
fn peer_header_blocks(wire: &[u8]) -> (Vec<(u32, bool)>, Vec<Vec<u8>>) {
let mut decoder = crate::protocol::mux::hpack::Decoder::new();
let mut seen = Vec::new();
let mut status = Vec::new();
for (kind, _, stream_id, payload) in peer_frames(wire).unwrap() {
if kind == 1 {
let decoded = decoder.decode_with_cb(&payload, |name, value| {
if &*name == b":status" {
status.push(value.into_owned());
}
});
seen.push((stream_id, decoded.is_ok()));
}
}
(seen, status)
}
/// A header block the encoder produced but the wire never started is
/// dropped with its stream, and the peer's HPACK table must not fall
/// behind for it (sozu-proxy/sozu#1627, path 1): the peer's RST_STREAM(1)
/// removes the parked stream 1, and stream 3's block must still decode.
///
/// TO SEE THIS RED: in `ConnectionH2::poll_write_target`'s
/// `H2WritePhase::Start` arm, delete the `self.reset_encoder_table()` call.
/// The peer then fails to decode stream 3's block: `left: [(3, false)],
/// right: [(3, true)]`. Verified 2026-09-27 (red on `58550dd4`).
#[test]
fn a_header_block_dropped_with_its_stream_keeps_the_peer_table_in_sync() {
let (seen, status) =
peer_reads_after_a_dropped_parked_block(|connection, context, router| {
connection.socket.inbound.extend(RST_STREAM_1);
for _ in 0..4 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(context, EndpointClient(router));
}
assert!(
!connection.core.stream_table.streams().contains_key(&1),
"premise: the RST removed stream 1"
);
});
assert_eq!(
seen,
vec![(3, true)],
"the peer must decode stream 3's block with its own table"
);
assert_eq!(status, vec![b"404".to_vec()], "and read what was encoded");
}
/// The park outlives a second write pass that is stalled too: that pass
/// resumes the parked stream, writes nothing, and parks it again, so it
/// must record again that the park holds an encoded block. The peer's
/// RST_STREAM(1) then removes it, and stream 3's block must still decode
/// (sozu-proxy/sozu#1627).
///
/// TO SEE THIS RED: in `ConnectionH2::poll_write_target`'s
/// `H2WritePhase::Resume` arm, delete the `self.parked_header_block = ..`
/// assignment before the stalled `return`. The peer then fails on stream
/// 3's block: `left: [(3, false)], right: [(3, true)]`. Verified
/// 2026-09-27.
#[test]
fn a_header_block_parked_across_two_stalled_passes_keeps_the_peer_table_in_sync() {
let (seen, status) =
peer_reads_after_a_dropped_parked_block(|connection, context, router| {
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(context, EndpointClient(router));
assert!(
connection.socket.wire.is_empty()
&& connection.core.stream_table.expect_write().is_some(),
"premise: the second pass resumed nothing and kept the park"
);
connection.socket.inbound.extend(RST_STREAM_1);
for _ in 0..4 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(context, EndpointClient(router));
}
assert!(
!connection.core.stream_table.streams().contains_key(&1),
"premise: the RST removed stream 1"
);
});
assert_eq!(
seen,
vec![(3, true)],
"the peer must decode stream 3's block with its own table"
);
assert_eq!(status, vec![b"404".to_vec()], "and read what was encoded");
}
/// An H2 backend connection (`Position::Client`, `Connected`, handshake
/// done) over a [`PacedSocket`] taking `budget` bytes.
fn paced_backend(pool: &Rc<RefCell<Pool>>, budget: usize) -> H2Shell<PacedSocket> {
let (socket, _peer) = connected_socket();
let backend = Rc::new(RefCell::new(crate::backends::Backend::new(
"relink-backend",
"127.0.0.1:2".parse().expect("backend address must parse"),
None,
None,
None,
)));
let mut registry = crate::protocol::mux::BackendRegistry::default();
let mut connection = H2Shell::new(
Ulid::generate(),
PacedSocket {
stream: socket,
budget,
wire: Vec::new(),
inbound: std::collections::VecDeque::new(),
},
Position::Client(
"relink-cluster".into(),
registry.id_for(&backend),
BackendStatus::Connected,
),
&mut PoolBufferSource::new(Rc::downgrade(pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, 9)),
Ready::READABLE | Ready::WRITABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection");
connection.core.state = H2State::Header;
connection
}
/// Queue a GET request carrying `x-api-key: <key>` on `gid`'s request
/// side, closed with END_STREAM, the way a relayed request reaches a
/// backend's write pass. The custom field is indexed into the encoder's
/// dynamic table, as a credential-like header would be.
fn queue_backend_request(
context: &mut Context<TestListener>,
gid: GlobalStreamId,
key: &'static [u8],
) {
let kawa = &mut context.streams[gid].front;
kawa.detached.status_line = kawa::StatusLine::Request {
version: kawa::Version::V20,
method: kawa::Store::Static(b"GET"),
uri: kawa::Store::Static(b"/"),
authority: kawa::Store::Static(b"example.com"),
path: kawa::Store::Static(b"/"),
};
kawa.push_block(kawa::Block::StatusLine);
kawa.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"x-api-key"),
val: kawa::Store::Static(key),
}));
kawa.push_block(kawa::Block::Flags(kawa::Flags {
end_body: false,
end_chunk: false,
end_header: true,
end_stream: true,
}));
kawa.parsing_phase = kawa::ParsingPhase::Terminated;
}
/// The #1632 starting point: a request linked to backend `backend`,
/// whose write pass HPACK-encoded its HEADERS while the socket took
/// nothing. Answers the request's global id and the exact bytes that
/// connection encoded for it.
fn park_an_encoded_request(
backend: &mut H2Shell<PacedSocket>,
context: &mut Context<TestListener>,
router: &mut Router,
) -> (GlobalStreamId, Vec<u8>) {
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
queue_backend_request(context, gid, b"alice-secret");
context.streams[gid].state = StreamState::Linked(mio::Token(1));
assert!(backend.start_stream(gid, context), "premise: stream 1");
backend.core.readiness.event.insert(Ready::WRITABLE);
backend.writable(context, EndpointClient(router));
let front = &context.streams[gid].front;
assert!(
backend.socket.wire.is_empty() && !front.out.is_empty() && !front.consumed,
"premise: the request was encoded and parked unsent"
);
let encoded: Vec<u8> = front
.as_io_slice()
.iter()
.flat_map(|slice| slice.iter().copied())
.collect();
(gid, encoded)
}
/// RFC 9113 §6.9: a proxied stream has an independent send window on each
/// connection. The client granted the maximum initial window on the
/// frontend connection; the H2 backend then advertises
/// `SETTINGS_INITIAL_WINDOW_SIZE = 2^31-1` too. Each value is within the
/// §6.9.2 ceiling, so the backend connection must acknowledge it and send
/// the request, not overflow a window it shares with the frontend leg and
/// answer GOAWAY.
///
/// TO SEE THIS RED: make `Stream::send_window_mut` and `Stream::split`
/// answer `front_window` for `Position::Client` too. The backend then
/// adds its delta to the client's 2^31-1: `the backend connection must
/// not answer a valid initial window with GOAWAY`. Verified 2026-10-01
/// (red on `3d3f077b`).
#[test]
fn a_backend_max_initial_window_is_independent_of_the_frontend_window() {
let pool = make_pool_for_invariant_16();
let mut backend = paced_backend(&pool, usize::MAX);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
// What a frontend connection whose client advertised 2^31-1 creates.
let gid = context
.create_stream(Ulid::generate(), FLOW_CONTROL_MAX_WINDOW)
.expect("test context must create a stream");
queue_backend_request(&mut context, gid, b"key");
context.streams[gid].state = StreamState::Linked(mio::Token(1));
assert!(backend.start_stream(gid, &mut context), "premise: stream 1");
let mut max_initial_window = vec![0, 4];
max_initial_window.extend_from_slice(&FLOW_CONTROL_MAX_WINDOW.to_be_bytes());
backend
.socket
.inbound
.extend(orphan_frame(4, 0, 0, 6, &max_initial_window));
for _ in 0..4 {
backend.core.readiness.event.insert(Ready::READABLE);
if backend.core.readiness.filter_interest().is_readable() {
backend.readable(&mut context, EndpointClient(&mut router));
}
}
backend.core.readiness.event.insert(Ready::WRITABLE);
backend.writable(&mut context, EndpointClient(&mut router));
let frames = peer_frames(&backend.socket.wire).expect("whole frames on the wire");
assert!(
!frames.iter().any(|(kind, ..)| *kind == 7),
"the backend connection must not answer a valid initial window with GOAWAY"
);
assert!(
frames
.iter()
.any(|(kind, flags, ..)| *kind == 4 && *flags == 1),
"the backend's SETTINGS are acknowledged"
);
assert!(
frames.iter().any(|(kind, _, id, _)| *kind == 1 && *id == 1),
"and the request leaves on stream 1"
);
}
/// A 4-octet WINDOW_UPDATE frame for `stream_id`.
fn window_update_frame(stream_id: u32, increment: u32) -> Vec<u8> {
orphan_frame(8, 0, stream_id, 4, &increment.to_be_bytes())
}
/// RFC 9113 §6.9 on the backend leg: `start_stream` sizes the stream's
/// send window from the initial window the BACKEND advertised (here
/// 1000, already received on a pooled connection), whatever the frontend
/// granted: larger (an H2 client at 2^31-1), the `1 << 16` an H1
/// frontend seeds, or nothing. Taking the frontend's value would let a
/// request body overrun the backend's window. The backend's WINDOW_UPDATE
/// credits that leg only.
///
/// TO SEE THIS RED: delete the `send_window_mut` assignment in
/// `ConnectionH2::start_stream`: `the backend leg starts at the
/// backend's window`, `left: 65535, right: 1000`. Verified 2026-10-01.
#[test]
fn a_backend_stream_window_is_sized_and_credited_by_the_backend_only() {
const BACKEND_WINDOW: u32 = 1000;
for front_window in [FLOW_CONTROL_MAX_WINDOW, 1 << 16, 0] {
let pool = make_pool_for_invariant_16();
let mut backend = paced_backend(&pool, usize::MAX);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let read = |backend: &mut H2Shell<PacedSocket>,
context: &mut Context<TestListener>,
router: &mut Router| {
for _ in 0..4 {
backend.core.readiness.event.insert(Ready::READABLE);
if backend.core.readiness.filter_interest().is_readable() {
backend.readable(context, EndpointClient(router));
}
}
};
let mut initial_window = vec![0, 4];
initial_window.extend_from_slice(&BACKEND_WINDOW.to_be_bytes());
backend
.socket
.inbound
.extend(orphan_frame(4, 0, 0, 6, &initial_window));
read(&mut backend, &mut context, &mut router);
let gid = context
.create_stream(Ulid::generate(), front_window)
.expect("test context must create a stream");
queue_backend_request(&mut context, gid, b"key");
context.streams[gid].state = StreamState::Linked(mio::Token(1));
assert!(backend.start_stream(gid, &mut context), "premise: stream 1");
assert_eq!(
context.streams[gid].back_window, BACKEND_WINDOW as i32,
"front window {front_window}: the backend leg starts at the backend's window"
);
backend.socket.inbound.extend(window_update_frame(1, 500));
read(&mut backend, &mut context, &mut router);
let stream = &context.streams[gid];
assert_eq!(
stream.back_window,
BACKEND_WINDOW as i32 + 500,
"front window {front_window}: the backend's WINDOW_UPDATE credits its leg"
);
assert_eq!(
stream.front_window,
i32::try_from(front_window).unwrap(),
"front window {front_window}: and leaves the frontend leg alone"
);
}
}
/// Feed `inbound` to a frontend connection carrying streams 1 and 3,
/// flush what it answers, and return the GOAWAY error codes it wrote.
fn goaway_codes_after(
connection: &mut H2Shell<PacedSocket>,
context: &mut Context<TestListener>,
router: &mut Router,
inbound: &[u8],
) -> Vec<u32> {
connection.socket.inbound.extend(inbound);
for _ in 0..4 {
connection.core.readiness.event.insert(Ready::READABLE);
if connection.core.readiness.filter_interest().is_readable() {
connection.readable(context, EndpointClient(router));
}
}
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(context, EndpointClient(router));
peer_frames(&connection.socket.wire)
.expect("whole frames on the wire")
.iter()
.filter(|(kind, ..)| *kind == 7)
.map(|(_, _, _, payload)| {
u32::from_be_bytes(payload[4..8].try_into().expect("GOAWAY error code"))
})
.collect()
}
/// A SETTINGS frame carrying only `SETTINGS_INITIAL_WINDOW_SIZE = value`.
fn initial_window_settings(value: u32) -> Vec<u8> {
let mut payload = vec![0, 4];
payload.extend_from_slice(&value.to_be_bytes());
orphan_frame(4, 0, 0, 6, &payload)
}
/// RFC 9113 §6.5.2: a `SETTINGS_INITIAL_WINDOW_SIZE` above 2^31-1 "MUST
/// be treated as a connection error of type FLOW_CONTROL_ERROR".
///
/// TO SEE THIS RED: answer the `SETTINGS_INITIAL_WINDOW_SIZE` arm of
/// `ConnectionH2::handle_settings_frame` with `H2Error::ProtocolError`:
/// `left: [1], right: [3]`. Verified 2026-10-01 (red on `c8779209`).
#[test]
fn an_initial_window_above_the_maximum_is_a_flow_control_error() {
let (_pool, mut connection, mut context, mut router, _peer) = two_requests_read(usize::MAX);
let codes = goaway_codes_after(
&mut connection,
&mut context,
&mut router,
&initial_window_settings(FLOW_CONTROL_MAX_WINDOW + 1),
);
assert_eq!(codes, vec![0x3], "GOAWAY(FLOW_CONTROL_ERROR)");
}
/// RFC 9113 §6.9.2: a SETTINGS change that pushes a stream window past
/// 2^31-1 is a connection error of type FLOW_CONTROL_ERROR. Stream 3's
/// window already stands at 2^31-1; raising the initial window by one
/// overflows it. The rejection is checked before any window moves, so
/// stream 1, walked first, keeps its window.
///
/// TO SEE THIS RED: in `ConnectionH2::update_initial_window_size`,
/// apply each delta as it is checked, returning on the first overflow:
/// `the rejected setting changes no stream window`, `left: 65536, right:
/// 65535`. Verified 2026-10-01 (red on `c8779209`).
#[test]
fn a_settings_change_overflowing_a_stream_window_is_a_flow_control_error() {
let (_pool, mut connection, mut context, mut router, _peer) = two_requests_read(usize::MAX);
let gid = |connection: &H2Shell<PacedSocket>, id: u32| {
*connection
.core
.stream_table
.streams()
.get(&id)
.expect("the stream is open")
};
let (first, third) = (gid(&connection, 1), gid(&connection, 3));
let mut inbound = orphan_frame(
8,
0,
3,
4,
&(FLOW_CONTROL_MAX_WINDOW - DEFAULT_INITIAL_WINDOW_SIZE).to_be_bytes(),
);
inbound.extend(initial_window_settings(DEFAULT_INITIAL_WINDOW_SIZE + 1));
let codes = goaway_codes_after(&mut connection, &mut context, &mut router, &inbound);
assert_eq!(
context.streams[third].front_window, FLOW_CONTROL_MAX_WINDOW as i32,
"premise: stream 3's window stands at 2^31-1"
);
assert_eq!(codes, vec![0x3], "GOAWAY(FLOW_CONTROL_ERROR)");
assert_eq!(
context.streams[first].front_window, DEFAULT_INITIAL_WINDOW_SIZE as i32,
"the rejected setting changes no stream window"
);
}
/// RFC 9113 §6.9 on the frontend leg: the client's WINDOW_UPDATE and
/// SETTINGS_INITIAL_WINDOW_SIZE move the stream's frontend window only.
#[test]
fn a_frontend_window_update_credits_the_frontend_leg_only() {
let (_pool, mut connection, mut context, mut router, _peer) = two_requests_read(usize::MAX);
let first = *connection
.core
.stream_table
.streams()
.get(&1)
.expect("the stream is open");
context.streams[first].back_window = 1234;
let mut initial_window = vec![0, 4];
initial_window.extend_from_slice(&100_000u32.to_be_bytes());
connection
.socket
.inbound
.extend(window_update_frame(1, 1000));
connection
.socket
.inbound
.extend(orphan_frame(4, 0, 0, 6, &initial_window));
for _ in 0..4 {
connection.core.readiness.event.insert(Ready::READABLE);
if connection.core.readiness.filter_interest().is_readable() {
connection.readable(&mut context, EndpointClient(&mut router));
}
}
let stream = &context.streams[first];
assert_eq!(
stream.front_window,
100_000 + 1000,
"the client's WINDOW_UPDATE and initial window move the frontend leg"
);
assert_eq!(stream.back_window, 1234, "and not the backend leg");
}
/// sozu-proxy/sozu#1632, GOAWAY form: the backend refuses the stream
/// (`last_stream_id` below it) after its HEADERS were encoded but not
/// sent. It was not processed, but its request cannot be sent anywhere
/// else: `front.out` holds this connection's frames and the blocks are
/// gone. It must be refused (REFUSED_STREAM toward the client, RFC 9113
/// §8.7), never queued for a re-link.
///
/// TO SEE THIS RED: in [`ConnectionH2::handle_goaway_frame`], test
/// `stream.front.consumed` alone. The stream is then re-linked with its
/// stale frames: `a request encoded for this backend must not be re-linked
/// after its GOAWAY`. Red on `d5161919`, verified 2026-09-28.
#[test]
fn a_request_encoded_for_a_backend_is_refused_on_goaway_not_relinked() {
let pool = make_pool_for_invariant_16();
let mut backend = paced_backend(&pool, 0);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let (gid, _) = park_an_encoded_request(&mut backend, &mut context, &mut router);
// GOAWAY(last_stream_id = 0, NO_ERROR): stream 1 was not processed.
backend
.socket
.inbound
.extend(orphan_frame(7, 0, 0, 8, &[0; 8]));
for _ in 0..4 {
backend.core.readiness.event.insert(Ready::READABLE);
if backend.core.readiness.filter_interest().is_readable() {
backend.readable(&mut context, EndpointClient(&mut router));
}
}
assert!(
!context.pending_links.contains(&gid),
"a request encoded for this backend must not be re-linked after its GOAWAY"
);
let stream = &context.streams[gid];
assert!(
stream.context.status == Some(503) && stream.state == StreamState::Unlinked,
"the client must be answered 503, got {:?} / {:?}",
stream.context.status,
stream.state
);
}
/// sozu-proxy/sozu#1632, H1 frontend form (found by the adversarial
/// review of the first fix): behind a GOAWAY that refuses a request its
/// H2 backend encoded but did not send, an H1 client must receive an
/// error response, not silence. The first fix answered with
/// `forcefully_terminate_answer(REFUSED_STREAM)`, an empty response kawa
/// in `Error`, which `ConnectionH1::writable` cannot write: it withdraws
/// WRITABLE and the client waits for its timeout.
///
/// TO SEE THIS RED: in [`ConnectionH2::handle_goaway_frame`], answer the
/// `front_bound_to_backend` branch with `forcefully_terminate_answer(..,
/// H2Error::RefusedStream)` as the consumed branch does. The H1 client
/// then reads nothing: `the H1 client must read an error response, got
/// ""`. Verified 2026-09-28.
#[test]
fn an_h1_client_behind_a_goaway_refusing_its_encoded_request_gets_a_response() {
use std::io::Read;
let pool = make_pool_for_invariant_16();
let mut backend = paced_backend(&pool, 0);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let (gid, _) = park_an_encoded_request(&mut backend, &mut context, &mut router);
let (socket, mut client) = connected_socket();
let mut frontend =
Connection::new_h1_server(Ulid::generate(), socket, Duration::from_secs(30));
assert_eq!(gid, 0, "premise: the H1 frontend serves slot 0");
backend
.socket
.inbound
.extend(orphan_frame(7, 0, 0, 8, &[0; 8]));
for _ in 0..4 {
backend.core.readiness.event.insert(Ready::READABLE);
if backend.core.readiness.filter_interest().is_readable() {
backend.readable(
&mut context,
crate::protocol::mux::connection::EndpointServer(&mut frontend),
);
}
}
assert!(
!context.pending_links.contains(&gid),
"premise: the stream was refused, not re-linked"
);
let mut received = Vec::new();
for _ in 0..16 {
frontend.readiness_mut().event.insert(Ready::WRITABLE);
if frontend.readiness().filter_interest().is_writable() {
frontend.writable(&mut context, EndpointClient(&mut router));
}
let mut buf = [0u8; 4096];
if let Ok(n) = client.read(&mut buf) {
received.extend_from_slice(&buf[..n]);
}
std::thread::yield_now();
}
assert!(
received.starts_with(b"HTTP/1.1 503"),
"the H1 client must read an error response, got {:?}",
String::from_utf8_lossy(&received)
);
}
/// sozu-proxy/sozu#1632, connection-loss form: the backend connection
/// goes away with the request encoded and parked, and
/// `H2Shell::close` hands the stream to its frontend, whose
/// `end_stream_decision` used to answer `Reconnect` on
/// `!front.consumed`. The request must be answered with an error instead.
///
/// TO SEE THIS RED: in `shared::end_stream_decision`, delete the
/// `stream.front_bound_to_backend` branch. The stream is then re-linked:
/// `a request encoded for a lost backend must not be re-linked`. Red on
/// `d5161919`, verified 2026-09-28.
#[test]
fn a_request_encoded_for_a_lost_backend_is_answered_not_relinked() {
let pool = make_pool_for_invariant_16();
let mut backend = paced_backend(&pool, 0);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let (gid, _) = park_an_encoded_request(&mut backend, &mut context, &mut router);
// What `H2Shell::close` does through `EndpointServer`: the frontend
// ends the stream the lost backend carried.
let (mut frontend, _peer) = test_h2_connection(&pool, None);
frontend.core.end_stream(gid, &mut context);
assert!(
!context.pending_links.contains(&gid),
"a request encoded for a lost backend must not be re-linked"
);
assert_eq!(
context.streams[gid].context.status,
Some(502),
"the client gets the answer a request that reached the backend gets"
);
}
/// sozu-proxy/sozu#1632, the property itself: no byte one backend
/// connection's encoder produced may leave on another connection. Here
/// the request encoded on backend A is offered to backend B, as a
/// re-link would; B must refuse it and write nothing of A's.
///
/// TO SEE THIS RED: in [`ConnectionH2::start_stream`], delete the
/// `front.out` guard. B then sends A's frames — stream id 1 of A, a field
/// block encoded against A's table: `backend B must not send what
/// backend A encoded`. Red on `d5161919`, verified 2026-09-28.
#[test]
fn a_request_encoded_for_one_backend_never_reaches_another() {
let pool = make_pool_for_invariant_16();
let mut first = paced_backend(&pool, 0);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let (gid, encoded) = park_an_encoded_request(&mut first, &mut context, &mut router);
assert!(
peer_frames(&encoded)
.expect("whole frames")
.iter()
.any(|(kind, _, id, _)| *kind == 1 && *id == 1),
"premise: backend A encoded a HEADERS frame for its stream 1"
);
let mut second = paced_backend(&pool, usize::MAX);
let started = second.start_stream(gid, &mut context);
drive_both_ways(&mut second, &mut context, &mut router);
assert!(
!second
.socket
.wire
.windows(encoded.len())
.any(|window| window == encoded.as_slice()),
"backend B must not send what backend A encoded: {:?}",
peer_frames(&second.socket.wire)
);
assert!(
!started,
"backend B must refuse a request encoded for another connection"
);
}
/// Queue on `gid`'s response side a 200 whose field block is `fields`,
/// closed with END_STREAM, the way a relayed response reaches the write
/// pass.
fn queue_response(
connection: &mut H2Shell<PacedSocket>,
context: &mut Context<TestListener>,
gid: GlobalStreamId,
fields: Vec<(&'static [u8], kawa::Store)>,
) {
let stream = &mut context.streams[gid];
let kawa = &mut stream.back;
kawa.detached.status_line = kawa::StatusLine::Response {
version: kawa::Version::V20,
code: 200,
status: kawa::Store::Static(b"200"),
reason: kawa::Store::Empty,
};
kawa.push_block(kawa::Block::StatusLine);
for (name, value) in fields {
kawa.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(name),
val: value,
}));
}
kawa.push_block(kawa::Block::Flags(kawa::Flags {
end_body: false,
end_chunk: false,
end_header: true,
end_stream: true,
}));
kawa.parsing_phase = kawa::ParsingPhase::Terminated;
stream.state = StreamState::Unlinked;
connection.core.readiness.arm_writable();
}
/// A block dropped for exceeding `MAX_HEADER_LIST_SIZE` re-arms a table
/// reset that must outlive the write pass when no other block of that
/// pass carries it (sozu-proxy/sozu#1627, path 2). Stream 1's response
/// inserts `x-a: 1`, then an `etag` value past the limit aborts it into a
/// RST_STREAM, and it is the only header block of its pass. Stream 3's
/// response, answered afterwards, encodes `x-a: 1` again, and the peer
/// must read it: its block has to open with the reset.
///
/// TO SEE THIS RED: in `ConnectionH2::poll_write_target`'s
/// `H2WritePhase::End` arm, restore the pre-image
/// `if converter_pass.size_update_emitted() { self.pending_table_size_update
/// = None; }` instead of taking `converter_pass.pending_table_size_update()`.
/// The reset is then lost with the pass: `the reset is still owed after
/// the pass`, `left: None, right: Some(4096)`. Verified 2026-09-27.
#[test]
fn an_oversized_block_reset_outlives_the_pass_that_dropped_it() {
let (_pool, mut connection, mut context, mut router, _peer) = two_requests_read(usize::MAX);
let gid = |connection: &H2Shell<PacedSocket>, id: u32| {
*connection
.core
.stream_table
.streams()
.get(&id)
.expect("the stream is open")
};
let (first, third) = (gid(&connection, 1), gid(&connection, 3));
let oversized = vec![b'v'; MAX_HEADER_LIST_SIZE + 1];
queue_response(
&mut connection,
&mut context,
first,
vec![
(b"x-a", kawa::Store::Static(b"1")),
(b"etag", kawa::Store::from_slice(&oversized)),
],
);
drive_both_ways(&mut connection, &mut context, &mut router);
assert!(
peer_frames(&connection.socket.wire)
.unwrap()
.iter()
.any(|(kind, _, id, _)| *kind == 3 && *id == 1),
"premise: stream 1 was reset without HEADERS"
);
assert_eq!(
connection.core.pending_table_size_update,
Some(4096),
"the reset is still owed after the pass"
);
queue_response(
&mut connection,
&mut context,
third,
vec![(b"x-a", kawa::Store::Static(b"1"))],
);
drive_both_ways(&mut connection, &mut context, &mut router);
let (seen, status) = peer_header_blocks(&connection.socket.wire);
assert_eq!(
seen,
vec![(3, true)],
"the peer must decode stream 3's block with its own table"
);
assert_eq!(status, vec![b"200".to_vec()]);
}
/// A response body 1000 octets past the default window, so one prepare
/// takes the whole connection window and leaves the rest in the blocks.
/// A `Store::Static`, because kawa 0.7.1's `Store::Alloc` arm of
/// `Store::consume` subtracts the length before it adds the index back,
/// which overflows in a debug build once the converter has split the
/// chunk (CleverCloud/kawa#22).
static WINDOW_SIZED_BODY: [u8; DEFAULT_INITIAL_WINDOW_SIZE as usize + 1000] =
[b'b'; DEFAULT_INITIAL_WINDOW_SIZE as usize + 1000];
/// Queue on `gid`'s response side a 200 carrying `body`, closed with
/// END_STREAM, the way a relayed response with a payload reaches the
/// write pass.
fn queue_response_with_body(
connection: &mut H2Shell<PacedSocket>,
context: &mut Context<TestListener>,
gid: GlobalStreamId,
body: &'static [u8],
) {
let stream = &mut context.streams[gid];
let kawa = &mut stream.back;
kawa.detached.status_line = kawa::StatusLine::Response {
version: kawa::Version::V20,
code: 200,
status: kawa::Store::Static(b"200"),
reason: kawa::Store::Empty,
};
kawa.push_block(kawa::Block::StatusLine);
kawa.push_block(kawa::Block::Flags(kawa::Flags {
end_body: false,
end_chunk: false,
end_header: true,
end_stream: false,
}));
kawa.push_block(kawa::Block::Chunk(kawa::Chunk {
data: kawa::Store::Static(body),
}));
kawa.push_block(kawa::Block::Flags(kawa::Flags {
end_body: true,
end_chunk: false,
end_header: false,
end_stream: true,
}));
kawa.parsing_phase = kawa::ParsingPhase::Terminated;
stream.state = StreamState::Unlinked;
connection.core.readiness.arm_writable();
}
/// The DATA payload octets `wire` carries for `stream_id`.
fn data_on_wire(wire: &[u8], stream_id: u32) -> usize {
peer_frames(wire)
.unwrap()
.iter()
.filter(|(kind, _, id, _)| *kind == 0 && *id == stream_id)
.map(|(_, _, _, payload)| payload.len())
.sum()
}
/// Stream 1's response body fills the whole connection send window in
/// one prepare, the socket takes `budget` octets, and the stream is
/// parked with the rest still queued. Its removal drops what the wire
/// has not started.
fn park_a_window_sized_body_on_stream_1(
budget: usize,
) -> (
H2Shell<PacedSocket>,
Context<TestListener>,
Router,
GlobalStreamId,
GlobalStreamId,
) {
let (_pool, mut connection, mut context, mut router, _peer) = two_requests_read(budget);
let gid = |connection: &H2Shell<PacedSocket>, id: u32| {
*connection
.core
.stream_table
.streams()
.get(&id)
.expect("the stream is open")
};
let (first, third) = (gid(&connection, 1), gid(&connection, 3));
queue_response_with_body(&mut connection, &mut context, first, &WINDOW_SIZED_BODY);
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(&mut context, EndpointClient(&mut router));
assert!(
connection.socket.wire.len() == budget
&& connection.core.stream_table.expect_write().is_some(),
"premise: stream 1's DATA was prepared and parked, the socket took {budget} octets"
);
assert_eq!(
connection.core.flow_control.window(),
0,
"premise: preparing stream 1's body took the whole connection window"
);
(connection, context, router, first, third)
}
/// RFC 9113 §6.9: the connection send window accounts for the DATA that
/// was SENT. DATA prepared on stream 1 and dropped unsent when the peer
/// resets it never reaches the peer, which will therefore never return
/// that credit, so the connection must take it back itself; otherwise
/// the window stays at zero for the life of the connection and stream 3's
/// body never leaves (sozu-proxy/sozu#1641).
///
/// TO SEE THIS RED: in `ConnectionH2::remove_dead_stream`, delete the
/// `self.refund_parked_data(parked_data)` call. The removal then gives
/// nothing back: `the removal itself gives the credit back`, `left: 0,
/// right: 65535`. Without the fix at all, stream 3 also sends nothing
/// (`left: 0, right: 1000`). Delete the `self.readiness.arm_writable()`
/// in `ConnectionH2::refund_parked_data` instead: `and re-arms WRITABLE`
/// fails. Verified 2026-09-28 (red on `5db53c60`).
#[test]
fn data_dropped_with_its_parked_stream_gives_the_connection_window_back() {
let (mut connection, mut context, mut router, _first, third) =
park_a_window_sized_body_on_stream_1(0);
// A zero-progress pass may release WRITABLE; only the refund may
// bring it back here.
connection.core.readiness.interest.remove(Ready::WRITABLE);
connection.socket.inbound.extend(RST_STREAM_1);
for _ in 0..4 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
}
assert!(
!connection.core.stream_table.streams().contains_key(&1),
"premise: the RST removed stream 1"
);
assert_eq!(
connection.core.flow_control.window(),
DEFAULT_INITIAL_WINDOW_SIZE as i32,
"the removal itself gives the credit back, before any write pass"
);
assert!(
connection.core.readiness.interest.is_writable(),
"and re-arms WRITABLE for the streams the closed window starved"
);
connection.socket.budget = usize::MAX;
queue_response_with_body(&mut connection, &mut context, third, &[b'c'; 1000]);
drive_both_ways(&mut connection, &mut context, &mut router);
let wire = &connection.socket.wire;
assert_eq!(data_on_wire(wire, 1), 0, "stream 1's DATA never left");
assert_eq!(
data_on_wire(wire, 3),
1000,
"stream 3's body must not wait on credit spent on dropped DATA"
);
assert_eq!(
i64::from(connection.core.flow_control.window())
+ (data_on_wire(wire, 1) + data_on_wire(wire, 3)) as i64,
i64::from(DEFAULT_INITIAL_WINDOW_SIZE),
"the window is debited for the DATA octets sent, and only those"
);
}
/// The same drop without a removal: a forced termination clears the
/// parked stream's queue and keeps the stream, which then sends its
/// RST_STREAM. Both the connection's and the stream's credit for the
/// dropped DATA come back (sozu-proxy/sozu#1641).
///
/// TO SEE THIS RED: in `ConnectionH2::poll_write_target`'s
/// `H2WritePhase::Start` arm, delete the `self.refund_parked_data(dropped)`
/// call: `the connection window is back where it was before the
/// prepare`, `left: 0, right: 65535`. Delete the `*parts.window` refund
/// beside it instead: `the stream's window is back where it was before
/// the prepare`, `left: 0, right: 65535`. Verified 2026-09-28 (red on
/// `5db53c60`).
#[test]
fn data_dropped_from_a_live_parked_stream_gives_both_windows_back() {
let (mut connection, mut context, mut router, first, _third) =
park_a_window_sized_body_on_stream_1(0);
crate::protocol::mux::answers::forcefully_terminate_answer(
&mut context.streams[first],
&mut connection.core.readiness,
H2Error::InternalError,
);
connection.socket.budget = usize::MAX;
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(&mut context, EndpointClient(&mut router));
assert_eq!(
context.streams[first].front_window, DEFAULT_INITIAL_WINDOW_SIZE as i32,
"the stream's window is back where it was before the prepare"
);
drive_both_ways(&mut connection, &mut context, &mut router);
assert_eq!(
data_on_wire(&connection.socket.wire, 1),
0,
"premise: stream 1's DATA never left"
);
assert_eq!(
connection.core.flow_control.window(),
DEFAULT_INITIAL_WINDOW_SIZE as i32,
"the connection window is back where it was before the prepare"
);
}
/// The peer grows the connection window to 2^31-1 while stream 1's
/// 65535 prepared octets sit parked, then resets the stream. The window
/// that really stands is 2^31-1 plus those octets, past the RFC 9113
/// §6.9.1 ceiling, and the refund is where that shows: the connection
/// answers GOAWAY(FLOW_CONTROL_ERROR), as it does for an overflowing
/// WINDOW_UPDATE, instead of asserting on what the peer sent.
///
/// TO SEE THIS RED: in `H2FlowControl::refund_send_window`, put back a
/// `debug_assert!(false, ..)` on the `checked_add` overflow: the test
/// panics on `a refund of consumed credit cannot overflow the send
/// window`, which is how `08730c9a` behaved. In
/// `ConnectionH2::refund_parked_data`, answer `None` on the overflow
/// instead of the GOAWAY: `exactly one GOAWAY`, `left: 0, right: 1`.
/// Verified 2026-09-28.
#[test]
fn a_refund_the_peer_pushed_past_the_window_ceiling_is_a_flow_control_error() {
let (mut connection, mut context, mut router, _first, _third) =
park_a_window_sized_body_on_stream_1(0);
// WINDOW_UPDATE(stream 0, 2^31-1), then RST_STREAM(1, CANCEL).
connection
.socket
.inbound
.extend(b"\x00\x00\x04\x08\x00\x00\x00\x00\x00\x7f\xff\xff\xff");
connection.socket.inbound.extend(RST_STREAM_1);
for _ in 0..4 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
}
assert_eq!(
connection.core.flow_control.window(),
i32::MAX,
"premise: the peer's WINDOW_UPDATE was accepted, and the refund not applied"
);
connection.socket.budget = usize::MAX;
drive_both_ways(&mut connection, &mut context, &mut router);
let goaways: Vec<Vec<u8>> = peer_frames(&connection.socket.wire)
.unwrap()
.into_iter()
.filter(|(kind, _, _, _)| *kind == 7)
.map(|(_, _, _, payload)| payload)
.collect();
assert_eq!(goaways.len(), 1, "exactly one GOAWAY");
assert_eq!(
&goaways[0][4..8],
&3u32.to_be_bytes(),
"the GOAWAY carries FLOW_CONTROL_ERROR"
);
}
/// A second write pass that is stalled too resumes the parked stream,
/// writes nothing and parks it again: every prepared DATA octet is still
/// queued, so nothing is given back and both windows stay at zero
/// (sozu-proxy/sozu#1641). Only DATA no longer queued is refunded.
///
/// TO SEE THIS RED: in `ConnectionH2::poll_write_target`'s
/// `H2WritePhase::Start` arm, refund `parked_data` instead of
/// `parked_data - queued`: `left: 65535, right: 0`. Verified 2026-09-28.
#[test]
fn data_still_parked_after_a_second_stalled_pass_is_not_given_back() {
let (mut connection, mut context, mut router, first, _third) =
park_a_window_sized_body_on_stream_1(0);
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(&mut context, EndpointClient(&mut router));
assert!(
connection.socket.wire.is_empty()
&& connection.core.stream_table.expect_write().is_some(),
"premise: the second pass resumed nothing and kept the park"
);
assert_eq!(
connection.core.flow_control.window(),
0,
"the connection window still owes every queued DATA octet"
);
assert_eq!(
context.streams[first].front_window, 0,
"and so does the stream's"
);
}
/// A write that cuts stream 1's first DATA frame hands the rest of that
/// frame to the ordered output queue, which sends it whole even after
/// the stream is gone (sozu-proxy/sozu#1625). Only the frames behind it,
/// which the wire never started, are given back: the window still
/// accounts for exactly the DATA octets sent (sozu-proxy/sozu#1641).
///
/// TO SEE THIS RED: in `ConnectionH2::remove_dead_stream`, give back the
/// whole prepare instead of the parked DATA
/// (`self.refund_parked_data(DEFAULT_INITIAL_WINDOW_SIZE as i32)`). The
/// window then exceeds the peer's grant by the cut frame's payload:
/// `left: 81919, right: 65535`. Without the fix at all it falls short by
/// the dropped frames instead: `left: 16384, right: 65535`. Verified
/// 2026-09-28 (red on `5db53c60`).
#[test]
fn data_a_partial_write_started_is_sent_whole_and_not_given_back() {
// A 10-octet HEADERS (`:status: 200`), then 109 octets of the first
// DATA frame: its header and 100 octets of payload.
let budget = 10 + 9 + 100;
let (mut connection, mut context, mut router, _first, _third) =
park_a_window_sized_body_on_stream_1(budget);
connection.socket.inbound.extend(RST_STREAM_1);
for _ in 0..4 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
}
assert!(
!connection.core.stream_table.streams().contains_key(&1),
"premise: the RST removed stream 1"
);
connection.socket.budget = usize::MAX;
drive_both_ways(&mut connection, &mut context, &mut router);
let sent = data_on_wire(&connection.socket.wire, 1);
assert_eq!(
sent, 16_384,
"the cut frame left whole, and nothing after it"
);
assert_eq!(
i64::from(connection.core.flow_control.window()) + sent as i64,
i64::from(DEFAULT_INITIAL_WINDOW_SIZE),
"the window is debited for the DATA octets sent, and only those"
);
}
/// An H1 backend answers chunked with `Connection: close`, sends the
/// last chunk and a first trailer line, then closes before the final
/// CRLF. The trailer block can never be completed, and the converter
/// defers a field block until its closing flags are queued
/// (sozu-proxy/sozu#1627). `ConnectionH2::end_stream`'s
/// `EndStreamAction::CloseDelimited` arm must not leave the stream
/// waiting on that block: the chunked body was cut short, so it ends
/// with RST_STREAM, exactly as `ConnectionH1::terminate_close_delimited`
/// (`lib/src/protocol/mux/h1.rs`) does, and the unfinished trailer is
/// dropped unencoded, so the next response still decodes on the peer.
///
/// Red on `d12c3299`, which deferred the trailer with no way out: the
/// stream stayed open with its trailer queued forever, `stream 1 must end
/// with RST_STREAM`, frames `[(1, 0), (0, 0)]`. TO SEE THIS RED now: in
/// `ConnectionH2::end_stream`, delete the `EndStreamAction::CloseDelimited`
/// arm guarded by `body_size != kawa::BodySize::Empty`. The truncated
/// response then ends silently: frames `[(1, 0), (0, 0), (0, 1)]`.
/// Verified 2026-09-27.
#[test]
fn a_chunked_response_closed_inside_its_trailers_ends_with_a_reset() {
let (_pool, mut connection, mut context, mut router, _peer) = two_requests_read(usize::MAX);
let gid = |connection: &H2Shell<PacedSocket>, id: u32| {
*connection
.core
.stream_table
.streams()
.get(&id)
.expect("the stream is open")
};
let (first, third) = (gid(&connection, 1), gid(&connection, 3));
{
let stream = &mut context.streams[first];
stream.context.keep_alive_backend = false;
let bytes: &[u8] = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\nx-checksum: abc\r\n";
let kawa = &mut stream.back;
kawa.storage.space()[..bytes.len()].copy_from_slice(bytes);
kawa.storage.fill(bytes.len());
kawa::h1::parse(kawa, &mut kawa::h1::NoCallbacks);
assert_eq!(
kawa.parsing_phase,
kawa::ParsingPhase::Trailers,
"premise: the backend stopped inside the trailers"
);
stream.state = StreamState::Unlinked;
}
connection.core.readiness.arm_writable();
drive_both_ways(&mut connection, &mut context, &mut router);
connection.core.end_stream(first, &mut context);
drive_both_ways(&mut connection, &mut context, &mut router);
let frames: Vec<(u8, u8)> = peer_frames(&connection.socket.wire)
.unwrap()
.iter()
.filter(|(_, _, id, _)| *id == 1)
.map(|(kind, flags, _, _)| (*kind, flags & parser::FLAG_END_STREAM))
.collect();
assert_eq!(
frames.last().map(|(kind, _)| *kind),
Some(0x3),
"stream 1 must end with RST_STREAM, frames {frames:?}"
);
assert!(
!frames.iter().any(|(_, end_stream)| *end_stream != 0),
"a truncated chunked body never ends cleanly: {frames:?}"
);
assert!(
!connection.core.stream_table.streams().contains_key(&1),
"stream 1 is retired"
);
queue_response(
&mut connection,
&mut context,
third,
vec![(b"x-checksum", kawa::Store::Static(b"abc"))],
);
drive_both_ways(&mut connection, &mut context, &mut router);
let (seen, status) = peer_header_blocks(&connection.socket.wire);
assert_eq!(
seen,
vec![(1, true), (3, true)],
"every block the peer reads decodes with its own table"
);
assert_eq!(status, vec![b"200".to_vec(), b"200".to_vec()]);
}
/// An H1 backend answers `Content-Length: 10` with `Connection: close`,
/// sends 4 body bytes, and its connection goes away without the EOF read
/// path ending the body (a socket error or a backend timeout), so
/// `ConnectionH2::end_stream` meets the response still in its body phase.
/// Its `EndStreamAction::CloseDelimited` arm must end the stream with
/// RST_STREAM: the body is 6 bytes short of its `content-length`, and
/// only a body with neither `Content-Length` nor chunked coding is
/// delimited by the close (RFC 9112 §6.3; RFC 9113 §8.1.1).
///
/// Red on `d5161919`, whose guard covered chunked only: stream 1 ended
/// with DATA carrying END_STREAM (sozu-proxy/sozu#1633).
#[test]
fn a_content_length_response_cut_short_ends_with_a_reset() {
let (_pool, mut connection, mut context, mut router, _peer) = two_requests_read(usize::MAX);
let first = *connection
.core
.stream_table
.streams()
.get(&1)
.expect("the stream is open");
{
let stream = &mut context.streams[first];
stream.context.keep_alive_backend = false;
let bytes: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabcd";
let kawa = &mut stream.back;
kawa.storage.space()[..bytes.len()].copy_from_slice(bytes);
kawa.storage.fill(bytes.len());
kawa::h1::parse(kawa, &mut kawa::h1::NoCallbacks);
assert!(
kawa.is_main_phase() && !kawa.is_terminated() && kawa.expects == 6,
"premise: the body is 6 bytes short, {:?}",
kawa.parsing_phase
);
stream.state = StreamState::Unlinked;
}
connection.core.readiness.arm_writable();
drive_both_ways(&mut connection, &mut context, &mut router);
connection.core.end_stream(first, &mut context);
drive_both_ways(&mut connection, &mut context, &mut router);
let frames: Vec<(u8, u8)> = peer_frames(&connection.socket.wire)
.unwrap()
.iter()
.filter(|(_, _, id, _)| *id == 1)
.map(|(kind, flags, _, _)| (*kind, flags & parser::FLAG_END_STREAM))
.collect();
assert_eq!(
frames.last().map(|(kind, _)| *kind),
Some(0x3),
"stream 1 must end with RST_STREAM, frames {frames:?}"
);
assert!(
!frames.iter().any(|(_, end_stream)| *end_stream != 0),
"a truncated Content-Length body never ends cleanly: {frames:?}"
);
}
/// The same block dropped while its stream stays parked:
/// `forcefully_terminate_answer` (a backend that closed mid-response)
/// clears the parked stream's `kawa.out`, which held the encoded block.
/// Stream 1 then ends with a RST_STREAM, and stream 3's block must still
/// decode on the peer (sozu-proxy/sozu#1627).
///
/// TO SEE THIS RED: in `ConnectionH2::poll_write_target`'s
/// `H2WritePhase::Start` arm, append `&& false` to the `.is_empty()` that
/// judges a park still in place. The peer then fails on stream 3's block:
/// `left: [(3, false)], right: [(3, true)]`, and this is the only test
/// that fails. Verified 2026-09-27.
#[test]
fn a_header_block_cleared_from_a_parked_stream_keeps_the_peer_table_in_sync() {
let (seen, status) =
peer_reads_after_a_dropped_parked_block(|connection, context, _router| {
let gid = *connection
.core
.stream_table
.streams()
.get(&1)
.expect("stream 1 is open");
forcefully_terminate_answer(
&mut context.streams[gid],
&mut connection.core.readiness,
H2Error::InternalError,
);
assert!(
context.streams[gid].back.out.is_empty()
&& connection.core.stream_table.expect_write().is_some(),
"premise: stream 1 stays parked with its block cleared"
);
});
assert_eq!(
seen,
vec![(3, true)],
"the peer must decode stream 3's block with its own table"
);
assert_eq!(status, vec![b"404".to_vec()], "and read what was encoded");
}
/// Resume-path bytes are not pass progress.
///
/// `finalize_write` reads `bytes_written_this_pass > 0` as
/// `made_progress`, and `made_progress && any_pending_back()` is LIFECYCLE
/// §9 invariant 16: it RETAINS `Ready::WRITABLE` because a pass that moved
/// bytes and left blocks behind is a voluntary scheduler yield that will
/// resume itself. The resume path's drain is not that. It happens in
/// `H2WritePhase::Resume`, before the scheduler pass opens at all; it is a
/// socket-backpressure catch-up rather than a
/// yield, and the stranded blocks it leaves belong to a stream the
/// converter never reached this pass. Counting it as progress would keep
/// WRITABLE armed on a connection whose scheduler pass did nothing, and
/// the next tick would find the same nothing — a spin, not a resume.
///
/// So `resume_bytes` is deliberately NOT accumulated into
/// `total_bytes_written`: only the main loop's `stream_bytes` is. With a
/// drained resume and a zero-byte scheduler pass, the correct answer is
/// `FinalizeAction::Quiesce`, which withdraws `Ready::WRITABLE` and waits
/// for an external trigger.
///
/// TO SEE THIS RED: in `begin_scheduler_pass`, seed the pass total from
/// the resume counter — insert `pass.total_bytes_written =
/// pass.resume_bytes;` immediately before
/// `pass.adopt_scheduler_pass(converter_pass, order, census);`, which is
/// where the pre-image seeded `let mut total_bytes_written: usize = 0;`
/// ahead of its per-stream loop. Both counters are fields of
/// `H2WritePass` and keeping them apart is the whole point, so the shape
/// a refactor unifying the two flush paths would reach for is exactly this
/// one statement. Measured: this test fails on its own first
/// assertion, `a pass whose scheduler loop wrote nothing must withdraw
/// WRITABLE interest`, with `left: Writable | Error | Hup, right: Error |
/// Hup`, and it is the ONLY failure in the suite.
/// No other test reaches the resume path at all, which is why the blast
/// radius is one.
#[test]
fn resume_path_bytes_do_not_make_the_pass_progress() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut context, gid, _peer) = writable_fixture(
&pool,
&[FIRST_BLOCK, SECOND_BLOCK],
&[(TOTAL_QUEUED, SocketResult::Continue)],
);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection
.core
.stream_table
.set_expect_write(Some(H2StreamId::Other {
id: REGISTERED_STREAM_ID,
gid,
}));
// A pending back-buffer, which is the OTHER half of invariant 16's
// condition. The flush drains `out` and never touches
// `blocks`, so this block survives the resume and makes
// `any_stream_has_pending_back` answer true — leaving `made_progress`
// as the single input that decides Quiesce against RetainPendingBack.
context.streams[gid]
.back
.blocks
.push_back(kawa::Block::StatusLine);
assert_prepare_gate_is_shut(&context, gid);
assert!(
connection.core.readiness.interest.is_writable(),
"premise: WRITABLE interest must start armed, or the withdrawal \
below is unobservable, got {:?}",
connection.core.readiness
);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert_eq!(
connection.core.readiness.interest,
Ready::HUP | Ready::ERROR,
"a pass whose scheduler loop wrote nothing must withdraw WRITABLE \
interest: the resume path's drain is socket catch-up, not the \
voluntary yield invariant 16 retains the bit for"
);
assert_eq!(
context.streams[gid].metrics.bout, TOTAL_QUEUED,
"premise: the resume path really did move every queued byte — \
without that this test would assert Quiesce over a pass that \
simply had nothing to write"
);
assert!(
!context.streams[gid].back.blocks.is_empty(),
"premise: the pending back-buffer must survive the pass, or \
any_stream_has_pending_back answers false and RetainPendingBack \
is unreachable whatever made_progress says"
);
assert_eq!(
connection.core.stream_table.expect_write(),
None,
"a drained resume clears the park before the scheduler pass"
);
assert_eq!(
connection.socket.vectored_calls, 1,
"the scheduler pass must find nothing to write: the resume drained \
`out`, and the prepare gate is shut"
);
assert!(
matches!(result, MuxResult::Continue),
"a quiesced write pass continues, got {result:?}"
);
}
// ── One pass, one prepare per stream ────────────────────────────────
//
// The four tests above pin the FLUSH half of a write pass. This one pins
// the PREPARE half against the same refactor: `write_streams` runs the
// gate at `if kawa.is_main_phase() || ...` — `kawa.prepare`, the
// `*parts.window` debit, `flow_control.consume_send_window`,
// `census.note_fired` and the `freshly_emitted_rsts` push — ONCE per
// stream per pass, in `H2WritePhase::Prepare` and outside the round-again
// `H2WritePhase::Flush` re-enters. A restructure that lifted those rounds
// up beside the prepare would debit the send window twice for
// one stream's single scheduling turn: a connection would believe it had
// spent credit it never put on the wire, and would stall itself short of
// the peer's real window.
//
// MEASURED, and it corrects the obvious way to write this test. A narrow
// per-stream window does NOT expose a double prepare. With
// `create_stream(ulid, 64)` against a 200-byte body and a two-round flush,
// production and an edit that visits every stream twice in one pass give
// the same `delta=64 vectored_calls=2 blocks_left=1 bout=73`. The send
// window is SELF-LIMITING: `*parts.window =
// parts.window.saturating_sub(consumed)` zeroes the stream's credit, the
// second prepare computes `min(0, connection_window) == 0`, the converter's
// `self.window > 0` gate returns the chunk untouched, and
// `consumed = 0 - 0 = 0`. A non-incremental prepare always takes every byte
// the window allows, so a second one has nothing left to take.
//
// INVISIBLE TO THOSE OBSERVABLES IS NOT HARMLESS, and the difference is
// the reason this block is worth reading. Two things DO move on that
// second visit, measured on the same fixture:
//
// - `converter.rs`'s flow-control-stall arm fires. It is the arm reached
// BECAUSE the window is zero, and its `incr!(names::h2::FLOW_CONTROL_STALL)`
// is unconditional once there — on `Position::Client` it also fires
// `incr!(names::backend::FLOW_CONTROL_PAUSED)`. Measured with a `panic!`
// planted on that `incr!`: the fixture does NOT reach it under
// production and DOES under the double visit. Those two counters are
// dashboard signals for a real RFC 9113 §6.9 condition, so a re-entrant
// write loop would inflate them with stalls that never happened on the
// wire — an operator would read backpressure into a connection that had
// none.
// - `context.debug.push(DebugEvent::S(..))` sits OUTSIDE the prepare
// gate's closing brace, so it fires once per VISIT, not once per
// prepare: measured `s_events` 1 -> 2 while `io_events` stays 2. Every
// extra entry evicts a real one from the bounded `DebugHistory` ring.
//
// So the correct claim is narrow: the BYTES are idempotent, the
// TELEMETRY is not.
//
// And the bytes are idempotent by FOUR independent mechanisms, only one of
// which is the window — a future reader must not conclude the window is
// the guard:
//
// 1. `consumed` debits `*parts.window` AND `flow_control`, which is the
// self-limiting above;
// 2. `H2Scheduler::note_fired` gates its bucket's round-robin lead on
// `consumed > 0`, so a zero-consumption re-entry cannot advance the
// cursor;
// 3. the `rst_sent` `HashSet` in `H2StreamTable` dedups RST_STREAM.
// This one is load-bearing rather than incidental:
// `H2BlockConverter::initialize` pushes a RST_STREAM frame on EVERY
// prepare of an errored kawa with no window gate whatsoever, so
// without that set a second visit would put a duplicate RST on the
// wire regardless of the send window;
// 4. `std::mem::take(&mut parts.context.headers_response)` drains the
// response-header edits, so a re-entry cannot apply them twice.
//
// The RFC 9218 §4 incremental yield is the one path that stops a prepare
// with BOTH window and blocks remaining: `H2BlockConverter`'s DATA arm
// returns `can_continue && !yield_after_data`, and with
// `incremental_mode && incremental_peer_count > 1` it yields after a
// single DATA frame while the window is still wide open. That is why this
// test registers a same-urgency incremental PAIR rather than one stream,
// and why the body must exceed `SETTINGS_MAX_FRAME_SIZE` (16 384) so one
// frame cannot carry it.
/// A response body larger than the default 16 384-byte
/// `SETTINGS_MAX_FRAME_SIZE`, so one DATA frame cannot carry it and the
/// incremental yield leaves the remainder in `blocks`.
static YIELDING_BODY: [u8; 40_000] = [b'x'; 40_000];
/// Payload bytes one yielded prepare converts: the default
/// `SETTINGS_MAX_FRAME_SIZE`, which bounds the frame before the window does.
const YIELDED_PAYLOAD: i32 = 16_384;
/// Bytes one yielded prepare puts in `kawa.out`: the DATA frame header
/// plus its payload.
const YIELDED_FRAME: usize = parser::FRAME_HEADER_SIZE + YIELDED_PAYLOAD as usize;
/// Two registered, same-urgency INCREMENTAL server streams, each carrying
/// `YIELDING_BODY` as one un-prepared chunk and each granted a send window
/// far wider than a single frame.
///
/// The width is the point: a narrow window would make the prepare stop
/// because it ran out of credit, and a second prepare would then be a
/// no-op whether or not the code is correct. Here the prepare stops
/// because it YIELDED, so credit survives it and a second one would spend
/// more.
fn incremental_pair_fixture(
pool: &Rc<RefCell<Pool>>,
script: &[(usize, SocketResult)],
) -> (
H2Shell<BackpressuredTlsSocket>,
Context<TestListener>,
[GlobalStreamId; 2],
std::net::TcpStream,
) {
let (mut connection, peer) = connection_with_backpressure(pool, 0, 0, H2State::Header);
connection.socket.vectored_script = script.iter().copied().collect();
let mut context = test_context(pool);
let mut gids = [0usize; 2];
for (slot, stream_id) in [1u32, 3u32].into_iter().enumerate() {
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
gids[slot] = gid;
connection
.core
.stream_table
.register(stream_id, gid, connection.core.now);
// Same urgency for both, so they share one bucket and the pass
// census answers `incremental_peer_count == 2`. At 1 the
// converter's solo-bucket guard suppresses the yield and the
// prepare drains to the window, which is the configuration this
// test exists to avoid.
connection.core.scheduler.push_priority(
stream_id,
parser::PriorityPart::Rfc9218 {
urgency: 3,
incremental: true,
},
);
let back = &mut context.streams[gid].back;
back.parsing_phase = kawa::ParsingPhase::Body;
back.blocks.push_back(kawa::Block::Chunk(kawa::Chunk {
data: kawa::Store::Static(&YIELDING_BODY),
}));
}
(connection, context, gids, peer)
}
/// One write pass debits the connection send window once per stream, even
/// when that stream's flush goes round twice.
///
/// Stream 1's flush is scripted to take two rounds — `(20, WouldBlock)`
/// then the rest — so the pass contains exactly the round-again shape the
/// flush tests above pin, and this test says the prepare does NOT come
/// along for the second round. Stream 3's single round is the control:
/// both streams yield after one frame, and both must cost the same credit.
///
/// WHAT THE EXISTING FIXTURE CANNOT SEE. `a_partial_write_reporting_would_block_continues_the_pass`
/// drives the identical two-round flush and stays green under every edit
/// below: its stream's parsing phase leaves the prepare gate shut by
/// construction, so it never runs a prepare at all and no amount of
/// re-preparing is representable in it. Its through-`writable()` twin
/// cannot see it either, for the same reason.
///
/// TO SEE THIS RED: in `begin_scheduler_pass`, insert
/// `let order: Vec<StreamId> = order.iter().chain(order.iter()).copied().collect();`
/// immediately before `pass.adopt_scheduler_pass(converter_pass, order, census);`,
/// so each stream takes a second turn — prepare included — within one pass.
/// Measured: this test fails on its own first assertion, `one write pass
/// must debit the connection send window once per stream`, with
/// `left: 65535, right: 32768`, and it is the ONLY failure in the suite.
/// The four frames the mutated pass emits instead
/// of two also move `vectored_calls` from 3 to 5 and each stream's `bout`
/// from 16 393 to 32 786 / 32 785, so the window is not a privileged
/// witness here — but it is the only one that names the SPENT CREDIT, and
/// a window debited for bytes the wire never carried is the failure that
/// stalls a connection. The same assertion is also the third failure under
/// the pass-terminator edit documented on
/// `a_partial_write_reporting_would_block_continues_the_pass_through_writable`,
/// where it reads `left: 16384, right: 32768` because stream 1's stall
/// ends the pass before stream 3 is prepared.
///
/// This is NOT keyed on the partial write, and the name says so. The
/// inversion makes the rounds visible — `H2WritePhase::Flush` is re-entered
/// once per round — but the prepare is not in that phase to be repeated:
/// it lives in `H2WritePhase::Prepare`, which a round-again never returns
/// to. Forcing a second visit of the whole order is the smallest honest
/// statement of the same bug, and it is what a restructure that folded the
/// two phases together would actually produce.
#[test]
fn a_yielded_incremental_stream_is_prepared_once_per_pass() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut context, gids, _peer) = incremental_pair_fixture(
&pool,
&[
// Stream 1: a partial write that reports WouldBlock, then the
// remainder. Stream 3: one round.
(20, SocketResult::WouldBlock),
(99_999, SocketResult::Continue),
(99_999, SocketResult::Continue),
],
);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let window_before = connection.core.flow_control.window();
assert_eq!(
window_before, DEFAULT_INITIAL_WINDOW_SIZE as i32,
"premise: the connection send window starts at the RFC 9113 \
default, so the debit below is the pass's doing"
);
for gid in gids {
assert!(
context.streams[gid].back.is_main_phase(),
"premise: the prepare gate must be OPEN for both streams, or \
no window is spent at all"
);
}
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert_eq!(
window_before - connection.core.flow_control.window(),
2 * YIELDED_PAYLOAD,
"one write pass must debit the connection send window once per \
stream: a second prepare would spend credit for bytes this pass \
never put on the wire"
);
assert_eq!(
connection.socket.vectored_calls, 3,
"the pass must be two flush rounds for stream 1 and one for \
stream 3 — without the two-round flush this test would not be \
asserting anything about re-entry"
);
for gid in gids {
assert_eq!(
context.streams[gid].metrics.bout, YIELDED_FRAME,
"each yielded prepare puts exactly one DATA frame on the wire"
);
assert!(
!context.streams[gid].back.blocks.is_empty(),
"premise: the yield must leave the rest of the body queued — \
with empty blocks a second prepare could not spend anything \
and this test could not fail"
);
}
assert!(
matches!(result, MuxResult::Continue),
"a yielded write pass continues, got {result:?}"
);
}
/// The send window granted to the stream that makes this pass's progress.
/// Narrower than `YIELDING_BODY`, so its prepare converts
/// `PARTIAL_STREAM_WINDOW` payload bytes and leaves the rest in `blocks`.
const PARTIAL_STREAM_WINDOW: u32 = 64;
/// Bytes that stream reaches the socket with: the DATA frame header plus
/// its window-capped payload.
const PARTIAL_FRAME: usize = parser::FRAME_HEADER_SIZE + PARTIAL_STREAM_WINDOW as usize;
/// A pass's byte total accumulates across streams: a later stream that
/// writes nothing must not erase what an earlier one delivered.
///
/// `pass.total_bytes_written.saturating_add(pass.stream_bytes)` in
/// `H2WritePhase::Flush` is the whole of it, and nothing pinned it. `finalize_write` hands the
/// total to `h2_close::finalize_action` as `made_progress`, and
/// `made_progress && any_pending_back()` is LIFECYCLE §9 invariant 16 — the
/// arm that RETAINS `Ready::WRITABLE` for a pass that moved bytes and left
/// blocks behind. Drop the accumulation and a two-stream pass reports the
/// LAST stream's count: stream 1 delivers a frame and keeps its remainder
/// queued, stream 3 is eligible but window-blocked and writes nothing, the
/// total reads 0, `expect_write` is parked on neither so `Parked` cannot
/// mask it, `RetainPendingBack` is skipped and the pass falls through to
/// `Quiesce`. `Quiesce` strips `Ready::WRITABLE`; edge-triggered epoll
/// never re-fires; stream 1's body is stranded in `blocks` with nothing
/// scheduled to drain it. That is a hung response, not a lost metric.
///
/// The two streams differ only in their send window, which is what puts
/// one on each side of the accumulation: 64 bytes buys stream 1 one
/// window-capped DATA frame, and 0 buys stream 3 the converter's
/// flow-control-stall arm, which returns the chunk untouched and leaves
/// `kawa.out` empty so its flush never calls the socket at all.
///
/// TO SEE THIS RED: in `poll_write_target`'s `H2WritePhase::Flush` arm,
/// change the accumulation
/// `pass.total_bytes_written = pass.total_bytes_written.saturating_add(pass.stream_bytes);`
/// into `pass.total_bytes_written = pass.stream_bytes;` — the shape a
/// rewrite that folds the resume and main flush paths into one per-stream
/// counter naturally reaches for. Measured: this test fails on its own first
/// assertion, `a pass that delivered bytes and left blocks queued must
/// RETAIN WRITABLE interest`, with `left: Error | Hup, right: Writable |
/// Error | Hup`, and it is the only failure in the suite. Measured without
/// this test in the tree, that same edit leaves every other test green —
/// which is what makes this one worth its lines.
#[test]
fn a_later_zero_byte_stream_does_not_erase_the_pass_progress() {
let pool = make_pool_for_invariant_16();
let (mut connection, _peer) = connection_with_backpressure(&pool, 0, 0, H2State::Header);
// One entry: stream 1's frame. Stream 3 puts nothing in `kawa.out`, so
// its flush loop never runs and never reaches the socket.
connection.socket.vectored_script =
[(99_999, SocketResult::Continue)].into_iter().collect();
let mut context = test_context(&pool);
let mut gids = [0usize; 2];
for (slot, (stream_id, window)) in [(1u32, PARTIAL_STREAM_WINDOW), (3u32, 0)]
.into_iter()
.enumerate()
{
let gid = context
.create_stream(Ulid::generate(), window)
.expect("test context must create a stream");
gids[slot] = gid;
connection
.core
.stream_table
.register(stream_id, gid, connection.core.now);
let back = &mut context.streams[gid].back;
back.parsing_phase = kawa::ParsingPhase::Body;
back.blocks.push_back(kawa::Block::Chunk(kawa::Chunk {
data: kawa::Store::Static(&YIELDING_BODY),
}));
}
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert!(
connection.core.readiness.interest.is_writable(),
"premise: WRITABLE interest starts armed, so retaining it is not \
vacuous, got {:?}",
connection.core.readiness
);
let result = connection.writable(&mut context, EndpointClient(&mut router));
assert_eq!(
connection.core.readiness.interest,
Ready::WRITABLE | Ready::HUP | Ready::ERROR,
"a pass that delivered bytes and left blocks queued must RETAIN \
WRITABLE interest (LIFECYCLE §9 invariant 16): stripping it \
strands the remainder, because edge-triggered epoll will not \
re-fire on its own"
);
assert_eq!(
context.streams[gids[0]].metrics.bout, PARTIAL_FRAME,
"premise: the FIRST stream really did deliver a frame — the \
accumulation has nothing to preserve otherwise"
);
assert_eq!(
context.streams[gids[1]].metrics.bout, 0,
"premise: the SECOND stream really did write nothing, so it is the \
stream whose count would overwrite the total"
);
assert_eq!(
connection.socket.vectored_calls, 1,
"only the first stream reaches the socket: a zero window leaves \
`kawa.out` empty and its flush loop never runs"
);
for gid in gids {
assert!(
!context.streams[gid].back.blocks.is_empty(),
"premise: both streams must leave blocks queued, or \
any_pending_back answers false and RetainPendingBack is \
unreachable whatever the byte total says"
);
}
assert_eq!(
connection.core.stream_table.expect_write(),
None,
"premise: nothing is parked, so `Parked` cannot decide this pass \
ahead of the byte total"
);
assert!(
matches!(result, MuxResult::Continue),
"a retained write pass continues, got {result:?}"
);
}
// ── force_disconnect's server arm with records pending ──────────────
//
// `h2_close::force_disconnect_action` is exhaustively tabled over its two
// booleans in that module. Nothing called `ConnectionH2::force_disconnect`
// itself, so the CALLER half — the `H2Shell::tls_wants_write` query
// that feeds the table and the wiring of `ReArmAndContinue` to interest,
// flush and return value — had no coverage. #1454 asks for this site by
// name.
//
// Shape targeted: a SINGLE query, not a triple. `force_disconnect` reads
// the answer once into `tls_wants_write`, above its `match` and therefore
// for both position arms, feeds the table and reports the same value from
// every debug line; there is no `H2Shell::flush_tls_records` here and
// so no second question to reconstruct.
/// A server connection whose TLS records are still buffered re-arms
/// instead of closing the session.
///
/// `MuxResult::CloseSession` triggers `shutdown(Write)`, which sends FIN
/// and destroys whatever rustls is still holding — the client reads a
/// truncated response. So the live-peer, records-pending row of
/// `force_disconnect_action` must keep WRITABLE and let the writable path
/// flush.
///
/// TO SEE THIS RED: in `force_disconnect`'s `Position::Server` arm, pass
/// `false` in place of `tls_wants_write` to `force_disconnect_action`.
/// Measured: this test fails on its own first assertion, `a server still
/// holding TLS records must not close: CloseSession sends FIN and destroys
/// them, got CloseSession`, while `h2_close`'s own
/// `force_disconnect_arm_is_exhaustive` stays green — the table is not the
/// caller, which is why this test exists. Passing `true` instead would NOT
/// redden this test; catching that constant ON THIS DIRECT PATH is what
/// the sibling below is for — it is not the suite's only witness against
/// `true`, and its own doc says which other test is.
///
/// The interest assertion has its own recipe: delete
/// `self.readiness.interest = Ready::WRITABLE | Ready::HUP | Ready::ERROR;`
/// from the same arm. Measured: `the re-arm must restore WRITABLE interest
/// so the writable path is called again to flush`, `left: Hup, right:
/// Writable | Error | Hup`. That recipe only bites because the test
/// withdraws the bit first — asserting it against the fixture's own
/// constructor argument passed under this very mutation.
#[test]
fn force_disconnect_re_arms_while_tls_records_are_pending() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
// A kernel that accepts nothing, so the records outlive any flush.
let (mut connection, _peer) = connection_with_backpressure(&pool, 2, 0, H2State::GoAway);
assert!(
connection.socket.socket_wants_write(),
"premise: this harness must report buffered TLS records"
);
assert!(
!connection.core.peer_gone_after_final_goaway(),
"premise: the peer must still be live, or the table short-circuits \
to CloseSession on the first boolean and the record count is never \
read"
);
// Withdraw WRITABLE interest first. `connection_with_backpressure`
// hands `WRITABLE | HUP | ERROR` to `ConnectionH2::new` as the
// connection's INTEREST, so asserting on it untouched would be reading
// this fixture's own argument back and would hold even if the re-arm
// arm never assigned it. Starting from `HUP` alone makes the
// assignment the only thing that can restore the bit.
connection.core.readiness.interest = Ready::HUP;
assert!(
!connection.core.readiness.event.is_writable(),
"premise: the WRITABLE event must start clear, so the signal below \
is the only thing that can set it, got {:?}",
connection.core.readiness
);
let result = connection.force_disconnect();
assert!(
matches!(result, MuxResult::Continue),
"a server still holding TLS records must not close: CloseSession \
sends FIN and destroys them, got {result:?}"
);
assert_eq!(
connection.core.readiness.interest,
Ready::WRITABLE | Ready::HUP | Ready::ERROR,
"the re-arm must restore WRITABLE interest so the writable path is \
called again to flush, got {:?}",
connection.core.readiness
);
assert!(
connection.core.readiness.event.is_writable(),
"the re-arm must signal the edge-triggered WRITABLE event, or the \
event loop never revisits this connection, got {:?}",
connection.core.readiness
);
}
/// The same call with nothing buffered closes the session.
///
/// The other row of the table. It does NOT rest on being the only thing
/// that catches a hard-wired `true`: the suite already caught that
/// constant without it. Measured on that mutation, TWO tests fail — this
/// test AND
/// `a_flush_that_succeeds_closes_within_one_writable_call` above, which
/// predates it and reaches `force_disconnect` through `writable()`'s
/// GoAway fall-through, failing with `a drained flush must reach the
/// disconnect in the SAME writable call, not defer it to another tick:
/// got Continue`.
///
/// What this test adds is the PATH, not the count: it calls
/// `ConnectionH2::force_disconnect` directly, so a red here names the
/// query under test, where the fall-through route arrives through a write
/// pass and a state machine that can each shift for reasons of their own.
///
/// TO SEE THIS RED: pass `true` in place of `tls_wants_write` to
/// `force_disconnect_action` in `force_disconnect`'s `Position::Server`
/// arm. Measured: this test fails with `a server holding nothing must
/// close rather than wait for a flush that has nothing to flush, got
/// Continue`, while the sibling above stays green. The mirror constant
/// `false` is measured at exactly ONE failure, and that failure is
/// the sibling above — so neither constant can replace the query, and the
/// sibling is the suite's only witness against `false`.
#[test]
fn force_disconnect_closes_when_no_tls_records_are_pending() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
// Same fixture, zero records: the query answers `false`.
let (mut connection, _peer) = connection_with_backpressure(&pool, 0, 0, H2State::GoAway);
assert!(
!connection.socket.socket_wants_write(),
"premise: nothing buffered, so the table reads its second boolean \
as false"
);
assert!(
!connection.core.peer_gone_after_final_goaway(),
"premise: the peer must still be live, so the close comes from the \
record count and not from the first boolean"
);
let result = connection.force_disconnect();
assert!(
matches!(result, MuxResult::CloseSession),
"a server holding nothing must close rather than wait for a flush \
that has nothing to flush, got {result:?}"
);
}
/// A force-disconnect raised from the READ path still re-arms, because the
/// shell settles it before the pass returns.
///
/// The sibling above calls `H2Shell::force_disconnect` directly, where the
/// raise and the settlement are two statements of one function. This one
/// enters through `H2Shell::readable`, which is the shape that made the
/// inversion necessary: `ConnectionH2::handle_read` force-disconnects at
/// seven sites, its `pub` signature returns `MuxResult` and cannot carry an
/// `H2ForceDisconnectTarget`, so the core parks one and answers
/// provisionally. Nothing the core does afterwards writes `readiness`; only
/// `H2Shell::settled` does, and only if the shell remembers to call it.
///
/// `Mux` reads `Readiness::filter_interest` between entry points to decide
/// whether a connection gets a `writable` at all, so a settlement deferred
/// to the next entry point is not the same as one performed here: the
/// connection would be left with records in rustls and no WRITABLE
/// interest to come back on.
///
/// TO SEE THIS RED: in `H2Shell::readable`, return `result` instead of
/// `self.settled(result)`. Measured: this test fails on its interest
/// assertion, `a force-disconnect raised inside handle_read must still
/// re-arm WRITABLE interest before the read pass returns`, `left: Hup`,
/// `right: Writable | Error | Hup`, while
/// `force_disconnect_re_arms_while_tls_records_are_pending` stays green —
/// the direct call is not the read path, which is why both exist.
#[test]
fn a_force_disconnect_raised_inside_handle_read_is_settled_before_the_pass_returns() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
// A kernel that accepts nothing, so the records outlive any flush and
// `force_disconnect_action` takes its `ReArmAndContinue` row.
let (mut connection, mut peer) =
connection_with_backpressure(&pool, 2, 0, H2State::ClientPreface);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert!(
connection.socket.socket_wants_write(),
"premise: this harness must report buffered TLS records, or the \
settlement takes the closing row and writes no interest at all"
);
assert!(
!connection.core.peer_gone_after_final_goaway(),
"premise: the peer must still be live, or the decision \
short-circuits to CloseSession on its first boolean and never \
reads the record count"
);
// A preface whose very first octet is wrong: `handle_read`'s early
// guard refuses it and reaches `force_disconnect` without the
// connection ever leaving `H2State::ClientPreface` on its own.
let mut wire = Vec::with_capacity(CLIENT_PREFACE_SIZE);
wire.extend_from_slice(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n");
wire.extend_from_slice(&[0, 0, 0, 4, 0, 0, 0, 0, 0]);
wire[0] ^= 0xff;
peer.write_all(&wire).expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
// Withdraw WRITABLE interest first. The fixture hands
// `WRITABLE | HUP | ERROR` to the constructor, so asserting on it
// untouched would read the fixture's own argument back and would hold
// even if nothing settled. Starting from `HUP` alone makes the
// settlement the only thing that can restore the bit.
connection.core.readiness.interest = Ready::HUP;
assert!(
!connection.core.readiness.event.is_writable(),
"premise: the WRITABLE event must start clear, so the signal below \
is the only thing that can set it, got {:?}",
connection.core.readiness
);
let mut result = MuxResult::Continue;
for _ in 0..64 {
result = connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::Error) {
break;
}
std::thread::yield_now();
}
assert!(
matches!(connection.core.state, H2State::Error),
"premise: the read pass must have reached force_disconnect, which \
assigns H2State::Error, got {:?}",
connection.core.state
);
assert!(
matches!(result, MuxResult::Continue),
"a server still holding TLS records must not close: CloseSession \
sends FIN and destroys them, got {result:?}"
);
assert_eq!(
connection.core.readiness.interest,
Ready::WRITABLE | Ready::HUP | Ready::ERROR,
"a force-disconnect raised inside handle_read must still re-arm \
WRITABLE interest before the read pass returns, got {:?}",
connection.core.readiness
);
assert!(
connection.core.readiness.event.is_writable(),
"the settlement must signal the edge-triggered WRITABLE event, or \
the event loop never revisits this connection, got {:?}",
connection.core.readiness
);
assert!(
connection.core.poll_force_disconnect().is_none(),
"the settlement must have TAKEN the parked target, so a second \
settlement cannot run"
);
}
/// Build a bare server-side `ConnectionH2` for tests that only exercise
/// connection-level bookkeeping. The socket is never read or written.
///
/// Returns the accepted peer alongside it; keep it alive for the duration
/// of the test so the socket stays connected and reads answer `WouldBlock`
/// instead of `ECONNREFUSED`.
fn test_h2_connection(
pool: &Rc<RefCell<Pool>>,
graceful_shutdown_deadline: Option<Duration>,
) -> (H2Shell<mio::net::TcpStream>, std::net::TcpStream) {
let (socket, peer) = connected_socket();
let connection = H2Shell::new(
Ulid::generate(),
socket,
Position::Server,
&mut PoolBufferSource::new(Rc::downgrade(pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
graceful_shutdown_deadline,
Duration::from_secs(30),
Some((H2StreamId::Zero, CLIENT_PREFACE_SIZE)),
Ready::READABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection");
(connection, peer)
}
/// `snapshot_rtts` samples the frontend once per connection, when the
/// first stream of the connection is logged, and hands every later stream
/// the same value, in the same pass or in a later one (issues #1339 Q11
/// and #1590).
///
/// The first two calls stand for two streams recycling inside one pass:
/// all five production callers of this method sit on a recycle or reset
/// path, and more than one of them can run in a single `write_streams`
/// sweep. The third, through a fresh `ShellEndpoint`, stands for a stream
/// logged in a later pass. The counter is
/// [`super::super::tcp_info_reads`], bumped by every
/// `getsockopt(TCP_INFO)` the mux issues.
///
/// The premise is that the sample is `Some`: the connection sits on a live
/// loopback socket, so `TCP_INFO` answers. Without it a `local_rtt` that
/// had lost the socket would pass the equality assertions while reporting
/// nothing at all.
///
/// TO SEE THIS RED: in `memoized_rtt` (`lib/src/protocol/mux/mod.rs`),
/// delete the early `return sample;` of the memo hit. Every call then
/// reads the socket and the second assertion fails with ``a second stream
/// finishing in the SAME pass must reuse the connection sample, not read
/// TCP_INFO again``, `left: 2`, `right: 1`.
#[test]
fn snapshot_rtts_samples_the_frontend_once_per_connection() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (connection, _peer) = test_h2_connection(&pool, None);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let mut inner = EndpointClient(&mut router);
let before = super::super::tcp_info_reads();
let endpoint = ShellEndpoint::new(&mut inner, &connection.socket, &connection.local_rtt);
let first = connection
.core
.snapshot_rtts(&endpoint, Some(mio::Token(1)));
assert!(
first.0.is_some(),
"premise: TCP_INFO must answer on a live loopback frontend"
);
let second = connection
.core
.snapshot_rtts(&endpoint, Some(mio::Token(2)));
assert_eq!(
super::super::tcp_info_reads() - before,
1,
"a second stream finishing in the SAME pass must reuse the \
connection sample, not read TCP_INFO again"
);
assert_eq!(
second.0, first.0,
"every stream finishing in one pass reports one client_rtt — the \
identity #1339 Q11 traded the per-recycle syscall for"
);
assert_eq!(
(first.1, second.1),
(None, None),
"the router holds no backend, so server_rtt stays None and costs \
no read"
);
let endpoint = ShellEndpoint::new(&mut inner, &connection.socket, &connection.local_rtt);
let next_pass = connection.core.snapshot_rtts(&endpoint, None);
assert_eq!(
super::super::tcp_info_reads() - before,
1,
"a stream logged in a later pass must reuse the connection's one \
sample, not read TCP_INFO again"
);
assert_eq!(
next_pass.0, first.0,
"every stream of one connection reports that connection's one \
client_rtt"
);
}
/// A pass that logs no stream reads no `TCP_INFO`, and a backend H2
/// connection never reads one for the access log (issue #1590).
///
/// TO SEE THIS RED: make `ShellEndpoint::new` sample eagerly — call
/// `super::sample_rtt(socket.socket_ref())` and store it in `sample` —
/// which is the shape `Mux::refresh_client_rtt` had. The first assertion
/// fails with ``a pass that logs no stream must not read TCP_INFO``,
/// `left: 1`, `right: 0`.
#[test]
fn a_pass_that_logs_nothing_reads_no_tcp_info() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let before = super::super::tcp_info_reads();
connection.cancel_timed_out_streams(&mut context, &mut EndpointClient(&mut router));
assert_eq!(
super::super::tcp_info_reads() - before,
0,
"a pass that logs no stream must not read TCP_INFO"
);
assert_eq!(connection.local_rtt.get(), None);
connection.core.position = Position::Client(
"rtt-cluster".into(),
super::super::BackendId {
slot: super::super::BackendSlot(0),
backend_id: Rc::from("rtt-backend"),
address: "127.0.0.1:1".parse().expect("a literal socket address"),
metric_incarnation: Default::default(),
},
BackendStatus::Connected,
);
let mut inner = EndpointClient(&mut router);
let endpoint = ShellEndpoint::new(&mut inner, &connection.socket, &connection.local_rtt);
assert_eq!(
connection
.core
.snapshot_rtts(&endpoint, Some(mio::Token(1))),
(None, None),
"a backend H2 connection must not report RTTs onto the shared stream"
);
assert_eq!(
super::super::tcp_info_reads() - before,
0,
"a backend H2 connection must not read TCP_INFO for the access log"
);
}
/// Two streams reaped in one pass: two access-log lines, one frontend
/// `TCP_INFO` read between them, and the same `client_rtt` on both lines.
///
/// Drives the real log path — `H2Shell::cancel_timed_out_streams` through
/// `ShellEndpoint`, `snapshot_rtts` and `Stream::generate_access_log` —
/// and reads the lines the logger wrote, so it pins what an operator sees:
/// the `client_rtt` cell is present (not `-`) and coherent across the
/// pass, and `server_rtt` is `-` for streams that never reached a backend.
///
/// TO SEE THIS RED: in `ConnectionH2::snapshot_rtts`, return `None` in
/// place of `endpoint.local_rtt()`. Both lines then carry `-` in the
/// `client_rtt` cell and the presence assertion fails.
#[test]
fn streams_reaped_in_one_pass_log_one_present_client_rtt() {
let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let reads_in_pass = reads.clone();
let output = crate::capture_test_logs(move || {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 8, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let armed_at = connection.core.now;
for stream_id in [1, 3] {
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
connection
.core
.stream_table
.register(stream_id, gid, armed_at);
}
context.now = armed_at + connection.core.stream_idle_timeout + Duration::from_millis(1);
let before = super::super::tcp_info_reads();
connection.cancel_timed_out_streams(&mut context, &mut EndpointClient(&mut router));
reads_in_pass.store(
super::super::tcp_info_reads() - before,
std::sync::atomic::Ordering::Relaxed,
);
});
let cells: Vec<(&str, &str)> = output
.lines()
.filter_map(|line| {
line.split_whitespace().find_map(|token| {
let cells: Vec<&str> = token.split('/').collect();
(cells.len() == 5).then(|| (cells[3], cells[4]))
})
})
.collect();
assert_eq!(
cells.len(),
2,
"two reaped streams must write two access-log lines, got: {output}"
);
for (client_rtt, server_rtt) in &cells {
assert_ne!(
*client_rtt, "-",
"the access log's client_rtt must be present, got: {output}"
);
assert_eq!(
*server_rtt, "-",
"a stream that never reached a backend has no server_rtt, got: {output}"
);
}
assert_eq!(
cells[0].0, cells[1].0,
"streams logged in one pass share one client_rtt, got: {output}"
);
assert_eq!(
reads.load(std::sync::atomic::Ordering::Relaxed),
1,
"two streams logged in one pass must cost one frontend TCP_INFO \
read and no backend read"
);
}
/// The graceful-shutdown budget is armed from the `now` handed to
/// `graceful_goaway` and evaluated against `ConnectionH2::now` — not
/// against the real clock at either end.
///
/// `armed_at` is deliberately 100 s in the future relative to the
/// connection's construction snapshot. That gap is what makes the test
/// discriminating: a `graceful_goaway` that armed `started_at` from
/// `Instant::now()` would put it ~100 s in the PAST relative to
/// `armed_at`, and the "not yet elapsed" assertion would trip.
///
/// To SEE THIS RED: restore either half of the old behaviour.
/// (a) `self.started_at = Some(Instant::now());` in
/// `h2_drain::H2DrainState::begin_graceful_drain` — the `assert_eq!`
/// on `drain.__test_started_at()` fails: ``assertion `left == right`
/// failed: graceful_goaway must arm the budget from its `now`
/// parameter / left: Some(Instant { tv_sec: 107343, .. }) / right:
/// Some(Instant { tv_sec: 107443, .. })`` — the two differing by
/// exactly the 100 s offset above.
/// (b) `started_at.elapsed() >= deadline` in
/// `h2_drain::H2DrainState::deadline_elapsed` — the LAST assertion
/// fails with `advancing only the connection snapshot past the
/// budget must force close`, because no real time passes in this
/// test, so the forced-close budget never expires however far the
/// connection's clock is moved.
#[test]
fn graceful_shutdown_deadline_is_evaluated_against_the_connection_snapshot() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let deadline = Duration::from_secs(5);
let (mut connection, _peer) = test_h2_connection(&pool, Some(deadline));
// Nothing armed yet: `started_at` is None, so the budget cannot expire.
assert!(
!connection.core.graceful_shutdown_deadline_elapsed(),
"an unarmed drain must never report its budget as elapsed"
);
let armed_at = connection.core.now + Duration::from_secs(100);
connection.graceful_goaway(armed_at);
assert_eq!(
connection.core.drain.__test_started_at(),
Some(armed_at),
"graceful_goaway must arm the budget from its `now` parameter"
);
// One millisecond short of the budget, measured on the connection's
// clock. Real elapsed time here is microseconds.
connection.core.now = armed_at + deadline - Duration::from_millis(1);
assert!(
!connection.core.graceful_shutdown_deadline_elapsed(),
"the budget must not expire before `started_at + deadline` on the \
connection's own clock"
);
connection.core.now = armed_at + deadline;
assert!(
connection.core.graceful_shutdown_deadline_elapsed(),
"advancing only the connection snapshot past the budget must force close"
);
}
/// The RFC 9113 §5.1.2 back-pressure window is driven by
/// `ConnectionH2::now`, so a refusal burst is weighed against the window
/// that was open when the pass started — it cannot decay mid-pass.
///
/// To SEE THIS RED: restore
/// `if self.refuse_window_start.elapsed() >= BACKPRESSURE_WINDOW_DURATION`
/// in `record_refusal_for_backpressure`. The window then never rolls over
/// (no real time passes) and the 50th refusal lands in the same window as
/// the first 49, so the `refuse_count_window` assertion fails first:
/// ``assertion `left == right` failed: a refusal in a new window must
/// restart the count, not top up the old one / left: 1000 / right: 1``. The
/// two assertions after it would also fail — `mcs_backpressure_applied`
/// becomes true and `settings_max_concurrent_streams` is halved off a burst
/// that actually spanned two windows.
#[test]
fn backpressure_window_rolls_over_on_the_connection_snapshot() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let advertised = connection
.core
.local_settings
.settings_max_concurrent_streams;
// One short of the burst threshold, all inside the first window.
for _ in 0..BACKPRESSURE_REFUSAL_THRESHOLD - 1 {
connection.core.record_refusal_for_backpressure();
}
assert_eq!(
connection.core.refuse_count_window,
BACKPRESSURE_REFUSAL_THRESHOLD - 1
);
assert!(
!connection.core.mcs_backpressure_applied,
"back-pressure must not apply below the refusal threshold"
);
// Cross into the next window on the connection's clock alone.
connection.core.now += BACKPRESSURE_WINDOW_DURATION;
connection.core.record_refusal_for_backpressure();
assert_eq!(
connection.core.refuse_count_window, 1,
"a refusal in a new window must restart the count, not top up the old one"
);
assert!(
!connection.core.mcs_backpressure_applied,
"two refusals spread across two windows are not a burst"
);
assert_eq!(
connection
.core
.local_settings
.settings_max_concurrent_streams,
advertised,
"MAX_CONCURRENT_STREAMS must be untouched when no burst occurred"
);
}
/// The RFC 9113 §6.5 SETTINGS-ACK deadline is evaluated against
/// `ConnectionH2::now`. `flush_pending_control_frames` is the `writable`
/// half of the pair; the `poll_read_target` half at `h2.rs` shares the predicate.
///
/// To SEE THIS RED: restore `sent_at.elapsed() >= SETTINGS_ACK_TIMEOUT` in
/// `flush_pending_control_frames`. No real time passes in this test, so the
/// deadline never fires however far the connection's clock is advanced, and
/// the third assertion fails with `advancing only the connection snapshot
/// to the deadline must emit GOAWAY`.
#[test]
fn settings_ack_deadline_is_evaluated_against_the_connection_snapshot() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let sent_at = connection.core.now;
connection.core.settings_sent_at = Some(sent_at);
// One millisecond short of the budget on the connection's clock.
connection.core.now = sent_at + SETTINGS_ACK_TIMEOUT - Duration::from_millis(1);
assert!(
matches!(
connection.core.flush_pending_control_frames(),
H2ControlFlushTarget::Proceed
),
"the SETTINGS-ACK deadline must not fire before SETTINGS_ACK_TIMEOUT \
on the connection's own clock"
);
assert_eq!(
connection.core.settings_sent_at,
Some(sent_at),
"a deadline that has not fired must leave the ACK timer armed"
);
// At the budget: GOAWAY(SETTINGS_TIMEOUT).
connection.core.now = sent_at + SETTINGS_ACK_TIMEOUT;
assert!(
matches!(
connection.core.flush_pending_control_frames(),
H2ControlFlushTarget::Done(_)
),
"advancing only the connection snapshot to the deadline must emit GOAWAY"
);
assert_eq!(
connection.core.settings_sent_at, None,
"goaway must disarm the ACK timer so the timeout cannot re-fire"
);
}
/// The per-stream liveness guard reaps against `ConnectionH2::now`, and
/// `cancel_timed_out_streams` adopts `context.now` on entry — this pins
/// both the entry-point mirror and the deadline arithmetic in
/// `H2StreamTable::collect_timed_out`.
///
/// To SEE THIS RED: restore `let now = Instant::now();` in
/// `cancel_timed_out_streams` (in place of `let now = self.now;`). The
/// injected instant is then ignored, no stream is ever old enough to reap
/// because no real time passes, and the reap assertion fails with
/// `advancing only the mux snapshot past the idle deadline must reap the
/// stream`. Deleting the `self.now = context.now;` entry-point assignment
/// turns the same assertion red, with the same message, for the same reason.
#[test]
fn per_stream_liveness_reaping_is_evaluated_against_the_connection_snapshot() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let idle_timeout = connection.core.stream_idle_timeout;
let armed_at = connection.core.now;
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
connection.core.stream_table.register(1, gid, armed_at);
// Exactly at the deadline: the predicate is a strict `>`, so the stream
// survives.
context.now = armed_at + idle_timeout;
connection.cancel_timed_out_streams(&mut context, &mut EndpointClient(&mut router));
assert!(
connection.core.control_tx.pending().is_empty(),
"a stream exactly at its idle deadline must not be reaped"
);
// One millisecond past it: reaped with RST_STREAM(CANCEL).
context.now = armed_at + idle_timeout + Duration::from_millis(1);
connection.cancel_timed_out_streams(&mut context, &mut EndpointClient(&mut router));
assert!(
!connection.core.control_tx.pending().is_empty(),
"advancing only the mux snapshot past the idle deadline must reap the stream"
);
assert_eq!(
connection.core.control_tx.pending()[0],
(1, H2Error::Cancel),
"the reaper must queue RST_STREAM(CANCEL) for the timed-out stream"
);
}
/// A mass idle-timeout reap is the only path that queues more than one
/// RST_STREAM between two `flush_pending_control_frames` passes:
/// `cancel_timed_out_streams` walks the whole timed-out set and calls
/// `enqueue_rst` for every entry inside ONE pass. The cap therefore has to
/// live at the insert, not at the drain — a drain-side check bounds what
/// is *written*, and says nothing about how large the queue got before
/// anyone looked. See sozu-proxy/sozu#1413.
///
/// `h2_control_tx::pending_rst_bound` keeps the bound above four resets
/// per advertised concurrent stream, so a real sweep cannot reach it on
/// its own; the queue still holds what every caller queued since the last
/// successful drain. This test registers more wire ids than the bound on
/// the stream table directly, exactly as its sibling
/// `per_stream_liveness_reaping_is_evaluated_against_the_connection_snapshot`
/// does, because the accept path is not what is under test here.
///
/// `check_invariants` is asserted explicitly because the reaper is also
/// reachable from `Mux::timeout` against a fully silent peer — the very
/// scenario that produces a mass reap — where `handle_frame`, the only
/// production caller of `check_invariants`, never runs at all. The reaper
/// is not the only insert path outside that caller: the
/// DATA-on-closed-stream reset sits in `handle_header_state`, which
/// `handle_read` returns into without ever reaching `handle_frame`. That one
/// is rate-limited by `record_glitch` + `check_flood_or_return!` and
/// cannot by itself fill the queue — see `ConnectionH2::enqueue_rst`.
///
/// To SEE THIS RED: delete the `pending_before >= self.max_pending` guard
/// at the top of `H2ControlTx::enqueue_rst` (`h2_control_tx.rs`), then run
/// `cargo test -p sozu-lib --locked mass_reap` (one positional filter —
/// cargo rejects a second one). Every reaped stream is then queued
/// unconditionally and `H2ControlTx::check_invariants` — the post-condition
/// every mutating method on that type runs — panics inside the sweep with
/// `pending RST queue must stay within its hard cap (escalates at the
/// cap)`. That clause moved to the module with this commit and is NOT
/// restated in `ConnectionH2::check_invariants`, so the explicit call
/// below no longer covers the queue bound — the assertion at the end of
/// this test is what pins it from the connection's side.
#[test]
fn mass_reap_keeps_the_pending_rst_queue_within_its_hard_cap() {
// `Stream::new` checks out two buffers per stream and the connection
// itself holds one for its zero buffer, so the ceiling has to clear
// `2 * REAPED` with room to spare or `create_stream` hands back
// `None` — which is how the first draft of this test failed.
//
// A production bound (`h2_control_tx::pending_rst_bound`, at least
// `MIN_PENDING_RST_STREAMS`) would need thousands of streams and two
// pool buffers each; what is under test is that the queue honours
// its instance's bound, so the test installs a small one.
let bound = 200;
let reaped = bound + 32;
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 2 * reaped + 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
connection.core.control_tx = h2_control_tx::H2ControlTx::with_cap(bound);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let idle_timeout = connection.core.stream_idle_timeout;
let armed_at = connection.core.now;
for i in 0..reaped {
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
// RFC 9113 §5.1.1: client-initiated stream ids are odd.
connection
.core
.stream_table
.register(2 * i as StreamId + 1, gid, armed_at);
}
// One sweep, every stream past its deadline: the entire set is reaped
// and RST-queued with no intervening flush.
context.now = armed_at + idle_timeout + Duration::from_millis(1);
connection.cancel_timed_out_streams(&mut context, &mut EndpointClient(&mut router));
#[cfg(debug_assertions)]
connection.core.check_invariants(&context);
assert!(
connection.core.control_tx.pending().len() <= bound,
"a mass reap must stop queueing at the cap, got {} entries",
connection.core.control_tx.pending().len()
);
}
// ── flood accounting: refusals, provoked resets, the denominator ────
/// A peer that keeps opening streams Sōzu refuses neither grows the
/// connection's state nor escapes the flood detector: a refused stream
/// was never registered, so nothing ever removes its id from `rst_sent`,
/// and each refusal past the limit counts as a glitch.
///
/// The refusals here fall in the band back-pressure opens, local limit
/// lowered below the advertised one, which cost nothing before.
#[test]
fn refused_streams_keep_rst_sent_bounded_and_trip_the_glitch_budget() {
use std::io::Write;
const BATCHES: u32 = 50;
const PER_BATCH: u32 = 1000;
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 8, 16384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
connection
.core
.local_settings
.settings_max_concurrent_streams = 0;
// `:method GET`, `:path /`, `:scheme https`, then `:authority`
// as a literal without indexing: no HPACK table state to track.
let mut block = vec![0x82, 0x84, 0x87, 0x01, 0x09];
block.extend_from_slice(b"localhost");
let mut next_id: u32 = 1;
'batches: for _ in 0..BATCHES {
let mut wire = Vec::new();
for _ in 0..PER_BATCH {
wire.extend(orphan_frame(
1,
parser::FLAG_END_HEADERS | parser::FLAG_END_STREAM,
next_id,
block.len() as u32,
&block,
));
next_id += 2;
}
peer.write_all(&wire).expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
// One read pass handles one frame header or one payload: drive
// until the batch's last stream id has been seen.
let last_id = next_id - 2;
for _ in 0..(16 * PER_BATCH) {
connection.core.readiness.event.insert(Ready::READABLE);
connection.core.readiness.interest.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
let _ = connection.core.flush_pending_control_frames();
connection.core.clear_output();
if matches!(connection.core.state, H2State::GoAway | H2State::Error) {
break 'batches;
}
if connection.core.stream_table.highest_peer_stream_id() >= last_id
&& matches!(connection.core.state, H2State::Header)
{
break;
}
}
}
assert!(
connection.core.stream_table.rst_sent_mut().len() < 100,
"refused stream ids must not accumulate in rst_sent, got {}",
connection.core.stream_table.rst_sent_mut().len()
);
assert!(
matches!(connection.core.state, H2State::GoAway | H2State::Error),
"endless refusals must trip the flood detector, got {:?} after {} streams",
connection.core.state,
next_id / 2
);
}
/// Every reset the peer provokes is a protocol error on its part and
/// counts as a glitch, so provoked resets have a per-window rate limit
/// independent of how many streams the connection served.
#[test]
fn peer_provoked_resets_count_as_glitches() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let now = connection.core.now;
for _ in 0..1_000_000 {
connection.core.flood_detector.record_stream_opened();
}
let glitch_budget = H2FloodConfig::default().max_glitch_count();
let mut tripped_at = None;
for n in 1..=(glitch_budget + 1) {
connection.core.now = now;
if connection
.core
.account_emitted_rst(H2Error::ProtocolError, RstOrigin::PeerProvoked)
.is_some()
{
tripped_at = Some(n);
break;
}
}
assert_eq!(
tripped_at,
Some(glitch_budget + 1),
"the provoked reset past the glitch budget must end the connection"
);
}
/// Streams the peer opens count toward the RST caps' denominator only
/// once a backend answers them: a stream Sōzu refuses, answers itself or
/// sees reset before a response cannot dilute the caps.
#[test]
fn unanswered_streams_do_not_dilute_the_reset_caps() {
const FLOOR: u64 = 10;
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 70, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
connection.core.flood_detector = h2_flood_detector::H2FloodDetector::new(
H2FloodConfig::from_optional(
None,
None,
None,
None,
None,
None,
None,
None,
Some(FLOOR),
None,
None,
None,
None,
None,
),
connection.core.now,
);
for i in 0..30u32 {
connection
.core
.create_stream(2 * i + 1, &mut context)
.expect("the pool must hand out stream buffers");
}
let mut tripped_at = None;
for n in 1..=(FLOOR + 1) {
if connection
.core
.flood_detector
.record_rst_received(false, false, connection.core.now)
.is_some()
{
tripped_at = Some(n);
break;
}
}
assert_eq!(
tripped_at,
Some(FLOOR + 1),
"thirty unanswered streams must not raise the pre-response cap"
);
}
/// Resets Sōzu emits on its own initiative — here the idle reaper's
/// `RST_STREAM(CANCEL)` — are not charged to the peer: neither the
/// CVE-2025-8671 MadeYouReset emitted-RST counter nor the pending RST
/// queue's bound may accumulate them across drains.
///
/// Two sweeps of 150 reaped streams with a control flush between them:
/// 300 local resets in the connection's lifetime, never more than 150
/// pending at once.
///
/// TO SEE THIS RED: in `ConnectionH2::cancel_timed_out_streams`, pass
/// `RstOrigin::PeerProvoked` instead of `RstOrigin::Local` to
/// `ConnectionH2::enqueue_rst`. The emitted-RST counter then reads 300.
#[test]
fn local_resets_are_not_charged_to_the_peer() {
const PER_SWEEP: usize = 150;
let pool = Rc::new(RefCell::new(Pool::with_capacity(
2,
4 * PER_SWEEP + 4,
16384,
)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let idle_timeout = connection.core.stream_idle_timeout;
for sweep in 0..2 {
let armed_at = connection.core.now;
for i in 0..PER_SWEEP {
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
let wire_id = 2 * (sweep * PER_SWEEP + i) as StreamId + 1;
connection
.core
.stream_table
.register(wire_id, gid, armed_at);
}
context.now = armed_at + idle_timeout + Duration::from_millis(1);
connection.cancel_timed_out_streams(&mut context, &mut EndpointClient(&mut router));
// The SETTINGS-ACK watchdog is not under test.
connection.core.settings_sent_at = None;
let flushed = connection.core.flush_pending_control_frames();
assert!(
!matches!(flushed, H2ControlFlushTarget::Done(_)),
"sweep {sweep}: local resets must not end the connection"
);
assert!(
!matches!(connection.core.state, H2State::GoAway | H2State::Error),
"sweep {sweep}: local resets must not send GOAWAY"
);
}
assert_eq!(
connection
.core
.flood_detector
.total_rst_streams_emitted_lifetime(),
0,
"reaper CANCELs are Sōzu's decision, not the peer's"
);
}
// ── forcefully_terminate_answer arms WRITABLE for ET epoll ───────────
//
// Gap A in the h2spec diagnosis: the pre-fix code set `interest` but
// never raised `event`, so `filter_interest() = event & interest` was
// zero and `writable()` was never scheduled. This test pins the fix.
#[test]
fn test_forcefully_terminate_answer_arms_event_and_interest() {
let pool = make_pool_for_invariant_16();
let ulid = Ulid::generate();
let mut stream = make_stream_for_invariant_16(&pool, ulid);
let mut readiness = Readiness::new();
assert!(!readiness.interest.is_writable());
assert!(!readiness.event.is_writable());
forcefully_terminate_answer(&mut stream, &mut readiness, H2Error::ProtocolError);
assert!(
readiness.interest.is_writable(),
"forcefully_terminate_answer must set the WRITABLE interest bit"
);
assert!(
readiness.event.is_writable(),
"forcefully_terminate_answer must set the WRITABLE event bit — \
without this, filter_interest() = 0 under edge-triggered epoll \
and writable() is never scheduled (h2spec Gap A)"
);
}
// ── peer-address snapshot in the MUX-H2 log envelope ─────────────────
//
// `log_context!` / `log_context_stream!` render `peer=` from
// `SocketHandler::peer_addr`, which is a snapshot the handler cached, not a
// live `getpeername(2)`. Both tests below make the two answers differ on
// purpose: the socket is genuinely connected to a loopback listener, so the
// live lookup succeeds and would print `127.0.0.1:<port>`; only a macro
// reading the snapshot can print `10.0.0.42:12345`.
//
// That gap is the PROXY-protocol symptom in miniature — the frontend's real
// peer is the load balancer while the cache holds the advertised client —
// and the same read is what keeps the slot populated after the peer's RST,
// when `getpeername(2)` answers ENOTCONN and the live lookup collapses to
// `None` on exactly the error lines an operator is reading.
/// A live, established loopback connection plus the address
/// `getpeername(2)` reports for it. The listener is returned so the
/// connection stays up for the whole test: these tests assert the snapshot
/// wins even while the live lookup is perfectly healthy, so a half-dead
/// socket would weaken them rather than strengthen them.
fn connected_loopback_stream() -> (
std::net::TcpListener,
mio::net::TcpStream,
std::net::SocketAddr,
) {
let listener = std::net::TcpListener::bind("127.0.0.1:0")
.expect("test listener must bind to a loopback port");
let live_peer = listener
.local_addr()
.expect("test listener must report its local address");
let stream =
std::net::TcpStream::connect(live_peer).expect("loopback connect must complete");
stream
.set_nonblocking(true)
.expect("mio requires a nonblocking stream");
(listener, mio::net::TcpStream::from_std(stream), live_peer)
}
/// Address the handler caches. Deliberately non-loopback so it cannot
/// collide with whatever ephemeral port the live lookup would report.
const CACHED_PEER: &str = "10.0.0.42:12345";
/// Build a server-side H2 connection whose frontend handler caches
/// [`CACHED_PEER`] while its socket is really connected to `live_peer`.
fn connection_with_cached_peer(
pool: &Rc<RefCell<Pool>>,
stream: mio::net::TcpStream,
) -> H2Shell<crate::socket::SessionTcpStream> {
let session_ulid = Ulid::generate();
let socket = crate::socket::SessionTcpStream::new(
stream,
session_ulid,
Some(
CACHED_PEER
.parse()
.expect("the cached peer literal must parse"),
),
);
H2Shell::new(
session_ulid,
socket,
Position::Server,
&mut PoolBufferSource::new(Rc::downgrade(pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, CLIENT_PREFACE_SIZE)),
Ready::READABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection")
}
/// To SEE THIS RED: in `log_context!` (h2.rs), put
/// `peer = $self.socket.socket_ref().peer_addr().ok(),` back in place of
/// `peer = $self.peer_address,`. The rendered line then carries the
/// loopback address the socket is really connected to, so the first
/// assertion fails on the missing cached address.
///
/// Substituting the pre-snapshot `peer = $self.socket.peer_addr(),` does
/// NOT turn this red, and that is worth stating: for a handler that
/// already caches, reading the cache and reading a snapshot taken from
/// that cache agree by construction. What the snapshot changed is WHICH
/// object is read on a log line, not what comes back — so the call-count
/// property, not this one, is what distinguishes them. It is pinned by
/// [`log_context_reads_the_peer_address_once_per_connection`].
#[test]
fn log_context_renders_the_cached_peer_not_a_live_lookup() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (_listener, stream, live_peer) = connected_loopback_stream();
let connection = connection_with_cached_peer(&pool, stream);
// Premise of the test: the live lookup is healthy and disagrees with the
// cache. Without this the assertions below could pass for the wrong
// reason (both answers happening to be the same address).
assert_eq!(
connection.socket.socket_ref().peer_addr().ok(),
Some(live_peer),
"the test socket must be genuinely connected, so a live lookup succeeds"
);
let rendered = log_context!(connection.core);
assert!(
rendered.contains(&format!("peer=Some({CACHED_PEER})")),
"MUX-H2 context must render the cached peer: {rendered}"
);
assert!(
!rendered.contains(&live_peer.to_string()),
"MUX-H2 context must not fall back to the live getpeername(2) answer: {rendered}"
);
}
/// To SEE THIS RED: in `log_context_stream!` (h2.rs), put
/// `peer = $self.socket.socket_ref().peer_addr().ok(),` back in place of
/// `peer = $self.peer_address,`. The per-stream envelope then
/// diverges from the connection envelope, which is worse than either being
/// wrong alone: the same session renders two different peers depending on
/// whether a stream happened to be in scope at the callsite.
#[test]
fn log_context_stream_renders_the_cached_peer_not_a_live_lookup() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (_listener, stream, live_peer) = connected_loopback_stream();
let connection = connection_with_cached_peer(&pool, stream);
let mux_stream = make_stream_for_invariant_16(&pool, connection.core.session_ulid);
let http_context = &mux_stream.context;
assert_eq!(
connection.socket.socket_ref().peer_addr().ok(),
Some(live_peer),
"the test socket must be genuinely connected, so a live lookup succeeds"
);
let rendered = log_context_stream!(connection.core, http_context);
assert!(
rendered.contains(&format!("peer=Some({CACHED_PEER})")),
"per-stream MUX-H2 context must render the cached peer: {rendered}"
);
assert!(
!rendered.contains(&live_peer.to_string()),
"per-stream MUX-H2 context must not fall back to the live \
getpeername(2) answer: {rendered}"
);
}
/// A [`SocketHandler`] that counts how often it is asked for the peer
/// address, delegating every other method to a real loopback stream.
///
/// The two tests above pin WHICH address the log prefix renders. Neither
/// can see how many times the connection reaches into the socket to get
/// it, because both production handlers answer from a cache and so give
/// the same answer however often they are asked. This handler makes the
/// count observable.
struct PeerAddrCountingSocket {
stream: mio::net::TcpStream,
peer: std::net::SocketAddr,
calls: Rc<std::cell::Cell<usize>>,
}
impl SocketHandler for PeerAddrCountingSocket {
fn socket_read(&mut self, buf: &mut [u8]) -> (usize, SocketResult) {
self.stream.socket_read(buf)
}
fn socket_write(&mut self, buf: &[u8]) -> (usize, SocketResult) {
self.stream.socket_write(buf)
}
fn socket_write_vectored(&mut self, bufs: &[IoSlice]) -> (usize, SocketResult) {
self.stream.socket_write_vectored(bufs)
}
fn socket_ref(&self) -> &mio::net::TcpStream {
&self.stream
}
fn socket_mut(&mut self) -> &mut mio::net::TcpStream {
&mut self.stream
}
fn peer_addr(&self) -> Option<std::net::SocketAddr> {
self.calls.set(self.calls.get() + 1);
Some(self.peer)
}
fn protocol(&self) -> crate::socket::TransportProtocol {
crate::socket::TransportProtocol::Tcp
}
fn read_error(&self) {}
fn write_error(&self) {}
}
/// The `peer=` slot is read from the socket exactly ONCE per connection —
/// at construction — however many log lines the connection renders.
///
/// This is the property that makes `peer_address` worth having. It is not
/// about which address appears (the two tests above own that); it is about
/// `log_context!` no longer being a socket operation. The macro expands at
/// 113 production callsites in this file, so before the snapshot every
/// method that logged was `Front`-coupled whether or not it did any I/O —
/// which is 113 of the ~140 total `Front` touch points in `h2.rs`.
///
/// TO SEE THIS RED: in `log_context!` (h2.rs), put
/// `peer = $self.socket.peer_addr(),` back in place of
/// `peer = $self.peer_address,`. The count then rises by one per rendered
/// line and the final assertion fails with
/// `the peer address must be read once, at construction, not once per log line:
/// left: 4, right: 1`.
#[test]
fn log_context_reads_the_peer_address_once_per_connection() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (_listener, stream, _live_peer) = connected_loopback_stream();
let calls = Rc::new(std::cell::Cell::new(0usize));
let session_ulid = Ulid::generate();
let socket = PeerAddrCountingSocket {
stream,
peer: CACHED_PEER
.parse()
.expect("the cached peer literal must parse"),
calls: Rc::clone(&calls),
};
let connection = H2Shell::new(
session_ulid,
socket,
Position::Server,
&mut PoolBufferSource::new(Rc::downgrade(&pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, CLIENT_PREFACE_SIZE)),
Ready::READABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection");
assert_eq!(
calls.get(),
1,
"construction takes exactly one peer_addr() snapshot"
);
// Premise: the rendered lines really do carry the address, so a zero
// count below would mean the slot went missing rather than that it
// became free.
for _ in 0..3 {
let rendered = log_context!(connection.core);
assert!(
rendered.contains(&format!("peer=Some({CACHED_PEER})")),
"each rendered line must still carry the peer: {rendered}"
);
}
assert_eq!(
calls.get(),
1,
"the peer address must be read once, at construction, not once per log line"
);
}
// ── The read-side two-call protocol (poll_read_target / handle_read) ──
/// `read_space` hands out the buffer the core named, and hands out the
/// stream's READ buffer — the one `Stream::split` labels `rbuffer` for the
/// connection's position — not its write buffer.
///
/// This is the only genuinely new logic in the read-side inversion: every
/// other line moved. Both halves are pinned because the two failure modes
/// differ — offering the wrong *kawa* corrupts the peer's request while
/// the response is parsed as H2 payload, offering the wrong *stream*
/// writes one stream's DATA into another's.
///
/// To SEE THIS RED: in `read_buffer` (h2.rs), return
/// `streams[global_stream_id].split(position).wbuffer` in place of
/// `.rbuffer`.
#[test]
fn read_space_offers_the_read_buffer_of_the_stream_the_core_named() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
// Premise: the two per-stream buffers really are distinct allocations,
// so a pointer comparison below can tell them apart at all.
let front = context.streams[gid].front.storage.space().as_ptr();
let back = context.streams[gid].back.storage.space().as_ptr();
let zero = connection.core.zero.storage.space().as_ptr();
assert_ne!(
front, back,
"a stream's request and response buffers must be distinct allocations"
);
let offered = read_space(
&mut connection.core.zero,
&mut context.streams,
&connection.core.position,
H2StreamId::Zero,
9,
);
assert_eq!(
offered.as_ptr(),
zero,
"H2StreamId::Zero must offer the connection-level scratch buffer"
);
assert_eq!(
offered.len(),
9,
"the offer is capped at the byte debt, never the whole free space"
);
let offered = read_space(
&mut connection.core.zero,
&mut context.streams,
&connection.core.position,
H2StreamId::Other { id: 1, gid },
7,
);
assert_eq!(
offered.as_ptr(),
front,
"a server position must offer the stream's request (front) buffer"
);
assert_ne!(
offered.as_ptr(),
back,
"the offered buffer must not be the stream's response (back) buffer"
);
assert_eq!(
offered.len(),
7,
"the offer is capped at the byte debt, never the whole free space"
);
}
/// A frame whose payload is zero bytes long owes the socket nothing, and
/// the core says so with its own variant rather than an `amount` of 0.
///
/// That distinction is load-bearing, not cosmetic. A caller handed
/// `Fill { amount: 0 }` would issue a zero-length read, and
/// `update_readiness_after_read(0, SocketResult::Continue, ..)` returns
/// `true` for it — "nothing arrived, stop" — so `handle_read` would return
/// before the frame state machine ever ran, and every empty SETTINGS,
/// empty DATA and SETTINGS ACK would stop being parsed.
///
/// To SEE THIS RED: in `poll_read_target` (h2.rs), replace the trailing
/// `if amount > 0 { .. } else { .. }` with its `Fill` half alone —
/// keep the `available_space` guard and end the function with
/// `H2ReadTarget::Fill { stream_id, amount }`, dropping the
/// `set_expect_read(None)` / `H2ReadTarget::Skip(stream_id)` branch.
#[test]
fn poll_read_target_skips_the_read_when_the_frame_carries_no_payload() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 0)));
let target = connection
.core
.poll_read_target(&mut context, &mut EndpointClient(&mut router));
assert!(
matches!(target, H2ReadTarget::Skip(H2StreamId::Zero)),
"a zero-length payload must be a Skip, not a zero-length Fill: {target:?}"
);
assert!(
connection.core.stream_table.expect_read().is_none(),
"the settled byte debt must be cleared before the frame is dispatched"
);
}
/// The core is told how many bytes arrived, and it subtracts exactly that
/// many from the debt it offered — a short read leaves the remainder owed,
/// it does not restart the frame.
///
/// 6 is neither of the two numbers the test hands in, so this cannot pass
/// by echoing the fixture: `amount` is 9 and `size` is 3.
///
/// To SEE THIS RED: in `handle_read` (h2.rs), pass `amount` in place of
/// `amount - size` to `set_expect_read`.
#[test]
fn handle_read_subtracts_the_byte_count_the_caller_reported() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
// Not ClientPreface: a short read there runs the early-preface check
// instead, which is a different branch with its own coverage.
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
let result = connection.core.handle_read(
&mut context,
EndpointClient(&mut router),
H2StreamId::Zero,
H2ReadOutcome::Filled {
amount: 9,
size: 3,
status: SocketResult::Continue,
},
);
assert!(
matches!(result, MuxResult::Continue),
"a short read keeps the connection running: {result:?}"
);
assert_eq!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Zero, 6)),
"a 3-byte answer to a 9-byte offer must leave 6 bytes owed"
);
assert_eq!(
connection.core.zero.storage.data().len(),
3,
"the core must consume exactly the bytes the caller reported"
);
}
/// A read that returned nothing clears the READABLE **event** and leaves
/// the READABLE **interest** alone, so the connection is re-armed for the
/// next epoll wake-up instead of being taken off the loop.
///
/// The interest half is asserted on purpose: `signal_pending_write` and
/// this path both touch `Readiness.event` only, and an assertion written
/// against `interest` cannot see either of them move.
///
/// To SEE THIS RED: in `handle_read` (h2.rs), call
/// `update_readiness_after_write` in place of `update_readiness_after_read`
/// — the WRITABLE bit is cleared instead and the READABLE event survives.
#[test]
fn handle_read_clears_the_readable_event_not_the_interest_when_nothing_arrived() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
connection.core.readiness.event = Ready::READABLE;
connection.core.readiness.interest = Ready::READABLE | Ready::HUP | Ready::ERROR;
let result = connection.core.handle_read(
&mut context,
EndpointClient(&mut router),
H2StreamId::Zero,
H2ReadOutcome::Filled {
amount: 9,
size: 0,
status: SocketResult::WouldBlock,
},
);
assert!(
matches!(result, MuxResult::Continue),
"an empty read is not a close: {result:?}"
);
assert!(
!connection.core.readiness.event.is_readable(),
"an empty read must clear the READABLE event"
);
assert!(
connection.core.readiness.interest.is_readable(),
"an empty read must NOT drop the READABLE interest"
);
assert_eq!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Zero, 9)),
"an empty read settles no part of the byte debt"
);
}
// ── RFC 9113 §4.3: a refused stream must not desynchronise HPACK ──────
//
// Field compression state is scoped to the whole connection, not to a
// stream. When `handle_header_state` refuses a stream — during a
// graceful drain, over MAX_CONCURRENT_STREAMS, or on buffer-pool
// exhaustion — the HEADERS payload it drops is an HPACK field block the
// peer's encoder has *already* applied to its own dynamic table. Dropping
// it without decoding leaves our decoder permanently behind the peer's
// encoder, and every later header block on the surviving connection then
// resolves the wrong dynamic entry or fails outright.
/// To SEE THIS RED: in the `(H2State::Discard, _)` arm of
/// [`ConnectionH2::handle_read`], remove the `if let Some(discarded) =
/// self.discarded_field_block.take() && ...` block that calls
/// [`decode_discarded_field_block`], restoring the unconditional
/// `kawa.storage.clear()`. The peer's second block is then a one-byte
/// reference to dynamic index 62 that our decoder has never been told
/// about, and the decode fails with an out-of-table index. Verified
/// 2026-09-21 (see this commit's message for the exact failure output).
#[test]
fn a_refused_stream_keeps_the_hpack_decoder_in_sync() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
// Past the preface/SETTINGS handshake, waiting on a frame header, and
// draining — so the next client-initiated stream is refused rather
// than created. Drain is only the cheapest of the three refusal
// triggers; MAX_CONCURRENT_STREAMS and pool exhaustion reach the same
// `refuse_stream_and_discard` call.
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
connection.core.drain.__test_set_draining();
// The peer's encoder. It indexes a header whose *name* is in
// neither table, so this block appends `x-sozu-probe: alpha` to the
// peer's dynamic table at index 62.
let mut peer_encoder = crate::protocol::mux::hpack::Encoder::new();
let refused_block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/refused"[..]),
(&b"x-sozu-probe"[..], &b"alpha"[..]),
]);
assert!(
refused_block.len() < 16_384,
"the probe block must fit one HEADERS frame under the default max frame size"
);
// HEADERS, END_STREAM | END_HEADERS, stream 1.
let mut frame = Vec::with_capacity(9 + refused_block.len());
frame.extend_from_slice(&(refused_block.len() as u32).to_be_bytes()[1..]);
frame.push(1);
frame.push(parser::FLAG_END_STREAM | parser::FLAG_END_HEADERS);
frame.extend_from_slice(&1u32.to_be_bytes());
frame.extend_from_slice(&refused_block);
peer.write_all(&frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
// Two passes consume the 9-byte header and then the payload; the rest
// are `WouldBlock` no-ops. Bounded so a delivery hiccup fails loudly
// instead of hanging.
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::Header)
&& matches!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Zero, 9))
)
&& !connection.core.control_tx.pending().is_empty()
{
break;
}
std::thread::yield_now();
}
// Premise of the test: we really went through the refusal path, the
// stream was refused rather than created, and the connection survived.
assert_eq!(
connection.core.control_tx.pending(),
vec![(1, H2Error::RefusedStream)],
"the drain gate must refuse stream 1 with RST_STREAM(REFUSED_STREAM)"
);
assert!(
connection.core.stream_table.is_empty(),
"a refused stream must not be created"
);
assert!(
!matches!(connection.core.state, H2State::Error | H2State::GoAway),
"refusing one stream must leave the connection usable: {:?}",
connection.core.state
);
// The peer's next block references the dynamic entry its encoder added
// while encoding the refused request — a single indexed field.
let next_block = peer_encoder.encode([(&b"x-sozu-probe"[..], &b"alpha"[..])]);
assert_eq!(
next_block.len(),
1,
"the peer must now reference its dynamic entry, not re-send a literal"
);
let mut decoded = Vec::new();
let status = connection
.core
.hpack
.decoder_mut()
.decode_with_cb(&next_block, |k, v| {
decoded.push((k.into_owned(), v.into_owned()));
});
assert!(
status.is_ok(),
"the connection decoder must still resolve the peer's dynamic table \
after a refused stream, got {status:?}"
);
assert_eq!(
decoded,
vec![(b"x-sozu-probe".to_vec(), b"alpha".to_vec())],
"the refused stream's field block must have been decoded into the \
connection's HPACK context"
);
}
/// Serialise one frame header followed by `payload`.
fn orphan_frame(
frame_type: u8,
flags: u8,
stream_id: u32,
len: u32,
payload: &[u8],
) -> Vec<u8> {
let mut frame = Vec::with_capacity(9 + payload.len());
frame.extend_from_slice(&len.to_be_bytes()[1..]);
frame.push(frame_type);
frame.push(flags);
frame.extend_from_slice(&stream_id.to_be_bytes());
frame.extend_from_slice(payload);
frame
}
/// A PING the peer sends after the orphaned payload: once its ACK sits in
/// `zero`, the connection has provably read past that payload.
fn orphan_ping() -> Vec<u8> {
orphan_frame(6, 0, 0, 8, b"pingpong")
}
/// Open stream 1 with a request whose body follows, then send a DATA
/// header announcing `payload_len` bytes and the first `sent` of them,
/// and drive `readable()` until the connection waits on that payload.
/// Returns what `expect_read` then names.
fn open_stream_mid_data_payload(
connection: &mut H2Shell<mio::net::TcpStream>,
peer: &mut std::net::TcpStream,
context: &mut Context<crate::protocol::mux::test_support::TestListener>,
router: &mut Router,
payload_len: u32,
sent: usize,
) -> Option<(H2StreamId, usize)> {
use std::io::Write;
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
let block = crate::protocol::mux::hpack::Encoder::new().encode([
(&b":method"[..], &b"POST"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/upload"[..]),
]);
let mut bytes = orphan_frame(1, parser::FLAG_END_HEADERS, 1, block.len() as u32, &block);
bytes.extend(orphan_frame(0, 0, 1, payload_len, &vec![b'x'; sent]));
peer.write_all(&bytes)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
for _ in 0..64 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(context, EndpointClient(router));
if let Some((H2StreamId::Other { .. }, remaining)) =
connection.core.stream_table.expect_read()
&& (remaining == payload_len as usize - sent
|| !connection.core.readiness.interest.is_readable())
{
break;
}
std::thread::yield_now();
}
connection.core.stream_table.expect_read()
}
/// Drive `readable()` until `done` holds, re-inserting the READABLE event
/// AND interest before every pass, so a lost edge-triggered event cannot
/// be what stalls the connection. Returns whether `done` held.
fn drive_reads_with_readiness_forced(
connection: &mut H2Shell<mio::net::TcpStream>,
context: &mut Context<crate::protocol::mux::test_support::TestListener>,
router: &mut Router,
done: impl Fn(&H2Shell<mio::net::TcpStream>) -> bool,
) -> bool {
for _ in 0..256 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.core.readiness.interest.insert(Ready::READABLE);
connection.readable(context, EndpointClient(router));
if done(connection) {
return true;
}
std::thread::yield_now();
}
false
}
/// Regression test for #1597: a stream reaped by the per-stream idle
/// guard while it owns the DATA payload being read must not leave the
/// connection unable to read.
///
/// `H2StreamTable::remove` nulls an `expect_read` naming the removed
/// stream. Before the fix nothing else changed, so the connection stayed
/// in `H2State::Frame` with nothing to read, and every later `readable()`
/// returned at the `expect_read() == None` early exit — the remaining
/// payload, the PING behind it and every later frame were never read.
///
/// TO SEE THIS RED: in [`ConnectionH2::remove_dead_stream`], delete the
/// `skip_orphaned_data_payload` call. The test fails with `after the reap
/// the connection must still read the PING behind the orphaned payload;
/// state=Frame(FrameHeader { payload_len: 10, frame_type: Data, flags: 0,
/// stream_id: 1 }) expect_read=None`. Verified 2026-09-26.
#[test]
fn reaping_a_stream_mid_data_payload_keeps_the_connection_reading() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let expect = open_stream_mid_data_payload(
&mut connection,
&mut peer,
&mut context,
&mut router,
10,
4,
);
assert!(
matches!(expect, Some((H2StreamId::Other { .. }, 6))),
"premise: mid DATA payload with 6 bytes left, got {expect:?}"
);
// The peer pauses past `stream_idle_timeout` (30 s in
// `test_h2_connection`), then sends the rest and a PING.
context.now += Duration::from_secs(31);
let mut rest = b"efghij".to_vec();
rest.extend(orphan_ping());
peer.write_all(&rest).expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
let read_on =
drive_reads_with_readiness_forced(&mut connection, &mut context, &mut router, |c| {
!c.core.output.is_empty()
});
assert!(
read_on,
"after the reap the connection must still read the PING behind the \
orphaned payload; state={:?} expect_read={:?}",
connection.core.state,
connection.core.stream_table.expect_read()
);
assert!(
connection.core.stream_table.is_empty(),
"premise: the idle guard reaped stream 1"
);
}
/// #1597: the orphaned frame never reaches `handle_data_frame`, so its
/// whole wire length must still be credited to the connection receive
/// window (RFC 9113 §6.9), or every reap shrinks the peer's window.
///
/// TO SEE THIS RED: in [`ConnectionH2::skip_orphaned_data_payload`],
/// delete the `account_received_bytes` block. The test then fails with
/// `the whole orphaned DATA frame must be credited to the connection
/// window`. Verified 2026-09-26.
#[test]
fn a_skipped_orphaned_data_payload_is_credited_to_the_connection_window() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let expect = open_stream_mid_data_payload(
&mut connection,
&mut peer,
&mut context,
&mut router,
10,
4,
);
assert!(matches!(expect, Some((H2StreamId::Other { .. }, 6))));
context.now += Duration::from_secs(31);
let mut rest = b"efghij".to_vec();
rest.extend(orphan_ping());
peer.write_all(&rest).expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
assert!(drive_reads_with_readiness_forced(
&mut connection,
&mut context,
&mut router,
|c| !c.core.output.is_empty(),
));
// A threshold of 1 hands back everything accumulated since the last
// connection WINDOW_UPDATE: exactly the orphaned frame's length.
assert_eq!(
connection.core.flow_control.account_received_bytes(0, 1),
Some(10),
"the whole orphaned DATA frame must be credited to the connection window"
);
}
/// #1597: a remainder close to `max_frame_size` is skipped in pieces as
/// small as the room in `zero` allows, never by waiting for room for all
/// of it. With 4 KiB buffers a 16 KiB remainder can never fit at once.
///
/// TO SEE THIS RED: in [`ConnectionH2::poll_read_target`], replace
/// `amount.min(available)` by `amount`. The connection then parks for
/// good on the full remainder and the test fails with `the orphaned
/// remainder must be skipped in pieces`.
#[test]
fn a_remainder_near_max_frame_size_is_skipped_in_pieces() {
use std::io::Write;
const PAYLOAD_LEN: u32 = 16_384;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 4_096)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let expect = open_stream_mid_data_payload(
&mut connection,
&mut peer,
&mut context,
&mut router,
PAYLOAD_LEN,
0,
);
assert!(
matches!(expect, Some((H2StreamId::Other { .. }, n)) if n == PAYLOAD_LEN as usize),
"premise: waiting on the whole DATA payload, got {expect:?}"
);
assert!(
connection.core.zero.storage.capacity() < PAYLOAD_LEN as usize,
"premise: the remainder cannot fit in `zero` at once"
);
context.now += Duration::from_secs(31);
let mut rest = vec![b'x'; PAYLOAD_LEN as usize];
rest.extend(orphan_ping());
peer.write_all(&rest).expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
let read_on =
drive_reads_with_readiness_forced(&mut connection, &mut context, &mut router, |c| {
!c.core.output.is_empty()
});
assert!(
read_on,
"the orphaned remainder must be skipped in pieces; state={:?} expect_read={:?}",
connection.core.state,
connection.core.stream_table.expect_read()
);
assert!(
matches!(connection.core.state, H2State::Header),
"the connection must be back on frame headers, not in {:?}",
connection.core.state
);
assert_eq!(
connection.core.flow_control.account_received_bytes(0, 1),
Some(PAYLOAD_LEN),
"the whole orphaned DATA frame must be credited to the connection window"
);
}
/// #1597, parked form: a stream parked on buffer pressure (READABLE
/// interest removed until `try_resume_reading` sees room) and then
/// reaped by `Mux::timeout`'s `cancel_timed_out_streams` must resume
/// reading. `try_resume_reading` only resumes a stream that still
/// exists, so nothing else restores the interest.
///
/// Unlike the other #1597 tests, this one never re-inserts READABLE
/// interest itself: it calls `readable()` only when the mux would.
///
/// TO SEE THIS RED: in [`ConnectionH2::skip_orphaned_data_payload`],
/// delete `self.readiness.interest.insert(Ready::READABLE);`. The test
/// fails with `a reaped parked stream must not leave the connection
/// unable to read`.
#[test]
fn reaping_a_stream_parked_on_buffer_pressure_resumes_reading() {
use std::io::Write;
const PAYLOAD_LEN: u32 = 16_384;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 4_096)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let expect = open_stream_mid_data_payload(
&mut connection,
&mut peer,
&mut context,
&mut router,
PAYLOAD_LEN,
0,
);
assert!(
matches!(expect, Some((H2StreamId::Other { .. }, n)) if n == PAYLOAD_LEN as usize),
"premise: waiting on the whole DATA payload, got {expect:?}"
);
// The next pass finds no room for the frame in the stream buffer.
connection.readable(&mut context, EndpointClient(&mut router));
assert!(
!connection.core.readiness.interest.is_readable(),
"premise: the stream buffer cannot take the frame, so the read parked"
);
let mut rest = vec![b'x'; PAYLOAD_LEN as usize];
rest.extend(orphan_ping());
peer.write_all(&rest).expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
// `Mux::timeout` reaps the parked stream.
context.now += Duration::from_secs(31);
connection.cancel_timed_out_streams(&mut context, &mut EndpointClient(&mut router));
assert!(
connection.core.stream_table.is_empty(),
"premise: the idle guard reaped stream 1"
);
let mut read_on = false;
for _ in 0..256 {
connection.core.readiness.event.insert(Ready::READABLE);
if connection.core.readiness.filter_interest().is_readable() {
connection.readable(&mut context, EndpointClient(&mut router));
}
if !connection.core.output.is_empty() {
read_on = true;
break;
}
std::thread::yield_now();
}
assert!(
read_on,
"a reaped parked stream must not leave the connection unable to read; \
state={:?} expect_read={:?} interest={:?}",
connection.core.state,
connection.core.stream_table.expect_read(),
connection.core.readiness.interest
);
}
/// #1597, backend form: `end_stream` on a `Position::Client` connection
/// (the client gave up, or the backend timeout fired) while the backend
/// is mid-way through a response DATA frame. The RST_STREAM(CANCEL)
/// `end_stream` queues must reach the backend, ahead of the PING ACK the
/// connection queues once it reads on. Before #1604 the RST was written
/// into `zero`, which `handle_header_state` parses from its first byte,
/// so an unflushed RST there was read back as if the backend had sent it,
/// then cleared, and never sent; the output queue shares nothing with the
/// read side any more.
///
/// The loop drives the connection the way `Mux` does: `writable()` and
/// `readable()` only when the filtered readiness allows them.
///
/// TO SEE THIS RED: in the `Position::Client` arm of
/// [`ConnectionH2::end_stream`], make the RST's `if` condition start with
/// `false &&`, so the RST is never queued. The test then fails with `the
/// backend must receive exactly RST_STREAM(CANCEL) for stream 1, then the
/// PING ACK`, `left` being the ACK alone. Verified 2026-09-27.
#[test]
fn end_stream_on_a_backend_mid_data_payload_sends_its_rst_before_reading_on() {
use std::io::{Read, Write};
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (socket, mut peer) = connected_socket();
peer.set_nonblocking(true)
.expect("the loopback peer must switch to non-blocking");
let backend = Rc::new(RefCell::new(crate::backends::Backend::new(
"orphan-backend",
"127.0.0.1:2".parse().expect("backend address must parse"),
None,
None,
None,
)));
let mut registry = crate::protocol::mux::BackendRegistry::default();
let mut connection = H2Shell::new(
Ulid::generate(),
socket,
Position::Client(
"orphan-cluster".into(),
registry.id_for(&backend),
BackendStatus::Connected,
),
&mut PoolBufferSource::new(Rc::downgrade(&pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, 9)),
Ready::READABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection");
connection.core.state = H2State::Header;
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
context.streams[gid].state = StreamState::Linked(mio::Token(1));
// The backend is answering, so the request's HEADERS reached it: only
// a stream opened on the wire is reset (sozu-proxy/sozu#1631).
context.streams[gid].front.consumed = true;
connection
.core
.stream_table
.register(1, gid, connection.core.now);
// The backend starts a 10-byte response DATA frame and sends 4 bytes.
peer.write_all(&orphan_frame(0, 0, 1, 10, b"abcd"))
.expect("loopback write must complete");
for _ in 0..64 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Other { .. }, 6))
) {
break;
}
std::thread::yield_now();
}
assert!(
matches!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Other { .. }, 6))
),
"premise: mid response DATA payload with 6 bytes left, got {:?}",
connection.core.stream_table.expect_read()
);
connection.end_stream(gid, &mut context);
// The backend, not yet aware of the RST, finishes the frame and pings.
let mut rest = b"efghij".to_vec();
rest.extend(orphan_ping());
peer.write_all(&rest).expect("loopback write must complete");
let mut received = Vec::new();
let expected_len = 13 + 17;
for _ in 0..256 {
connection
.core
.readiness
.event
.insert(Ready::READABLE | Ready::WRITABLE);
if connection.core.readiness.filter_interest().is_writable() {
connection.writable(&mut context, EndpointClient(&mut router));
}
if connection.core.readiness.filter_interest().is_readable() {
connection.readable(&mut context, EndpointClient(&mut router));
}
let mut buf = [0u8; 256];
if let Ok(n) = peer.read(&mut buf) {
received.extend_from_slice(&buf[..n]);
}
if received.len() >= expected_len
|| matches!(connection.core.state, H2State::GoAway | H2State::Error)
{
break;
}
std::thread::yield_now();
}
let mut expected = orphan_frame(3, 0, 1, 4, &(H2Error::Cancel as u32).to_be_bytes());
expected.extend(orphan_frame(6, parser::FLAG_ACK, 0, 8, b"pingpong"));
assert_eq!(
received,
expected,
"the backend must receive exactly RST_STREAM(CANCEL) for stream 1, then the \
PING ACK; state={:?} expect_read={:?}",
connection.core.state,
connection.core.stream_table.expect_read()
);
}
/// What [`backend_with_a_linked_stream`] builds. Callers bind `_pool`
/// first so it is dropped last, after everything holding its buffers.
struct LinkedBackend {
connection: H2Shell<mio::net::TcpStream>,
/// The backend's end of the socket.
peer: std::net::TcpStream,
context: Context<TestListener>,
router: Router,
/// The linked stream's global id.
gid: GlobalStreamId,
_pool: Rc<RefCell<Pool>>,
}
/// A backend (`Position::Client`) connection in `state` over a loopback
/// socket, with `interest`, and one stream linked to it by
/// [`H2Shell::start_stream`]: stream 1, whose HEADERS no write pass has
/// sent.
fn backend_with_a_linked_stream(
state: H2State,
status: BackendStatus,
interest: Ready,
) -> LinkedBackend {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (socket, peer) = connected_socket();
let backend = Rc::new(RefCell::new(crate::backends::Backend::new(
"idle-backend",
"127.0.0.1:2".parse().expect("backend address must parse"),
None,
None,
None,
)));
let mut registry = crate::protocol::mux::BackendRegistry::default();
let expect_read = match state {
H2State::ClientPreface | H2State::ClientSettings => None,
_ => Some((H2StreamId::Zero, 9)),
};
let mut connection = H2Shell::new(
Ulid::generate(),
socket,
Position::Client("idle-cluster".into(), registry.id_for(&backend), status),
&mut PoolBufferSource::new(Rc::downgrade(&pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
expect_read,
interest,
)
.expect("a pool with free buffers must yield an H2 connection");
connection.core.state = state;
let mut context = test_context(&pool);
let router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
assert!(
connection.start_stream(gid, &mut context),
"premise: the backend connection accepts the stream"
);
assert_eq!(
connection.core.stream_table.get(1),
Some(gid),
"premise: the stream got wire id 1"
);
LinkedBackend {
connection,
peer,
context,
router,
gid,
_pool: pool,
}
}
/// Drive a backend connection the way `Mux` does — `writable()` and
/// `readable()` only when the filtered readiness allows them — and answer
/// every byte its peer end received.
fn drive_and_read_backend(
connection: &mut H2Shell<mio::net::TcpStream>,
peer: &mut std::net::TcpStream,
context: &mut Context<TestListener>,
router: &mut Router,
) -> Vec<u8> {
use std::io::Read;
let mut received = Vec::new();
for _ in 0..64 {
connection
.core
.readiness
.event
.insert(Ready::READABLE | Ready::WRITABLE);
if connection.core.readiness.filter_interest().is_writable() {
connection.writable(context, EndpointClient(router));
}
if connection.core.readiness.filter_interest().is_readable() {
connection.readable(context, EndpointClient(router));
}
let mut buf = [0u8; 256];
if let Ok(n) = peer.read(&mut buf) {
received.extend_from_slice(&buf[..n]);
}
std::thread::yield_now();
}
received
}
/// Queue a GET request on `gid`'s request side, closed with END_STREAM,
/// the way a relayed request reaches a backend's write pass.
fn queue_request(context: &mut Context<TestListener>, gid: GlobalStreamId) {
let kawa = &mut context.streams[gid].front;
kawa.detached.status_line = kawa::StatusLine::Request {
version: kawa::Version::V20,
method: kawa::Store::Static(b"GET"),
uri: kawa::Store::Static(b"/"),
authority: kawa::Store::Static(b"example.com"),
path: kawa::Store::Static(b"/"),
};
kawa.push_block(kawa::Block::StatusLine);
kawa.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"x-client"),
val: kawa::Store::Static(b"alice"),
}));
kawa.push_block(kawa::Block::Flags(kawa::Flags {
end_body: false,
end_chunk: false,
end_header: true,
end_stream: true,
}));
kawa.parsing_phase = kawa::ParsingPhase::Terminated;
}
/// Frame-level pin of `handle_data_frame`'s bodiless branches: a backend
/// answers `method` with the HEADERS block `head` (no END_STREAM, so the
/// stream stays linked, RFC 9113 §8.1), then DATA carrying `hello`
/// flagged END_STREAM. Returns the frames the backend reads after the
/// DATA, whether the payload was queued on the response, whether the
/// stream is still tracked, and how many bytes the DATA frame left in the
/// response buffer.
fn bodiless_response_with_data(
method: crate::protocol::kawa_h1::parser::Method,
head: &[u8],
) -> (Vec<PeerFrame>, bool, bool, usize) {
use std::io::Write;
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
gid,
} = backend_with_a_linked_stream(
H2State::Header,
BackendStatus::Connected,
Ready::READABLE | Ready::HUP | Ready::ERROR,
);
queue_request(&mut context, gid);
context.streams[gid].context.method = Some(method);
// Open on the client side, as a stream the router linked; `Link`
// carries no frontend token for `reset_stream` to end.
context.streams[gid].state = StreamState::Link;
let request = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert!(
peer_frames(&request)
.expect("whole frames")
.iter()
.any(|(kind, _, id, _)| *kind == 1 && *id == 1),
"premise: the request went out on stream 1, got {request:?}"
);
peer.write_all(&orphan_frame(1, 0x4, 1, head.len() as u32, head))
.expect("loopback write must complete");
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let end_before = context.streams[gid].back.storage.end;
peer.write_all(&orphan_frame(0, 0x1, 1, 5, b"hello"))
.expect("loopback write must complete");
let after = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let after = peer_frames(&after).expect("whole frames");
assert!(
matches!(connection.core.state, H2State::Header),
"the shared connection stays up, got {:?}",
connection.core.state
);
let back = &context.streams[gid].back;
let buffer = back.storage.buffer();
let queued = back.blocks.iter().any(|block| {
matches!(block, kawa::Block::Chunk(kawa::Chunk { data })
if !data.data(buffer).is_empty())
});
let tracked = connection.core.stream_table.get(1).is_some();
let retained = back.storage.end.saturating_sub(end_before);
(after, queued, tracked, retained)
}
/// RFC 9110 §15.3.5, §15.4.5, RFC 9113 §8.1.1: DATA carrying a payload
/// on a 204 still linked to its backend stream resets that stream with
/// PROTOCOL_ERROR before the payload is queued, without a GOAWAY. RFC 9110
/// §9.3.2: the payload of a response to HEAD is dropped from the stream
/// buffer and never queued, and its END_STREAM still ends the response,
/// with no frame sent. A 200 to GET forwards the payload.
///
/// TO SEE THIS RED: make `ConnectionH2::backend_response_content` return
/// `BackendResponseContent::Forwarded` for every response, or keep the
/// discarded payload behind `storage.head` in `handle_data_frame`.
#[test]
fn a_204_response_carrying_data_resets_its_backend_stream_and_a_head_response_discards_it() {
use crate::protocol::kawa_h1::parser::Method;
// :status 204 (static index 9).
let (frames, queued, tracked, _) = bodiless_response_with_data(Method::Get, &[0x89]);
assert_eq!(
frames,
vec![(3, 0, 1, vec![0, 0, 0, 1])],
"a 204 carrying DATA gets RST_STREAM(PROTOCOL_ERROR) alone"
);
assert!(!queued, "the payload of a 204 is never queued");
assert!(!tracked, "the reset stream is retired");
// :status 200 (static index 8), to HEAD.
let (frames, queued, _, retained) = bodiless_response_with_data(Method::Head, &[0x88]);
assert!(
!frames
.iter()
.any(|(kind, _, _, _)| *kind == 3 || *kind == 7),
"a response to HEAD carrying DATA is not reset, got {frames:?}"
);
assert!(!queued, "the payload of a response to HEAD is never queued");
assert_eq!(
retained, 0,
"the payload of a response to HEAD leaves no byte in the buffer"
);
let (frames, queued, _, retained) = bodiless_response_with_data(Method::Get, &[0x88]);
assert!(
!frames
.iter()
.any(|(kind, _, _, _)| *kind == 3 || *kind == 7),
"premise: a 200 to GET is not reset, got {frames:?}"
);
assert!(queued, "premise: the payload of a 200 to GET is queued");
assert_eq!(
retained, 5,
"premise: a forwarded payload stays in the buffer"
);
}
/// RFC 9113 §5.1: after the PROTOCOL_ERROR reset of a 204 carrying DATA,
/// the trailer block the backend had already sent on that stream is
/// ignored, in one HEADERS frame or split over a CONTINUATION frame
/// (RFC 9113 §6.10). The shared backend connection gets no GOAWAY and no
/// further RST_STREAM, and stays up.
///
/// TO SEE THIS RED: in the `H2State::Discard` arm of
/// `ConnectionH2::handle_read` (`lib/src/protocol/mux/h2.rs`), drop the
/// `pending_discarded_block` store: the CONTINUATION is taken for a
/// standalone frame and answered GOAWAY(PROTOCOL_ERROR):
/// `split=true: ... got [(7, 0, 0, [0, 0, 0, 0, 0, 0, 0, 1])]`, verified
/// 2026-10-02. The split row is also red on `2ce07244`
/// (sozu-proxy/sozu#1775 before sozu-proxy/sozu#1784 was merged), where
/// the unsplit row already passes.
#[test]
fn a_trailer_after_the_reset_of_a_204_carrying_data_is_ignored() {
use std::io::Write;
for split in [false, true] {
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
gid,
} = backend_with_a_linked_stream(
H2State::Header,
BackendStatus::Connected,
Ready::READABLE | Ready::HUP | Ready::ERROR,
);
queue_request(&mut context, gid);
context.streams[gid].context.method =
Some(crate::protocol::kawa_h1::parser::Method::Get);
context.streams[gid].state = StreamState::Link;
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
// :status 204 (static index 9), without END_STREAM, then DATA
// without END_STREAM: the backend means to end with a trailer.
peer.write_all(&orphan_frame(1, 0x4, 1, 1, &[0x89]))
.expect("loopback write must complete");
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
peer.write_all(&orphan_frame(0, 0x0, 1, 5, b"hello"))
.expect("loopback write must complete");
let reset =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let reset = peer_frames(&reset).expect("whole frames");
assert_eq!(
reset,
vec![(3, 0, 1, vec![0, 0, 0, 1])],
"split={split}: premise: a 204 carrying DATA gets RST_STREAM(PROTOCOL_ERROR) alone"
);
// The trailer block `x-trailer: one` (literal without indexing),
// ending the stream.
let mut trailer = vec![0x00, 9];
trailer.extend_from_slice(b"x-trailer");
trailer.extend_from_slice(&[3]);
trailer.extend_from_slice(b"one");
let wire = if split {
let (first, rest) = trailer.split_at(4);
let mut wire = orphan_frame(1, 0x1, 1, first.len() as u32, first);
wire.extend(orphan_frame(9, 0x4, 1, rest.len() as u32, rest));
wire
} else {
orphan_frame(1, 0x4 | 0x1, 1, trailer.len() as u32, &trailer)
};
peer.write_all(&wire).expect("loopback write must complete");
let after =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let after = peer_frames(&after).expect("whole frames");
assert!(
!after.iter().any(|(kind, _, _, _)| *kind == 7 || *kind == 3),
"split={split}: the trailer on the reset stream is ignored: no GOAWAY, no \
RST_STREAM, got {after:?}"
);
assert!(
matches!(connection.core.state, H2State::Header),
"split={split}: the shared connection stays up, got {:?}",
connection.core.state
);
}
}
/// sozu-proxy/sozu#1631, `Header` row: a backend stream ended before any
/// write pass sent its HEADERS is idle on the backend, and a RST_STREAM
/// for it is a connection error there (RFC 9113 §5.1, §6.4) that takes
/// down every stream of the shared connection. It must be forgotten: no
/// frame at all.
///
/// TO SEE THIS RED: in the `Position::Client` arm of
/// [`ConnectionH2::end_stream`], replace `opened_on_the_wire` in the RST
/// condition with `true`. The backend then reads `RST_STREAM(1, CANCEL)`:
/// `left: [0, 0, 4, 3, 0, 0, 0, 0, 1, 0, 0, 0, 8], right: []`. Red on
/// `d5161919`, verified 2026-09-28.
#[test]
fn a_backend_stream_ended_before_its_headers_left_is_not_reset() {
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
gid,
} = backend_with_a_linked_stream(
H2State::Header,
BackendStatus::Connected,
Ready::READABLE | Ready::HUP | Ready::ERROR,
);
connection.end_stream(gid, &mut context);
let received =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert_eq!(
received,
Vec::<u8>::new(),
"the backend must receive nothing for a stream it never saw"
);
assert!(
connection.core.stream_table.is_empty()
&& !connection.core.stream_table.rst_sent_contains(1),
"the stream is retired without a reset"
);
assert!(
matches!(connection.core.state, H2State::Header),
"the shared connection stays up, got {:?}",
connection.core.state
);
}
/// sozu-proxy/sozu#1631, `ClientPreface` row: the backend connection's
/// TCP connect is still in progress when the stream ends. The preface
/// must be the first bytes the backend reads (RFC 9113 §3.4), with no
/// frame for the stream anywhere.
///
/// TO SEE THIS RED: same edit as
/// `a_backend_stream_ended_before_its_headers_left_is_not_reset`. The
/// backend then reads the RST before `PRI * HTTP/2.0`: `the connection
/// preface must come first`. Red on `d5161919`, verified 2026-09-28.
#[test]
fn a_backend_stream_ended_before_the_preface_leaves_the_preface_first() {
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
gid,
} = backend_with_a_linked_stream(
H2State::ClientPreface,
BackendStatus::Connecting(Instant::now()),
Ready::WRITABLE | Ready::HUP | Ready::ERROR,
);
connection.end_stream(gid, &mut context);
let received =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let preface = serializer::H2_PRI.as_bytes();
assert!(
received.starts_with(preface),
"the connection preface must come first, got {received:?}"
);
let frames =
peer_frames(&received[preface.len()..]).expect("whole frames after the preface");
assert!(
frames.iter().all(|(kind, _, id, _)| *id == 0 && *kind != 3),
"only connection-level frames may follow, got {frames:?}"
);
}
/// sozu-proxy/sozu#1631, `ServerSettings` row: the preface is out and the
/// server's SETTINGS are not read yet. Neither ending a stream nor
/// linking one may kill the connection; `start_stream` arms a write, as
/// it must for `Router::plan_connect`, whose pool scan links onto a
/// `Connected` connection whatever its handshake state.
///
/// TO SEE THIS RED: in [`ConnectionH2::dispatch_writable_state`], move
/// `(H2State::ServerSettings, Position::Client(..))` back into the
/// `Unexpected combination` arm. The connection then force-disconnects:
/// `the shared connection must survive the handshake, got Error`. Red on
/// `d5161919`, verified 2026-09-28.
#[test]
fn a_backend_stream_ended_during_the_handshake_keeps_the_connection() {
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
gid,
} = backend_with_a_linked_stream(
H2State::ServerSettings,
BackendStatus::Connected,
Ready::READABLE | Ready::HUP | Ready::ERROR,
);
connection.end_stream(gid, &mut context);
let received =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert!(
matches!(connection.core.state, H2State::ServerSettings),
"the shared connection must survive the handshake, got {:?}",
connection.core.state
);
assert_eq!(
received,
Vec::<u8>::new(),
"the backend must receive nothing for a stream it never saw"
);
}
/// A backend may answer early, before the whole request reached it
/// (RFC 9113 §8.1). When the stream then ends with the request received
/// whole from the client but its tail still unsent toward the backend,
/// the backend never sees END_STREAM: forgetting the stream without a
/// reset leaves it open (half-closed) on the backend, holding one of the
/// backend's MAX_CONCURRENT_STREAMS slots for this connection's lifetime
/// (RFC 9113 §5.1.2). The stream must be cancelled, exactly once.
///
/// TO SEE THIS RED: in the `Position::Client` arm of
/// [`ConnectionH2::end_stream`], drop `&& stream.front.is_completed()`
/// from `fully_completed`. The backend then reads no RST_STREAM: `the
/// backend must read RST_STREAM(1, CANCEL) once`, `right` holding that
/// frame and `left` nothing.
#[test]
fn a_backend_stream_answered_before_its_request_was_written_is_cancelled() {
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
gid,
} = backend_with_a_linked_stream(
H2State::Header,
BackendStatus::Connected,
Ready::READABLE | Ready::HUP | Ready::ERROR,
);
// A request received whole from the client: its HEADERS, then a
// 4-byte body closed with END_STREAM.
let kawa = &mut context.streams[gid].front;
kawa.detached.status_line = kawa::StatusLine::Request {
version: kawa::Version::V20,
method: kawa::Store::Static(b"POST"),
uri: kawa::Store::Static(b"/"),
authority: kawa::Store::Static(b"example.com"),
path: kawa::Store::Static(b"/"),
};
kawa.push_block(kawa::Block::StatusLine);
kawa.push_block(kawa::Block::Header(kawa::Pair {
key: kawa::Store::Static(b"content-length"),
val: kawa::Store::Static(b"4"),
}));
kawa.push_block(kawa::Block::Flags(kawa::Flags {
end_body: false,
end_chunk: false,
end_header: true,
end_stream: false,
}));
kawa.push_block(kawa::Block::Chunk(kawa::Chunk {
data: kawa::Store::Static(b"body"),
}));
kawa.push_block(kawa::Block::Flags(kawa::Flags {
end_body: true,
end_chunk: false,
end_header: false,
end_stream: true,
}));
kawa.body_size = kawa::BodySize::Length(4);
kawa.parsing_phase = kawa::ParsingPhase::Terminated;
// The backend grants the stream no send window: its HEADERS leave,
// its body waits.
context.streams[gid].back_window = 0;
let sent = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let frames = peer_frames(&sent).expect("whole frames");
assert!(
frames
.iter()
.any(|(kind, flags, id, _)| *kind == 1 && *id == 1 && flags & 0x1 == 0)
&& !frames.iter().any(|(kind, _, _, _)| *kind == 0),
"premise: the HEADERS left without END_STREAM and no DATA, got {frames:?}"
);
let stream = &context.streams[gid];
assert!(
stream.front.consumed && stream.front.is_terminated() && !stream.front.is_completed(),
"premise: the request was received whole but not written whole"
);
// The backend's complete response arrived: its END_STREAM was read.
context.streams[gid].back_received_end_of_stream = true;
connection.end_stream(gid, &mut context);
// Ending it again must not reset it a second time.
connection.end_stream(gid, &mut context);
let received =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert_eq!(
received,
orphan_frame(3, 0, 1, 4, &(H2Error::Cancel as u32).to_be_bytes()),
"the backend must read RST_STREAM(1, CANCEL) once"
);
assert!(
connection.core.stream_table.is_empty(),
"the stream is retired from the backend connection"
);
assert!(
matches!(connection.core.state, H2State::Header),
"the shared connection stays up, got {:?}",
connection.core.state
);
}
/// The liveness half of the `ServerSettings` fix: the write readiness
/// withdrawn while the server's SETTINGS are awaited comes back with
/// them, and a request linked during the handshake goes out after the
/// SETTINGS ACK.
///
/// TO SEE THIS RED: in [`ConnectionH2::dispatch_writable_state`], move
/// `(H2State::ServerSettings, Position::Client(..))` back into the
/// `Unexpected combination` arm: the connection force-disconnects before
/// the SETTINGS arrive, `premise: nothing is written before the server
/// SETTINGS, got [] in Error`. Red on `d5161919`, verified 2026-09-28.
#[test]
fn a_request_linked_during_the_handshake_goes_out_after_the_settings() {
use std::io::Write;
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
gid,
} = backend_with_a_linked_stream(
H2State::ServerSettings,
BackendStatus::Connected,
Ready::READABLE | Ready::HUP | Ready::ERROR,
);
queue_request(&mut context, gid);
let before = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert!(
before.is_empty() && matches!(connection.core.state, H2State::ServerSettings),
"premise: nothing is written before the server SETTINGS, got {before:?} in {:?}",
connection.core.state
);
peer.write_all(&orphan_frame(4, 0, 0, 0, b""))
.expect("loopback write must complete");
let after = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let frames = peer_frames(&after).expect("whole frames");
assert!(
frames
.iter()
.any(|(kind, flags, id, _)| *kind == 4 && *flags == parser::FLAG_ACK && *id == 0),
"the server SETTINGS are acknowledged, got {frames:?}"
);
assert!(
frames.iter().any(|(kind, _, id, _)| *kind == 1 && *id == 1),
"the request linked during the handshake must go out, got {frames:?}"
);
}
/// A backend stream 1 whose response is framed by `content-length: 3`,
/// its request sent and its response carrying 5 bytes of DATA, which
/// makes Sōzu reset it with PROTOCOL_ERROR (RFC 9113 §8.1.1). Returns the
/// fixture, ready for the frames the backend sends after the reset.
fn backend_stream_reset_for_its_content_length() -> LinkedBackend {
use std::io::Write;
let mut linked = backend_with_a_linked_stream(
H2State::Header,
BackendStatus::Connected,
Ready::READABLE | Ready::HUP | Ready::ERROR,
);
let LinkedBackend {
connection,
peer,
context,
router,
gid,
..
} = &mut linked;
queue_request(context, *gid);
// Open on the client side, as a stream the router linked; `Link`
// carries no frontend token for `reset_stream` to end.
context.streams[*gid].state = StreamState::Link;
let request = drive_and_read_backend(connection, peer, context, router);
assert!(
peer_frames(&request)
.expect("whole frames")
.iter()
.any(|(kind, _, id, _)| *kind == 1 && *id == 1),
"premise: the request went out on stream 1, got {request:?}"
);
// :status 200 (static index 8), then `content-length: 3` as a
// literal without indexing.
let mut head = vec![0x88, 0x00, 14];
head.extend_from_slice(b"content-length");
head.extend_from_slice(&[1, b'3']);
let mut wire = orphan_frame(1, 0x4, 1, head.len() as u32, &head);
wire.extend(orphan_frame(0, 0, 1, 5, b"hello"));
peer.write_all(&wire).expect("loopback write must complete");
let reset = drive_and_read_backend(connection, peer, context, router);
let reset = peer_frames(&reset).expect("whole frames");
assert!(
reset
.iter()
.any(|(kind, _, id, payload)| *kind == 3 && *id == 1 && payload == &[0, 0, 0, 1]),
"premise: stream 1 is reset with PROTOCOL_ERROR, got {reset:?}"
);
linked
}
/// The trailer block adding `x-dyn: one` to the HPACK dynamic table
/// (literal with incremental indexing) on stream 1, in one HEADERS frame
/// or, with `split`, a HEADERS frame without END_HEADERS and a
/// CONTINUATION frame (RFC 9113 §6.10).
fn dynamic_trailer_block(split: bool) -> Vec<u8> {
let mut trailer = vec![0x40, 5];
trailer.extend_from_slice(b"x-dyn");
trailer.extend_from_slice(&[3]);
trailer.extend_from_slice(b"one");
if split {
let (first, rest) = trailer.split_at(4);
let mut wire = orphan_frame(1, 0x1, 1, first.len() as u32, first);
wire.extend(orphan_frame(9, 0x4, 1, rest.len() as u32, rest));
wire
} else {
orphan_frame(1, 0x4 | 0x1, 1, trailer.len() as u32, &trailer)
}
}
/// RFC 9113 §5.1: after sozu resets a backend stream, the backend may
/// still send frames it queued before reading the RST_STREAM, and sozu
/// MUST ignore them. Here the backend sends a trailer block, in one
/// HEADERS frame or split over a CONTINUATION frame, and another DATA
/// frame on the reset stream. The trailer block adds `x-dyn: one` to the
/// HPACK dynamic table, and the response on stream 3 refers to that
/// entry, so it parses only if the ignored block was decoded whole
/// (§4.3). What the backend had in flight counts no glitch.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_header_state`, drop the
/// `was_reset_locally` branch: the trailer HEADERS is answered
/// GOAWAY(STREAM_CLOSED); or, in the `H2State::Discard` arm,
/// drop the `pending_discarded_block` store: the CONTINUATION is
/// answered GOAWAY(PROTOCOL_ERROR) as standalone.
#[test]
fn frames_on_a_backend_stream_sozu_reset_are_ignored() {
use std::io::Write;
for split in [true, false] {
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
..
} = backend_stream_reset_for_its_content_length();
let glitches = connection.core.flood_detector.glitch_count();
let mut wire = dynamic_trailer_block(split);
wire.extend(orphan_frame(0, 0x1, 1, 2, b"zz"));
peer.write_all(&wire).expect("loopback write must complete");
let after =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let after = peer_frames(&after).expect("whole frames");
assert!(
!after.iter().any(|(kind, _, _, _)| *kind == 7 || *kind == 3),
"split={split}: frames on the reset stream are ignored: no GOAWAY, no \
RST_STREAM, got {after:?}"
);
assert!(
matches!(connection.core.state, H2State::Header),
"split={split}: the shared connection stays up, got {:?}",
connection.core.state
);
assert_eq!(
connection.core.flood_detector.glitch_count(),
glitches,
"split={split}: what the backend had in flight counts no glitch"
);
// A second stream, answered with `:status 200` and the dynamic
// entry the ignored trailer block added (index 62).
let second = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
assert!(
connection.start_stream(second, &mut context),
"the backend connection accepts a second stream"
);
queue_request(&mut context, second);
context.streams[second].state = StreamState::Link;
let request =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert!(
peer_frames(&request)
.expect("whole frames")
.iter()
.any(|(kind, _, id, _)| *kind == 1 && *id == 3),
"premise: the second request went out on stream 3, got {request:?}"
);
peer.write_all(&orphan_frame(1, 0x4 | 0x1, 3, 2, &[0x88, 0xbe]))
.expect("loopback write must complete");
let last =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let last = peer_frames(&last).expect("whole frames");
assert!(
!last.iter().any(|(kind, _, _, _)| *kind == 7),
"split={split}: the second response decodes against the same dynamic table, \
got {last:?}"
);
let buffer = context.streams[second].back.storage.buffer();
let has_dynamic_field = context.streams[second].back.blocks.iter().any(|block| {
matches!(block, kawa::Block::Header(kawa::Pair { key, val })
if key.data(buffer) == b"x-dyn" && val.data(buffer) == b"one")
});
assert!(
has_dynamic_field,
"split={split}: the second response carries the field of the dynamic entry"
);
}
}
/// DATA a backend sends on a stream Sōzu reset counts no glitch while it
/// fits the stream's receive window, the 65 535 bytes Sōzu advertised
/// and keeps crediting back: the backend may have had that much in
/// flight when it read the reset (RFC 9113 §6.9). DATA beyond it counts
/// one glitch per frame.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_header_state`, count a glitch for
/// every frame ignored on a reset stream: `in-flight DATA counts no
/// glitch`.
#[test]
fn data_on_a_reset_backend_stream_counts_a_glitch_beyond_its_window_only() {
use std::io::Write;
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
..
} = backend_stream_reset_for_its_content_length();
let glitches = connection.core.flood_detector.glitch_count();
// 5 bytes already arrived before the reset; four 16 000-byte frames
// still fit in the 65 535-byte window.
let chunk = vec![b'd'; 16_000];
let mut wire = Vec::new();
for _ in 0..4 {
wire.extend(orphan_frame(0, 0, 1, chunk.len() as u32, &chunk));
}
peer.write_all(&wire).expect("loopback write must complete");
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert_eq!(
connection.core.flood_detector.glitch_count(),
glitches,
"in-flight DATA counts no glitch"
);
peer.write_all(&orphan_frame(0, 0, 1, chunk.len() as u32, &chunk))
.expect("loopback write must complete");
let after = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert_eq!(
connection.core.flood_detector.glitch_count(),
glitches + 1,
"DATA beyond the stream's window counts a glitch"
);
assert!(
!peer_frames(&after)
.expect("whole frames")
.iter()
.any(|(kind, _, _, _)| *kind == 7 || *kind == 3),
"it is still ignored, not answered"
);
}
/// Header blocks a backend sends on a stream Sōzu reset count no glitch
/// for the first two, a trailer section or an interim and a final
/// response it may have had in flight (RFC 9113 §8.1), and one glitch
/// each beyond: a peer repeating blocks on a reset stream trips the
/// glitch limit and the connection ends with GOAWAY(ENHANCE_YOUR_CALM),
/// instead of having every block HPACK-decoded for free.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_header_state`, count every header
/// block on a reset stream as in flight: no GOAWAY ever comes.
#[test]
fn header_blocks_on_a_reset_backend_stream_beyond_two_count_glitches() {
use std::io::Write;
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
..
} = backend_stream_reset_for_its_content_length();
// Each small write must reach the connection before the next drive,
// not wait behind Nagle's algorithm for the previous one's ACK.
peer.set_nodelay(true).expect("TCP_NODELAY must apply");
// An explicit glitch limit, so the flood below trips it whatever the
// default (`DEFAULT_MAX_GLITCH_COUNT`) is.
connection.core.flood_detector = h2_flood_detector::H2FloodDetector::new(
H2FloodConfig::from_optional(
None,
None,
None,
None,
None,
None,
Some(100),
None,
None,
None,
None,
None,
None,
None,
),
connection.core.now,
);
let glitches = connection.core.flood_detector.glitch_count();
// `:status 200`, alone, in one HEADERS frame ending the block.
let block = || orphan_frame(1, 0x4, 1, 1, &[0x88]);
let mut wire = block();
wire.extend(block());
peer.write_all(&wire).expect("loopback write must complete");
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert_eq!(
connection.core.flood_detector.glitch_count(),
glitches,
"two header blocks may be in flight"
);
peer.write_all(&block())
.expect("loopback write must complete");
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert_eq!(
connection.core.flood_detector.glitch_count(),
glitches + 1,
"a third header block counts a glitch"
);
let mut received = Vec::new();
for _ in 0..20 {
let mut wire = Vec::new();
for _ in 0..100 {
wire.extend(block());
}
if peer.write_all(&wire).is_err() {
break;
}
received.extend(drive_and_read_backend(
&mut connection,
&mut peer,
&mut context,
&mut router,
));
if peer_frames(&received)
.map(|frames| frames.iter().any(|(kind, _, _, _)| *kind == 7))
.unwrap_or(false)
{
break;
}
}
let frames = peer_frames(&received).expect("whole frames");
assert!(
frames.iter().any(|(kind, _, _, payload)| *kind == 7
&& payload.get(4..8) == Some(&[0, 0, 0, 0xb][..])),
"repeated header blocks end the connection with GOAWAY(ENHANCE_YOUR_CALM), got {frames:?}"
);
}
/// Empty DATA frames without END_STREAM on a stream Sōzu reset count
/// toward the empty-DATA flood limit as on a live stream
/// (CVE-2019-9518): they carry no payload to charge against the stream's
/// window, so without it a peer could send them for free. A flood of
/// them ends the connection with GOAWAY(ENHANCE_YOUR_CALM).
///
/// TO SEE THIS RED: in `ConnectionH2::handle_header_state`, drop the
/// `record_empty_data_frame` call of the `was_reset_locally` branch: no
/// GOAWAY ever comes.
#[test]
fn empty_data_frames_on_a_reset_backend_stream_count_toward_the_flood_limit() {
use std::io::Write;
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
..
} = backend_stream_reset_for_its_content_length();
peer.set_nodelay(true).expect("TCP_NODELAY must apply");
// An explicit empty-DATA limit, so the flood below trips it whatever the
// default (`DEFAULT_MAX_EMPTY_DATA_PER_WINDOW`) is. The glitch limit
// stays the default: the GOAWAY must come from the empty-DATA count.
connection.core.flood_detector = h2_flood_detector::H2FloodDetector::new(
H2FloodConfig::from_optional(
None,
None,
None,
Some(100),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
),
connection.core.now,
);
let mut received = Vec::new();
for _ in 0..50 {
let mut wire = Vec::new();
for _ in 0..100 {
wire.extend(orphan_frame(0, 0, 1, 0, b""));
}
if peer.write_all(&wire).is_err() {
break;
}
received.extend(drive_and_read_backend(
&mut connection,
&mut peer,
&mut context,
&mut router,
));
if peer_frames(&received)
.map(|frames| frames.iter().any(|(kind, _, _, _)| *kind == 7))
.unwrap_or(false)
{
break;
}
}
let frames = peer_frames(&received).expect("whole frames");
assert!(
frames.iter().any(|(kind, _, _, payload)| *kind == 7
&& payload.get(4..8) == Some(&[0, 0, 0, 0xb][..])),
"a flood of empty DATA frames ends the connection with \
GOAWAY(ENHANCE_YOUR_CALM), got {frames:?}"
);
}
/// A backend stream Sōzu reset from its write pass, because its request
/// failed after its head left, stays tracked until its end: DATA the
/// backend then sends on it is ignored (RFC 9113 §5.1), never queued for
/// the client nor counted against the response.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_header_state`, drop the
/// `was_reset_locally` branch: the DATA reaches the stream and is counted
/// in `back_data_received`.
#[test]
fn data_on_a_tracked_backend_stream_sozu_reset_is_ignored() {
use std::io::Write;
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
gid,
} = backend_with_a_linked_stream(
H2State::Header,
BackendStatus::Connected,
Ready::READABLE | Ready::HUP | Ready::ERROR,
);
// A request whose body is still to come: its head leaves, the
// stream stays open on the request side.
queue_request(&mut context, gid);
let front = &mut context.streams[gid].front;
if let Some(kawa::Block::Flags(flags)) = front.blocks.back_mut() {
flags.end_stream = false;
}
front.body_size = kawa::BodySize::Chunked;
front.parsing_phase = kawa::ParsingPhase::Chunks { first: true };
context.streams[gid].state = StreamState::Link;
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
// The request side then fails: the write pass resets the stream and
// keeps it tracked.
context.streams[gid]
.front
.parsing_phase
.error("request failed".into());
connection.core.readiness.arm_writable();
let reset = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert!(
peer_frames(&reset)
.expect("whole frames")
.iter()
.any(|(kind, _, id, _)| *kind == 3 && *id == 1),
"premise: the write pass resets stream 1, got {reset:?}"
);
assert!(
connection.core.stream_table.get(1).is_some()
&& connection.core.stream_table.rst_sent_contains(1),
"premise: the reset stream is still tracked"
);
peer.write_all(&orphan_frame(0, 0, 1, 5, b"hello"))
.expect("loopback write must complete");
let after = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert_eq!(
context.streams[gid].back_data_received, 0,
"the DATA is not counted against the response"
);
assert!(
!context.streams[gid]
.back
.blocks
.iter()
.any(|block| matches!(block, kawa::Block::Chunk(_))),
"the DATA is not queued for the client"
);
assert!(
!peer_frames(&after)
.expect("whole frames")
.iter()
.any(|(kind, _, _, _)| *kind == 7),
"the connection stays up"
);
}
/// The client-facing side of RFC 9113 §5.1: a request framed by
/// `content-length: 3` whose DATA carries 5 bytes is reset by Sōzu
/// (§8.1.1); the DATA and the trailer block the client had already sent
/// on that stream are ignored, and the connection stays up.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_header_state`, drop the
/// `was_reset_locally` branch: the trailer HEADERS is answered
/// GOAWAY(STREAM_CLOSED).
#[test]
fn frames_on_a_client_stream_sozu_reset_are_ignored() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
let block = crate::protocol::mux::hpack::Encoder::new().encode([
(&b":method"[..], &b"POST"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/upload"[..]),
(&b"content-length"[..], &b"3"[..]),
]);
let mut wire = orphan_frame(1, parser::FLAG_END_HEADERS, 1, block.len() as u32, &block);
wire.extend(orphan_frame(0, 0, 1, 5, b"hello"));
peer.write_all(&wire).expect("loopback write must complete");
let reset = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let reset = peer_frames(&reset).expect("whole frames");
assert!(
reset
.iter()
.any(|(kind, _, id, payload)| *kind == 3 && *id == 1 && payload == &[0, 0, 0, 1]),
"premise: stream 1 is reset with PROTOCOL_ERROR, got {reset:?}"
);
let mut wire = orphan_frame(0, 0, 1, 2, b"zz");
wire.extend(dynamic_trailer_block(false));
peer.write_all(&wire).expect("loopback write must complete");
let after = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let after = peer_frames(&after).expect("whole frames");
assert!(
!after.iter().any(|(kind, _, _, _)| *kind == 7 || *kind == 3),
"frames on the reset stream are ignored: no GOAWAY, no RST_STREAM, got {after:?}"
);
assert!(
matches!(connection.core.state, H2State::Header),
"the client connection stays up, got {:?}",
connection.core.state
);
}
/// A stream Sōzu refuses at its HEADERS frame (here because
/// SETTINGS_MAX_CONCURRENT_STREAMS is reached) is answered
/// RST_STREAM(REFUSED_STREAM) and its field block discarded, and the
/// CONTINUATION frame completing that block (RFC 9113 §6.10) is
/// discarded with it, not taken for a standalone CONTINUATION.
///
/// TO SEE THIS RED: in the `H2State::Discard` arm of
/// [`ConnectionH2::handle_read`], drop the `pending_discarded_block`
/// store: the CONTINUATION is answered GOAWAY(PROTOCOL_ERROR).
#[test]
fn a_continuation_of_a_refused_header_block_is_discarded_with_it() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
connection
.core
.local_settings
.settings_max_concurrent_streams = 0;
let block = crate::protocol::mux::hpack::Encoder::new().encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/"[..]),
]);
let (first, rest) = block.split_at(block.len() / 2);
let mut wire = orphan_frame(1, parser::FLAG_END_STREAM, 1, first.len() as u32, first);
wire.extend(orphan_frame(
9,
parser::FLAG_END_HEADERS,
1,
rest.len() as u32,
rest,
));
peer.write_all(&wire).expect("loopback write must complete");
let answer = drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let answer = peer_frames(&answer).expect("whole frames");
assert!(
answer
.iter()
.any(|(kind, _, id, payload)| *kind == 3 && *id == 1 && payload == &[0, 0, 0, 7]),
"the stream is refused, got {answer:?}"
);
assert!(
!answer.iter().any(|(kind, _, _, _)| *kind == 7),
"the CONTINUATION is discarded with its block, got {answer:?}"
);
assert!(
matches!(connection.core.state, H2State::Header),
"the client connection stays up, got {:?}",
connection.core.state
);
}
/// A refused header block is held to `max_header_list_size` like an
/// accepted one, its first fragment included: a 16 000-byte HEADERS frame
/// and four 12 500-byte CONTINUATION frames exceed the 65 536-byte limit
/// only with that first fragment, and end the connection with
/// GOAWAY(ENHANCE_YOUR_CALM) (CVE-2024-27316).
///
/// TO SEE THIS RED: in [`ConnectionH2::discard_field_block`], drop the
/// `begin_header_block_if_new` call: the block is read to its end, and
/// the test fails once its 30-second deadline passes without a GOAWAY.
#[test]
fn a_refused_header_block_counts_its_first_fragment_toward_its_size() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
connection
.core
.local_settings
.settings_max_concurrent_streams = 0;
let first = vec![0x88; 16_000];
let mut wire = orphan_frame(1, 0, 1, first.len() as u32, &first);
let rest = vec![0x88; 12_500];
for _ in 0..4 {
wire.extend(orphan_frame(9, 0, 1, rest.len() as u32, &rest));
}
// The peer cannot write all of it before the connection reads, so
// write it from a thread while the connection is driven.
let mut writer = peer.try_clone().expect("the peer socket must clone");
let sender = std::thread::spawn(move || writer.write_all(&wire));
let enhance_your_calm = |frames: &[PeerFrame]| {
frames.iter().any(|(kind, _, _, payload)| {
*kind == 7 && payload.get(4..8) == Some(&[0, 0, 0, 0xb][..])
})
};
// Drive until the GOAWAY reaches the peer, not for a fixed number of
// passes: on a loaded host the writer thread can be descheduled for
// all of them, and the GOAWAY the read of the last fragment queues is
// only flushed by the next pass's `writable()`.
let deadline = Instant::now() + Duration::from_secs(30);
let mut received = Vec::new();
while Instant::now() < deadline {
received.extend(drive_and_read_backend(
&mut connection,
&mut peer,
&mut context,
&mut router,
));
if peer_frames(&received).is_ok_and(|frames| enhance_your_calm(&frames)) {
break;
}
}
sender
.join()
.expect("the writer thread must not panic")
.expect("the peer must write the whole block");
let frames = peer_frames(&received).expect("whole frames");
assert!(
enhance_your_calm(&frames),
"the oversized refused block ends the connection with GOAWAY(ENHANCE_YOUR_CALM), \
got {frames:?}"
);
}
/// sozu-proxy/sozu#1631, reaper form: `cancel_timed_out_streams` has the
/// same shape as `end_stream` for a backend stream reaped before its
/// HEADERS left, and must forget it the same way.
///
/// TO SEE THIS RED: in [`ConnectionH2::cancel_timed_out_streams`], make
/// `never_opened` `false`. The stream is then reset: `a stream reaped
/// before its HEADERS left must not be reset`. Red on `d5161919`,
/// verified 2026-09-28.
#[test]
fn a_backend_stream_reaped_before_its_headers_left_is_not_reset() {
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
gid: _,
} = backend_with_a_linked_stream(
H2State::Header,
BackendStatus::Connected,
Ready::READABLE | Ready::HUP | Ready::ERROR,
);
connection.core.readiness.interest.remove(Ready::WRITABLE);
context.now += Duration::from_secs(31);
connection.cancel_timed_out_streams(&mut context, &mut EndpointClient(&mut router));
assert!(
connection.core.stream_table.is_empty(),
"premise: the idle guard reaped stream 1"
);
assert!(
connection.core.control_tx.pending().is_empty()
&& !connection.core.readiness.interest.is_writable()
&& connection.core.output.is_empty(),
"a stream reaped before its HEADERS left must not be reset"
);
let received =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
assert_eq!(
received,
Vec::<u8>::new(),
"and nothing reaches the backend"
);
}
/// sozu-proxy/sozu#1631, frontend-error form (found by the adversarial
/// review of the first fix): the H1 frontend rejects a malformed request
/// body and answers 400 itself, which leaves the request kawa in `Error`
/// while its backend stream is still registered and its HEADERS never
/// left. The next write pass used to hand it to the converter, whose
/// `initialize` queues a RST_STREAM for that idle stream.
///
/// TO SEE THIS RED: in [`ConnectionH2::poll_write_target`]'s
/// `H2WritePhase::Prepare` arm, make `never_opened_error` `false`. The
/// backend then reads `RST_STREAM(1)`: `no frame may name stream 1`.
/// Red on `6eef0ba1`, verified 2026-09-28.
#[test]
fn a_backend_stream_whose_request_failed_before_its_headers_left_is_not_reset() {
let LinkedBackend {
_pool,
mut connection,
mut peer,
mut context,
mut router,
gid,
} = backend_with_a_linked_stream(
H2State::Header,
BackendStatus::Connected,
Ready::READABLE | Ready::HUP | Ready::ERROR,
);
queue_request(&mut context, gid);
// What `kawa::h1::parse` leaves on a malformed chunked body.
context.streams[gid]
.front
.parsing_phase
.error(kawa::ParsingErrorKind::Processing {
message: "malformed chunk",
});
assert!(
context.streams[gid].front.is_error() && !context.streams[gid].front.consumed,
"premise: the request failed before any byte left"
);
let received =
drive_and_read_backend(&mut connection, &mut peer, &mut context, &mut router);
let frames = peer_frames(&received).expect("whole frames");
assert!(
frames.iter().all(|(_, _, id, _)| *id != 1),
"no frame may name stream 1, which the backend never saw: {frames:?}"
);
assert!(
matches!(connection.core.state, H2State::Header),
"the shared connection stays up, got {:?}",
connection.core.state
);
}
/// sozu-proxy/sozu#1631, parked form: the stream's HEADERS were encoded
/// by a write pass, but the socket took none of it, so the block is
/// parked in `front.out` (the #1627 situation on `Position::Client`).
/// The backend has still not seen the stream: no RST for it, and since
/// #1630 the dropped block resets the encoder's table, so the next
/// stream's block still decodes on the backend.
///
/// TO SEE THIS RED: same edit as
/// `a_backend_stream_ended_before_its_headers_left_is_not_reset`. The
/// backend then reads a RST_STREAM for stream 1, which it never opened:
/// `no frame may name stream 1`. Red on `d5161919`, verified 2026-09-28.
#[test]
fn a_backend_stream_ended_with_its_headers_parked_is_not_reset() {
let pool = make_pool_for_invariant_16();
let (socket, _peer) = connected_socket();
let backend = Rc::new(RefCell::new(crate::backends::Backend::new(
"parked-backend",
"127.0.0.1:2".parse().expect("backend address must parse"),
None,
None,
None,
)));
let mut registry = crate::protocol::mux::BackendRegistry::default();
let mut connection = H2Shell::new(
Ulid::generate(),
PacedSocket {
stream: socket,
budget: 0,
wire: Vec::new(),
inbound: std::collections::VecDeque::new(),
},
Position::Client(
"parked-cluster".into(),
registry.id_for(&backend),
BackendStatus::Connected,
),
&mut PoolBufferSource::new(Rc::downgrade(&pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, 9)),
Ready::READABLE | Ready::WRITABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection");
connection.core.state = H2State::Header;
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let first = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
queue_request(&mut context, first);
assert!(
connection.start_stream(first, &mut context),
"premise: stream 1"
);
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(&mut context, EndpointClient(&mut router));
assert!(
connection.socket.wire.is_empty()
&& !context.streams[first].front.out.is_empty()
&& !context.streams[first].front.consumed,
"premise: stream 1's HEADERS were encoded and parked unsent"
);
connection.end_stream(first, &mut context);
let third = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
queue_request(&mut context, third);
assert!(
connection.start_stream(third, &mut context),
"premise: stream 3"
);
connection.socket.budget = usize::MAX;
drive_both_ways(&mut connection, &mut context, &mut router);
let frames = peer_frames(&connection.socket.wire).expect("whole frames");
assert!(
frames.iter().all(|(_, _, id, _)| *id != 1),
"no frame may name stream 1, which the backend never saw: {frames:?}"
);
let mut decoder = crate::protocol::mux::hpack::Decoder::new();
let decoded: Vec<(u32, bool)> = frames
.iter()
.filter(|(kind, ..)| *kind == 1)
.map(|(_, _, id, payload)| (*id, decoder.decode_with_cb(payload, |_, _| {}).is_ok()))
.collect();
assert_eq!(
decoded,
vec![(3, true)],
"stream 3's block must decode with the backend's own table"
);
}
/// #1597, with #1604's split: skipping an orphaned remainder turns
/// READABLE back on even while output is queued, and leaves that output
/// alone. The skip reads into `zero`, which is input only, so there is
/// nothing to wait for; the queued frame still leaves in order.
///
/// Replaces the pre-#1604 test that pinned the opposite (READABLE kept
/// off while zero held output), for the pre-image, where `zero` also held
/// our output and a read into it would have parsed that output.
///
/// TO SEE THIS RED: in [`ConnectionH2::skip_orphaned_data_payload`], only
/// insert READABLE when `self.output.is_empty()`. The test fails with
/// `the skip must read on while output is queued`.
#[test]
fn skipping_an_orphaned_payload_reads_on_while_output_is_queued() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let window_update = orphan_frame(8, 0, 1, 4, &16_384u32.to_be_bytes());
connection.core.output.push(&window_update);
connection.core.readiness.interest.remove(Ready::READABLE);
connection.core.skip_orphaned_data_payload(6, 10);
assert!(
connection.core.readiness.interest.is_readable(),
"the skip must read on while output is queued"
);
assert_eq!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Zero, 6)),
"the remainder must still be scheduled for skipping"
);
assert_eq!(
connection.core.output_pending(),
&window_update[..],
"the skip must leave the queued output untouched"
);
}
/// A backend (client-side) H2 connection starts write-only, sends its
/// preface and SETTINGS, and must then READ, for the server's SETTINGS.
/// The pre-image turned READABLE on as a side effect of flushing `zero`;
/// with the output queue separate from the input, the
/// `(ClientSettings, Client)` arm turns it on itself.
///
/// TO SEE THIS RED: in [`ConnectionH2::dispatch_writable_state`]'s
/// `(H2State::ClientSettings, Position::Client(..))` arm, delete
/// `self.readiness.interest.insert(Ready::READABLE);`. The test fails
/// with `a backend connection must read once its preface is out`.
#[test]
fn a_backend_connection_reads_once_its_preface_is_out() {
use std::io::Read;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (socket, mut peer) = connected_socket();
let backend = Rc::new(RefCell::new(crate::backends::Backend::new(
"preface-backend",
"127.0.0.1:2".parse().expect("backend address must parse"),
None,
None,
None,
)));
let mut registry = crate::protocol::mux::BackendRegistry::default();
let mut connection = H2Shell::new(
Ulid::generate(),
socket,
Position::Client(
"preface-cluster".into(),
registry.id_for(&backend),
BackendStatus::Connected,
),
&mut PoolBufferSource::new(Rc::downgrade(&pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
None,
Ready::WRITABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection");
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
for _ in 0..2 {
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(&mut context, EndpointClient(&mut router));
}
let mut preface = [0u8; 24];
peer.read_exact(&mut preface)
.expect("the backend must receive the client preface");
assert_eq!(&preface[..], serializer::H2_PRI.as_bytes());
assert!(
matches!(connection.core.state, H2State::ServerSettings),
"premise: the preface went out, got {:?}",
connection.core.state
);
assert!(
connection.core.readiness.interest.is_readable(),
"a backend connection must read once its preface is out"
);
}
/// A peer that sends while never reading cannot grow the output queue
/// without bound: once one buffer's worth is queued, reading stops, as
/// HAProxy's demux stops on a full `mbuf` (`H2_CF_DEM_MROOM`), and it
/// resumes once the queue drains below that.
///
/// TO SEE THIS RED: in [`ConnectionH2::poll_read_target`], delete the
/// `self.output.len() >= self.output_read_cap()` early return. The test
/// fails with `a full output queue must stop reading`.
#[test]
fn a_full_output_queue_stops_reading_until_it_drains() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
let cap = connection.core.output_read_cap();
connection.core.output.push(&vec![0; cap]);
connection.core.readiness.interest.insert(Ready::READABLE);
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
assert!(
!connection.core.readiness.interest.is_readable(),
"a full output queue must stop reading"
);
assert!(
connection.core.readiness.event.is_readable(),
"the READABLE event must be kept for the read that follows the drain"
);
connection.core.consume_output(1);
assert!(
connection.core.readiness.interest.is_readable(),
"draining below the cap must restore reading"
);
}
/// Clearing the output queue, as a peer that hung up while the
/// connection drains does, also ends the read cap's hold on READABLE:
/// otherwise `reads_wait_for_output` would outlive the output it waited on
/// and the shutdown drive's reads would stay withdrawn.
///
/// TO SEE THIS RED: in `ConnectionH2::clear_output`, drop everything but
/// `self.output.clear();`. The test then fails with `a cleared queue must
/// release the read cap`. Verified 2026-09-27.
#[test]
fn clearing_the_output_releases_the_read_cap() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
let cap = connection.core.output_read_cap();
connection.core.output.push(&vec![0; cap]);
connection.core.readiness.interest.insert(Ready::READABLE);
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
assert!(
connection.core.reads_wait_for_output
&& !connection.core.readiness.interest.is_readable(),
"premise: the read cap withdrew READABLE"
);
connection.core.clear_output();
assert!(
!connection.core.reads_wait_for_output
&& connection.core.readiness.interest.is_readable(),
"a cleared queue must release the read cap"
);
}
/// #1597, with #1604's split: a frame header read while output is still
/// queued lands in `zero` and leaves that output intact, and the answer
/// to the frame read queues BEHIND it. A stalled WINDOW_UPDATE flush
/// leaves exactly that state; the CI e2e suite once caught the
/// pre-image, where both shared `zero`, through `handle_header_state`'s
/// `debug_assert!`, with `zero` holding a stream-1 WINDOW_UPDATE followed
/// by the peer's next DATA header.
///
/// Replaces the pre-#1604 test that pinned the pre-image's cure (no frame
/// header read at all while zero held unflushed output until the flush):
/// the hazard it guarded no longer exists, and what the peer must receive
/// is unchanged and still asserted below.
///
/// TO SEE THIS RED: in [`ConnectionH2::handle_ping_frame`], queue the ACK
/// in front of the output instead of behind it (`self.output.clear()`
/// before `push_frames`). The test fails with `the PING ACK must queue
/// behind the WINDOW_UPDATE`.
#[test]
fn a_frame_header_read_leaves_queued_output_intact() {
use std::io::{Read, Write};
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
peer.set_nonblocking(true)
.expect("the loopback peer must switch to non-blocking");
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
// What a stalled WINDOW_UPDATE flush leaves behind.
let window_update = orphan_frame(8, 0, 1, 4, &16_384u32.to_be_bytes());
connection.core.output.push(&window_update);
connection.core.readiness.interest.insert(Ready::READABLE);
peer.write_all(&orphan_ping())
.expect("loopback write must complete");
let mut expected = window_update.clone();
expected.extend(orphan_frame(6, parser::FLAG_ACK, 0, 8, b"pingpong"));
for _ in 0..64 {
connection.core.readiness.event.insert(Ready::READABLE);
connection.readable(&mut context, EndpointClient(&mut router));
if connection.core.output.len() >= expected.len() {
break;
}
std::thread::yield_now();
}
assert_eq!(
connection.core.output_pending(),
&expected[..],
"the PING ACK must queue behind the WINDOW_UPDATE"
);
// Driven the way `Mux` does, the queue leaves in order.
let mut received = Vec::new();
for _ in 0..256 {
connection
.core
.readiness
.event
.insert(Ready::READABLE | Ready::WRITABLE);
connection.core.readiness.interest.insert(Ready::WRITABLE);
if connection.core.readiness.filter_interest().is_writable() {
connection.writable(&mut context, EndpointClient(&mut router));
}
if connection.core.readiness.filter_interest().is_readable() {
connection.readable(&mut context, EndpointClient(&mut router));
}
let mut buf = [0u8; 256];
if let Ok(n) = peer.read(&mut buf) {
received.extend_from_slice(&buf[..n]);
}
if received.len() >= expected.len() {
break;
}
std::thread::yield_now();
}
assert_eq!(
received, expected,
"the peer must receive the WINDOW_UPDATE, then the PING ACK"
);
}
/// The RED-confirmed test above sends a HEADERS payload that is nothing
/// but a field block: no `Pad Length`, no `Stream Dependency`/`Weight`.
/// That shape cannot catch correction #1 — RFC 9113 §6.2 lays a HEADERS
/// payload out as `[Pad Length?][Priority?][field block][padding?]`, and
/// `refuse_stream_and_discard`'s new-stream callers pass the *whole*
/// payload. A decode that forgets to strip the PADDED/PRIORITY prefix
/// feeds the HPACK decoder six bytes of pad-length/dependency/weight
/// garbage before the real field block starts.
///
/// To SEE THIS RED: in [`decode_discarded_field_block`]'s
/// `DiscardedFieldBlock::New` arm, replace the `let fragment = headers
/// .header_block_fragment.data_opt(payload)...` lines with `let fragment
/// = payload;` — i.e. hand the connection decoder the raw payload
/// unstripped, the way a fix that only handled the un-padded case would.
/// The peer's first block is still legitimate HPACK once you skip past
/// the 6-byte prefix, but decoding from byte 0 instead makes the pad
/// length byte and the first three dependency/weight octets look like
/// HPACK opcodes. Verified 2026-09-21: the decode fails with
/// `H2Error::CompressionError`, observed via the connection reaching
/// `H2State::GoAway` (the panic is on the "connection usable" assertion,
/// not the later decode assertion — the malformed prefix corrupts the
/// connection before the probe even gets to send its second block).
#[test]
fn a_refused_padded_prioritized_stream_keeps_the_hpack_decoder_in_sync() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
connection.core.drain.__test_set_draining();
// Same probe technique as the sibling test: this appends
// `x-sozu-probe: alpha` to the peer's dynamic table at index 62.
let mut peer_encoder = crate::protocol::mux::hpack::Encoder::new();
let field_block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/refused"[..]),
(&b"x-sozu-probe"[..], &b"alpha"[..]),
]);
// RFC 9113 §6.2 HEADERS payload: Pad Length (1) + Stream Dependency
// (4, exclusive bit clear, depends on stream 0) + Weight (1) + the
// field block + 3 zero padding bytes.
const PAD_LEN: u8 = 3;
let mut payload = Vec::with_capacity(1 + 5 + field_block.len() + PAD_LEN as usize);
payload.push(PAD_LEN);
payload.extend_from_slice(&0u32.to_be_bytes()); // Stream Dependency
payload.push(15); // Weight
payload.extend_from_slice(&field_block);
payload.extend(std::iter::repeat_n(0u8, PAD_LEN as usize));
// HEADERS, END_STREAM | END_HEADERS | PADDED | PRIORITY, stream 1.
let mut frame = Vec::with_capacity(9 + payload.len());
frame.extend_from_slice(&(payload.len() as u32).to_be_bytes()[1..]);
frame.push(1);
frame.push(
parser::FLAG_END_STREAM
| parser::FLAG_END_HEADERS
| parser::FLAG_PADDED
| parser::FLAG_PRIORITY,
);
frame.extend_from_slice(&1u32.to_be_bytes());
frame.extend_from_slice(&payload);
peer.write_all(&frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::Header)
&& matches!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Zero, 9))
)
&& !connection.core.control_tx.pending().is_empty()
{
break;
}
std::thread::yield_now();
}
assert_eq!(
connection.core.control_tx.pending(),
vec![(1, H2Error::RefusedStream)],
"the drain gate must refuse stream 1 with RST_STREAM(REFUSED_STREAM)"
);
assert!(
connection.core.stream_table.is_empty(),
"a refused stream must not be created"
);
assert!(
!matches!(connection.core.state, H2State::Error | H2State::GoAway),
"refusing a PADDED|PRIORITY HEADERS frame must leave the connection \
usable: {:?}",
connection.core.state
);
let next_block = peer_encoder.encode([(&b"x-sozu-probe"[..], &b"alpha"[..])]);
assert_eq!(
next_block.len(),
1,
"the peer must now reference its dynamic entry, not re-send a literal"
);
let mut decoded = Vec::new();
let status = connection
.core
.hpack
.decoder_mut()
.decode_with_cb(&next_block, |k, v| {
decoded.push((k.into_owned(), v.into_owned()));
});
assert!(
status.is_ok(),
"the connection decoder must still resolve the peer's dynamic table \
after a refused PADDED|PRIORITY stream, got {status:?}"
);
assert_eq!(
decoded,
vec![(b"x-sozu-probe".to_vec(), b"alpha".to_vec())],
"the refused stream's field block — located past the Pad Length and \
Priority prefix — must have been decoded into the connection's \
HPACK context"
);
}
// ── `self.zero` is read-accumulation AND write scratch — that is the bug ──
//
// `ConnectionH2::zero` serves two roles: the read-side accumulation
// buffer for a HEADERS/CONTINUATION field block in progress, and the
// write scratch space `flush_pending_control_frames` reuses to
// serialise ANY queued WINDOW_UPDATE or RST_STREAM — for any stream,
// not just the one being reassembled. `mod.rs`'s inner event loop
// dispatches frontend `readable()` then `writable()` in the same sweep,
// so a legitimate, non-refused multi-frame header block can be
// clobbered by completely unrelated connection-level flow-control
// housekeeping before its CONTINUATION frame ever arrives — no
// adversarial peer required.
//
// That was the pre-image of #1396/#1397/#1401 and, for the write
// scratch, of #1604: the accumulator moved to `header_reassembly`, and
// every control frame is now queued in the separate `output`. The tests
// below keep pinning the outcome, a block that survives every
// interleaved flush; their RED notes describe the tree they were written
// against.
/// To SEE THIS RED: this is the pre-existing behaviour on `main`, no
/// mutation needed. `flush_pending_control_frames`'s WINDOW_UPDATE-drain
/// stage (`let kawa = &mut self.zero; kawa.storage.clear();`) runs
/// unconditionally whenever a WINDOW_UPDATE is queued, with no check on
/// `self.state`. Guarding that stage (and the RST_STREAM-drain stage
/// beneath it) on `matches!(self.state, H2State::ContinuationHeader(_) |
/// H2State::ContinuationFrame(_))` is exactly the fix this test proves.
#[test]
fn a_legitimate_continuation_survives_an_unrelated_window_update_flush() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
// Past the preface/SETTINGS handshake, waiting on a frame header.
// `drain.draining` stays false: stream 1 must be genuinely accepted,
// not refused — this is the ordinary, non-refusal path.
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
// The peer's encoder. The marker header's name is in neither table,
// so encoding it appends `x-sozu-marker: legitimate-value` to the
// peer's dynamic table at a known index — the same technique
// `a_refused_stream_keeps_the_hpack_decoder_in_sync` uses to prove
// decoder sync, here applied to a stream that is never refused.
let mut peer_encoder = crate::protocol::mux::hpack::Encoder::new();
let field_block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/legit-multiframe"[..]),
(&b"x-sozu-marker"[..], &b"legitimate-value"[..]),
]);
// Split well past the 13 bytes a single WINDOW_UPDATE frame occupies,
// so the clobber this test provokes lands inside real field-block
// bytes rather than in a margin this test invented.
assert!(
field_block.len() > 26,
"the probe block must be large enough to split meaningfully"
);
let split = field_block.len() / 2;
let (first_half, second_half) = field_block.split_at(split);
// HEADERS, END_STREAM but NOT END_HEADERS, stream 1: a legitimate
// multi-frame header block, exactly as a large request produces.
let mut headers_frame = Vec::with_capacity(9 + first_half.len());
headers_frame.extend_from_slice(&(first_half.len() as u32).to_be_bytes()[1..]);
headers_frame.push(1); // HEADERS
headers_frame.push(parser::FLAG_END_STREAM);
headers_frame.extend_from_slice(&1u32.to_be_bytes());
headers_frame.extend_from_slice(first_half);
peer.write_all(&headers_frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
// Drive reads until the connection is genuinely waiting on the
// CONTINUATION frame for stream 1 — accepted, not refused.
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::ContinuationHeader(_)) {
break;
}
std::thread::yield_now();
}
assert!(
matches!(connection.core.state, H2State::ContinuationHeader(_)),
"stream 1's HEADERS frame must be accepted and await CONTINUATION: {:?}",
connection.core.state
);
assert!(
!connection.core.stream_table.is_empty(),
"a non-refused HEADERS frame must create stream 1"
);
// Completely unrelated connection-level housekeeping: replenishing
// the connection flow-control window after DATA consumed on some
// other stream. Nothing about it targets stream 1 or its
// in-progress header block — this is the ordinary path, not an
// attack.
connection.core.queue_window_update(0, 65_535);
connection.writable(&mut context, EndpointClient(&mut router));
// Now the CONTINUATION frame completing the block arrives.
let mut continuation_frame = Vec::with_capacity(9 + second_half.len());
continuation_frame.extend_from_slice(&(second_half.len() as u32).to_be_bytes()[1..]);
continuation_frame.push(9); // CONTINUATION
continuation_frame.push(parser::FLAG_END_HEADERS);
continuation_frame.extend_from_slice(&1u32.to_be_bytes());
continuation_frame.extend_from_slice(second_half);
peer.write_all(&continuation_frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if !matches!(
connection.core.state,
H2State::ContinuationHeader(_) | H2State::ContinuationFrame(_)
) {
break;
}
std::thread::yield_now();
}
assert!(
!matches!(connection.core.state, H2State::Error | H2State::GoAway),
"an unrelated WINDOW_UPDATE flush must not corrupt a legitimate \
in-progress header block into a connection error: {:?}",
connection.core.state
);
// The peer's next block references the dynamic entry its encoder
// added while encoding stream 1's request.
let next_block = peer_encoder.encode([(&b"x-sozu-marker"[..], &b"legitimate-value"[..])]);
assert_eq!(
next_block.len(),
1,
"the peer must now reference its dynamic entry, not re-send a literal"
);
let mut decoded = Vec::new();
let status = connection
.core
.hpack
.decoder_mut()
.decode_with_cb(&next_block, |k, v| {
decoded.push((k.into_owned(), v.into_owned()));
});
assert!(
status.is_ok(),
"the connection decoder must still resolve the peer's dynamic table \
after an unrelated WINDOW_UPDATE flush during header-block \
reassembly, got {status:?}"
);
assert_eq!(
decoded,
vec![(b"x-sozu-marker".to_vec(), b"legitimate-value".to_vec())],
"stream 1's field block must have been decoded byte-for-byte, not \
clobbered by the unrelated WINDOW_UPDATE flush"
);
}
/// Same shape as
/// `a_legitimate_continuation_survives_an_unrelated_window_update_flush`,
/// but the clobbering trigger is `graceful_goaway` itself instead of an
/// unrelated WINDOW_UPDATE flush — the third instance of the same bug
/// class (issue #1398).
///
/// Since #1637 the advisory GOAWAY is no longer deferred while the block
/// reassembles: it is queued in `self.output` and flushed in the same
/// pass, and this test checks both halves on the wire — the peer reads
/// the 17-byte `GOAWAY(NO_ERROR, STREAM_ID_MAX)` BEFORE it sends the
/// CONTINUATION, and the block that CONTINUATION completes still decodes
/// byte-for-byte afterwards.
///
/// To SEE THIS RED, either reintroduce the #1401 deferral (hold the
/// GOAWAY back while `H2State::ContinuationHeader` holds) — the
/// `read_exact` of the advisory GOAWAY before the CONTINUATION finds nothing
/// — or serialize the GOAWAY into `self.zero.storage` as the pre-#1604
/// code did, which corrupts the block and fails the decode assertions.
#[test]
fn a_legitimate_continuation_survives_a_graceful_goaway() {
use std::io::{Read, Write};
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
// Past the preface/SETTINGS handshake, waiting on a frame header.
// `drain.draining` stays false until `graceful_goaway` is called
// below: stream 1 must be genuinely accepted, not refused — this is
// the ordinary, non-refusal path.
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
// The peer's encoder. The marker header's name is in neither table,
// so encoding it appends `x-sozu-marker: legitimate-value` to the
// peer's dynamic table at a known index — the same technique
// `a_legitimate_continuation_survives_an_unrelated_window_update_flush`
// uses to prove decoder sync.
let mut peer_encoder = crate::protocol::mux::hpack::Encoder::new();
let field_block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/legit-multiframe"[..]),
(&b"x-sozu-marker"[..], &b"legitimate-value"[..]),
]);
// Same header set and split proportions as
// `a_legitimate_continuation_survives_an_unrelated_window_update_flush`:
// `header_block_fragment`'s window is a `(start, len)` pair recorded
// once, independent of `self.zero.storage`'s own bookkeeping, so
// *any* clear-then-refill of that buffer — 13 bytes of WINDOW_UPDATE
// or 17 bytes of GOAWAY, it makes no difference — leaves the window
// pointing at the same wrong offset once `end`/`head` reset to 0 and
// the CONTINUATION's own bytes land there instead. Matching the split
// reproduces the same decodable-but-wrong HPACK byte sequence rather
// than a split that happens to land on an invalid opcode.
assert!(
field_block.len() > 34,
"the probe block must be large enough to split meaningfully"
);
let split = field_block.len() / 2;
let (first_half, second_half) = field_block.split_at(split);
// HEADERS, END_STREAM but NOT END_HEADERS, stream 1: a legitimate
// multi-frame header block, exactly as a large request produces.
let mut headers_frame = Vec::with_capacity(9 + first_half.len());
headers_frame.extend_from_slice(&(first_half.len() as u32).to_be_bytes()[1..]);
headers_frame.push(1); // HEADERS
headers_frame.push(parser::FLAG_END_STREAM);
headers_frame.extend_from_slice(&1u32.to_be_bytes());
headers_frame.extend_from_slice(first_half);
peer.write_all(&headers_frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
// Drive reads until the connection is genuinely waiting on the
// CONTINUATION frame for stream 1 — accepted, not refused.
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::ContinuationHeader(_)) {
break;
}
std::thread::yield_now();
}
assert!(
matches!(connection.core.state, H2State::ContinuationHeader(_)),
"stream 1's HEADERS frame must be accepted and await CONTINUATION: {:?}",
connection.core.state
);
assert!(
!connection.core.stream_table.is_empty(),
"a non-refused HEADERS frame must create stream 1"
);
// The proxy decides to drain — a graceful shutdown or hot reload
// landing mid-reassembly, exactly as issue #1398 describes. Nothing
// about it targets stream 1 or its in-progress header block.
let drain_at = connection.core.now;
let result = connection.graceful_goaway(drain_at);
assert!(
matches!(result, MuxResult::Continue),
"graceful_goaway must not tear the connection down mid-reassembly: {result:?}"
);
// The forced-close budget must arm from the moment the proxy decided
// to drain — `Mux::shutting_down` samples exactly this field against
// `graceful_shutdown_deadline`.
assert!(
connection.core.drain.draining(),
"graceful_goaway must mark the connection as draining immediately"
);
assert_eq!(
connection.core.drain.__test_started_at(),
Some(drain_at),
"the forced-close budget must arm from `graceful_goaway`'s `now` parameter"
);
assert!(
matches!(connection.core.state, H2State::ContinuationHeader(_)),
"queueing the advisory GOAWAY must leave the reassembly state alone: {:?}",
connection.core.state
);
// Mirror `Mux::shutting_down_inner` exactly: it calls
// `flush_output_buffer()` immediately after `graceful_goaway()`
// returns `Continue`, because edge-triggered epoll won't deliver a
// fresh WRITABLE event for an already-writable socket. The advisory
// GOAWAY leaves in this flush, while the block is still incomplete.
connection.flush_output_buffer();
assert!(
connection.core.output.is_empty(),
"the advisory GOAWAY must have been fully flushed to the socket"
);
let mut expected = [0u8; 17];
let (_, expected_size) =
serializer::gen_goaway(&mut expected, STREAM_ID_MAX, H2Error::NoError)
.expect("serializing the expected GOAWAY must succeed");
assert_eq!(expected_size, 17);
let mut received = [0u8; 17];
peer.set_read_timeout(Some(Duration::from_secs(5)))
.expect("setting a read timeout must succeed");
peer.read_exact(&mut received)
.expect("the peer must receive the advisory GOAWAY before it completes the block");
assert_eq!(
received, expected,
"the advisory GOAWAY must reach the peer byte-for-byte while its \
header block is still incomplete"
);
// Now the CONTINUATION frame completing the block arrives.
let mut continuation_frame = Vec::with_capacity(9 + second_half.len());
continuation_frame.extend_from_slice(&(second_half.len() as u32).to_be_bytes()[1..]);
continuation_frame.push(9); // CONTINUATION
continuation_frame.push(parser::FLAG_END_HEADERS);
continuation_frame.extend_from_slice(&1u32.to_be_bytes());
continuation_frame.extend_from_slice(second_half);
peer.write_all(&continuation_frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if !matches!(
connection.core.state,
H2State::ContinuationHeader(_) | H2State::ContinuationFrame(_)
) {
break;
}
std::thread::yield_now();
}
assert!(
!matches!(connection.core.state, H2State::Error | H2State::GoAway),
"a graceful_goaway landing mid-reassembly must not corrupt a \
legitimate in-progress header block into a connection error: {:?}",
connection.core.state
);
// The peer's next block references the dynamic entry its encoder
// added while encoding stream 1's request.
let next_block = peer_encoder.encode([(&b"x-sozu-marker"[..], &b"legitimate-value"[..])]);
assert_eq!(
next_block.len(),
1,
"the peer must now reference its dynamic entry, not re-send a literal"
);
let mut decoded = Vec::new();
let status = connection
.core
.hpack
.decoder_mut()
.decode_with_cb(&next_block, |k, v| {
decoded.push((k.into_owned(), v.into_owned()));
});
assert!(
status.is_ok(),
"the connection decoder must still resolve the peer's dynamic table \
after a graceful_goaway during header-block reassembly, got {status:?}"
);
assert_eq!(
decoded,
vec![(b"x-sozu-marker".to_vec(), b"legitimate-value".to_vec())],
"stream 1's field block must have been decoded byte-for-byte, not \
clobbered by graceful_goaway"
);
// Nothing else was owed to the peer: the drain is not a second
// GOAWAY, and the completed block leaves nothing queued.
assert!(
connection.core.output.is_empty(),
"completing the block must not queue a second advisory GOAWAY"
);
}
/// Regression test for #1423: `flush_pending_control_frames`'s
/// `frontend_hung_up_while_draining()` stage cleared `zero.storage`
/// unconditionally, with no `header_block_reassembly_in_progress()`
/// guard — unlike its three sibling stages, which #1397 and #1401
/// already gated. LIFECYCLE.md invariant 24 tracked this as a "known
/// live gap, reported and NOT fixed" until this changeset.
///
/// Same shape as `a_legitimate_continuation_survives_a_graceful_goaway`,
/// but the clobbering trigger is a HUP/ERROR readiness event landing
/// while draining — exactly `frontend_hung_up_while_draining()`'s own
/// condition — instead of `graceful_goaway` itself. This test drives
/// the gap BETWEEN two frames (a HUP arriving while
/// `expect_read = Some((Zero, 9))`, waiting for the next CONTINUATION's
/// header, with nothing of this block's history left in `zero.storage`
/// to lose). Moving the accumulator out of `zero.storage` into
/// `self.header_reassembly` (`h2_header_reassembly.rs`) makes THIS
/// scenario safe regardless of whether this stage is guarded: there is
/// no accumulated history left in `zero.storage` for it to clobber.
///
/// That is NOT the whole of #1423, though — see
/// `a_continuation_frame_split_by_tcp_segmentation_survives_a_hup_while_draining`
/// below for the narrower "HUP lands mid-frame, not between frames"
/// case this test does not cover, which review of this changeset's
/// first version (`e1c3c2fb`) proved the accumulator move alone does
/// NOT make safe, and which is why `frontend_hung_up_while_draining()`'s
/// stage carries the same `header_block_reassembly_in_progress()` guard
/// as its three siblings after all.
///
/// To SEE THIS RED on this step's base commit (`f76c8eb3`, before the
/// accumulator moved out of `zero.storage`): run this test unmodified
/// against that commit. `flush_pending_control_frames`'s first stage
/// clears `zero.storage` — which, on that commit, still holds stream 1's
/// entire accumulated first-half fragment — before the CONTINUATION
/// frame completing the block arrives; the CONTINUATION's own bytes
/// then land at the wrong (reset-to-zero) offset, and the connection's
/// HPACK decoder desyncs from the peer's encoder. The failure is
/// silent corruption, not a crash (HPACK carries no self-check): the
/// `decode_with_cb` assertion below fails with a decode error, or
/// succeeds but yields something other than
/// `[(b"x-sozu-marker", b"legitimate-value")]`.
#[test]
fn a_legitimate_continuation_survives_a_hup_while_draining() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
// Past the preface/SETTINGS handshake, waiting on a frame header.
// `drain.draining` stays false until manually forced below: stream 1
// must be genuinely accepted, not refused — this is the ordinary,
// non-refusal path.
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
// The peer's encoder. The marker header's name is in neither table,
// so encoding it appends `x-sozu-marker: legitimate-value` to the
// peer's dynamic table at a known index — the same technique the
// sibling WINDOW_UPDATE/graceful_goaway tests use to prove decoder
// sync.
let mut peer_encoder = crate::protocol::mux::hpack::Encoder::new();
let field_block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/legit-multiframe"[..]),
(&b"x-sozu-marker"[..], &b"legitimate-value"[..]),
]);
assert!(
field_block.len() > 34,
"the probe block must be large enough to split meaningfully"
);
let split = field_block.len() / 2;
let (first_half, second_half) = field_block.split_at(split);
// HEADERS, END_STREAM but NOT END_HEADERS, stream 1: a legitimate
// multi-frame header block, exactly as a large request produces.
let mut headers_frame = Vec::with_capacity(9 + first_half.len());
headers_frame.extend_from_slice(&(first_half.len() as u32).to_be_bytes()[1..]);
headers_frame.push(1); // HEADERS
headers_frame.push(parser::FLAG_END_STREAM);
headers_frame.extend_from_slice(&1u32.to_be_bytes());
headers_frame.extend_from_slice(first_half);
peer.write_all(&headers_frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
// Drive reads until the connection is genuinely waiting on the
// CONTINUATION frame for stream 1 — accepted, not refused.
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::ContinuationHeader(_)) {
break;
}
std::thread::yield_now();
}
assert!(
matches!(connection.core.state, H2State::ContinuationHeader(_)),
"stream 1's HEADERS frame must be accepted and await CONTINUATION: {:?}",
connection.core.state
);
assert!(
!connection.core.stream_table.is_empty(),
"a non-refused HEADERS frame must create stream 1"
);
// Exactly #1423's trigger: the connection is draining AND the
// frontend readiness reports HUP, landing squarely mid-reassembly.
// A peer can legitimately produce this by sending TCP FIN together
// with (or shortly after) the last CONTINUATION frame it manages to
// get out, observed by edge-triggered epoll as one combined event.
connection.core.drain.__test_set_draining();
connection.core.readiness.event.insert(Ready::HUP);
// `flush_pending_control_frames`'s first, HUP-gated stage fires
// here.
connection.writable(&mut context, EndpointClient(&mut router));
// Real epoll delivers HUP once; un-signal it so the reads below
// reflect the peer's normal traffic rather than a second synthetic
// HUP this test injected by hand.
connection.core.readiness.event.remove(Ready::HUP);
// Now the CONTINUATION frame completing the block arrives — still
// fully deliverable, exactly as it would be if the peer's FIN and
// its last frame arrived close together but the block itself was
// sent in full.
let mut continuation_frame = Vec::with_capacity(9 + second_half.len());
continuation_frame.extend_from_slice(&(second_half.len() as u32).to_be_bytes()[1..]);
continuation_frame.push(9); // CONTINUATION
continuation_frame.push(parser::FLAG_END_HEADERS);
continuation_frame.extend_from_slice(&1u32.to_be_bytes());
continuation_frame.extend_from_slice(second_half);
peer.write_all(&continuation_frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if !matches!(
connection.core.state,
H2State::ContinuationHeader(_) | H2State::ContinuationFrame(_)
) {
break;
}
std::thread::yield_now();
}
assert!(
!matches!(connection.core.state, H2State::Error | H2State::GoAway),
"a HUP-while-draining event landing mid-reassembly must not corrupt \
a legitimate in-progress header block into a connection error: {:?}",
connection.core.state
);
// The peer's next block references the dynamic entry its encoder
// added while encoding stream 1's request.
let next_block = peer_encoder.encode([(&b"x-sozu-marker"[..], &b"legitimate-value"[..])]);
assert_eq!(
next_block.len(),
1,
"the peer must now reference its dynamic entry, not re-send a literal"
);
let mut decoded = Vec::new();
let status = connection
.core
.hpack
.decoder_mut()
.decode_with_cb(&next_block, |k, v| {
decoded.push((k.into_owned(), v.into_owned()));
});
assert!(
status.is_ok(),
"the connection decoder must still resolve the peer's dynamic table \
after a HUP-while-draining event during header-block reassembly, \
got {status:?}"
);
assert_eq!(
decoded,
vec![(b"x-sozu-marker".to_vec(), b"legitimate-value".to_vec())],
"stream 1's field block must have been decoded byte-for-byte, not \
clobbered by the HUP-while-draining stage of \
flush_pending_control_frames"
);
}
/// Regression test for B1 (review of `e1c3c2fb`): `handle_headers_frame`'s
/// RFC 7540 §5.3.1 self-dependency early return — `reset_stream` +
/// `remove_dead_stream` when a stream's own PRIORITY depends on itself —
/// used to skip retiring `self.header_reassembly` before returning. When
/// the aborted stream's block had gone through CONTINUATION reassembly
/// (`header_reassembly.is_in_progress() == true` at abort time), the
/// flag leaked into the very NEXT HEADERS frame processed on this
/// connection: that frame's own "read from `zero.storage`" fast path
/// was silently skipped in favour of the aborted stream's stale,
/// already-discarded bytes — corrupting an entirely unrelated stream's
/// decoded request. This reproduces in BOTH debug and release builds:
/// nothing on this path was ever a `debug_assert!`.
///
/// To SEE THIS RED on `e1c3c2fb` (before this fix): stream 1's block
/// completes reassembly, its RFC 7540 PRIORITY self-dependency
/// (`stream_dependency.stream_id == 1`) aborts it via `reset_stream`
/// without retiring the accumulator, and
/// `header_reassembly.is_in_progress()` stays `true`. Stream 3's own,
/// unrelated, single-frame request is then decoded from stream 1's
/// stale fragment instead of its own bytes: the `:path` pseudo-header
/// this test asserts on is corrupted rather than
/// `"/stream-3-legitimate"`.
#[test]
fn a_priority_self_dependency_reset_does_not_leak_the_reassembly_accumulator() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
// Stream 1: a legitimate multi-frame header block — split across
// HEADERS + CONTINUATION, exactly like the sibling reassembly tests
// — but the FIRST frame also carries an RFC 7540 PRIORITY field
// whose stream dependency is stream 1 itself (RFC 7540 §5.3.1: a
// stream cannot depend on itself).
let mut peer_encoder = crate::protocol::mux::hpack::Encoder::new();
let field_block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/stream-1-aborted"[..]),
]);
assert!(
field_block.len() > 10,
"the probe block must be large enough to split meaningfully"
);
let split = field_block.len() / 2;
let (first_half, second_half) = field_block.split_at(split);
// HEADERS, PRIORITY (self-dependency), NOT END_HEADERS.
let mut priority_prefix = Vec::with_capacity(5);
priority_prefix.extend_from_slice(&1u32.to_be_bytes()); // stream_dependency = 1 (self)
priority_prefix.push(15); // weight
let mut headers_frame = Vec::with_capacity(9 + priority_prefix.len() + first_half.len());
headers_frame.extend_from_slice(
&((priority_prefix.len() + first_half.len()) as u32).to_be_bytes()[1..],
);
headers_frame.push(1); // HEADERS
headers_frame.push(parser::FLAG_PRIORITY);
headers_frame.extend_from_slice(&1u32.to_be_bytes());
headers_frame.extend_from_slice(&priority_prefix);
headers_frame.extend_from_slice(first_half);
peer.write_all(&headers_frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::ContinuationHeader(_)) {
break;
}
std::thread::yield_now();
}
assert!(
matches!(connection.core.state, H2State::ContinuationHeader(_)),
"stream 1's HEADERS frame must be accepted and await CONTINUATION: {:?}",
connection.core.state
);
// Complete the block — this re-enters `handle_headers_frame`, which
// detects the self-dependency and aborts stream 1.
let mut continuation_frame = Vec::with_capacity(9 + second_half.len());
continuation_frame.extend_from_slice(&(second_half.len() as u32).to_be_bytes()[1..]);
continuation_frame.push(9); // CONTINUATION
continuation_frame.push(parser::FLAG_END_HEADERS);
continuation_frame.extend_from_slice(&1u32.to_be_bytes());
continuation_frame.extend_from_slice(second_half);
peer.write_all(&continuation_frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if connection.core.stream_table.get(1).is_none() {
break;
}
std::thread::yield_now();
}
// Premise: stream 1 really was torn down for self-dependency.
assert!(
connection.core.stream_table.get(1).is_none(),
"the self-dependent stream must have been torn down"
);
// The direct proof of the fix: the accumulator must be idle again
// before any other stream's HEADERS frame is processed. `h2.rs`'s
// own test module can reach the private field directly — no
// test-only accessor needed.
assert!(
!connection.core.header_reassembly.is_in_progress(),
"aborting a self-dependent stream mid-reassembly must retire the \
accumulator, not leave it `is_in_progress() == true` for the \
next HEADERS frame on this connection"
);
// Stream 3: an entirely unrelated, single-frame, complete request.
let stream3_block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/stream-3-legitimate"[..]),
]);
let mut stream3_frame = Vec::with_capacity(9 + stream3_block.len());
stream3_frame.extend_from_slice(&(stream3_block.len() as u32).to_be_bytes()[1..]);
stream3_frame.push(1); // HEADERS
stream3_frame.push(parser::FLAG_END_STREAM | parser::FLAG_END_HEADERS);
stream3_frame.extend_from_slice(&3u32.to_be_bytes());
stream3_frame.extend_from_slice(&stream3_block);
peer.write_all(&stream3_frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
// Wait until the connection is back at `Header`, waiting on the
// NEXT frame's 9-byte header — not merely until the stream slot
// exists, which `create_stream` populates as soon as the frame
// HEADER is parsed, well before its payload is read and decoded.
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::Header)
&& matches!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Zero, 9))
)
{
break;
}
std::thread::yield_now();
}
let stream3_gid = connection
.core
.stream_table
.get(3)
.expect("stream 3's HEADERS frame must create a stream");
assert_eq!(
context.streams[stream3_gid].context.path.as_deref(),
Some("/stream-3-legitimate"),
"stream 3 must decode its OWN bytes, not stream 1's stale, \
aborted reassembly fragment"
);
}
/// Regression test for B2 (review of `e1c3c2fb`): `flush_pending_control_frames`'s
/// `frontend_hung_up_while_draining()` stage used to clear `zero.storage`
/// unconditionally, on the premise that `Ready::HUP` means "no further
/// bytes can ever arrive". That premise is false: `Ready::HUP` is
/// `is_read_closed() || is_write_closed()` (`command/src/ready.rs`), and
/// mio documents `is_read_closed()` as also true on a TCP half-close —
/// a FIN with data the peer already sent still sitting, unread, in the
/// kernel receive queue. A CONTINUATION frame's payload split across two
/// TCP segments can have its FIRST segment already read into
/// `zero.storage` (a genuine partial `socket_read()`, not stale bytes
/// from a completed pass) when the readiness event batches HUP together
/// with that first segment; `drive_frontend_shutdown_io` (`mod.rs`)
/// force-calls `readable()` for H2 on every `shutting_down()` poll, so
/// this is the ordinary soft-stop path, not a contrived corner case.
///
/// To SEE THIS RED on `e1c3c2fb` (before this fix): the partial first
/// segment is wiped by the unconditional clear; the second segment then
/// lands at the wrong (reset-to-zero) offset once appended, and the
/// connection's HPACK decoder desyncs from the peer's encoder — the
/// same silent-wrong-values failure mode as #1397/#1401/#1423.
#[test]
fn a_continuation_frame_split_by_tcp_segmentation_survives_a_hup_while_draining() {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
let mut peer_encoder = crate::protocol::mux::hpack::Encoder::new();
let field_block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/tcp-segmented"[..]),
(&b"x-sozu-marker"[..], &b"legitimate-value"[..]),
]);
assert!(
field_block.len() > 40,
"the probe block must be large enough to split meaningfully"
);
let split = field_block.len() / 2;
let (first_half, continuation_payload) = field_block.split_at(split);
assert!(
continuation_payload.len() > 10,
"the CONTINUATION frame's own payload must be large enough to \
split into two TCP segments"
);
let payload_split = continuation_payload.len() / 2;
let (payload_segment_1, payload_segment_2) = continuation_payload.split_at(payload_split);
// HEADERS, END_STREAM but NOT END_HEADERS, stream 1.
let mut headers_frame = Vec::with_capacity(9 + first_half.len());
headers_frame.extend_from_slice(&(first_half.len() as u32).to_be_bytes()[1..]);
headers_frame.push(1); // HEADERS
headers_frame.push(parser::FLAG_END_STREAM);
headers_frame.extend_from_slice(&1u32.to_be_bytes());
headers_frame.extend_from_slice(first_half);
peer.write_all(&headers_frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::ContinuationHeader(_)) {
break;
}
std::thread::yield_now();
}
assert!(
matches!(connection.core.state, H2State::ContinuationHeader(_)),
"stream 1's HEADERS frame must be accepted and await CONTINUATION: {:?}",
connection.core.state
);
// The CONTINUATION frame header, announcing the FULL payload
// length, followed by only its FIRST TCP segment.
let mut continuation_header_and_segment_1 = Vec::with_capacity(9 + payload_segment_1.len());
continuation_header_and_segment_1
.extend_from_slice(&(continuation_payload.len() as u32).to_be_bytes()[1..]);
continuation_header_and_segment_1.push(9); // CONTINUATION
continuation_header_and_segment_1.push(parser::FLAG_END_HEADERS);
continuation_header_and_segment_1.extend_from_slice(&1u32.to_be_bytes());
continuation_header_and_segment_1.extend_from_slice(payload_segment_1);
peer.write_all(&continuation_header_and_segment_1)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
// Drive reads until the connection has consumed exactly the first
// segment and is waiting on the rest of THIS SAME CONTINUATION
// frame's payload — a genuine partial `socket_read()`, proven by
// `expect_read` still wanting the remainder.
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::ContinuationFrame(_))
&& matches!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Zero, remaining)) if remaining == payload_segment_2.len()
)
{
break;
}
std::thread::yield_now();
}
assert!(
matches!(connection.core.state, H2State::ContinuationFrame(_)),
"the CONTINUATION frame must be mid-payload, waiting on its \
second TCP segment: {:?}",
connection.core.state
);
assert_eq!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Zero, payload_segment_2.len())),
"expect_read must want exactly the remaining bytes of this \
frame's own payload — proof this is a genuine partial read, \
not an inter-frame gap"
);
// Exactly B2's trigger: the connection is draining AND the frontend
// readiness reports HUP, landing squarely mid-payload-read — the
// shape a TCP half-close produces (mio's `is_read_closed()` can be
// true with data the peer already sent still unread).
connection.core.drain.__test_set_draining();
connection.core.readiness.event.insert(Ready::HUP);
connection.writable(&mut context, EndpointClient(&mut router));
connection.core.readiness.event.remove(Ready::HUP);
// Now the second TCP segment, completing the CONTINUATION frame's
// payload, arrives.
peer.write_all(payload_segment_2)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if !matches!(
connection.core.state,
H2State::ContinuationHeader(_) | H2State::ContinuationFrame(_)
) {
break;
}
std::thread::yield_now();
}
assert!(
!matches!(connection.core.state, H2State::Error | H2State::GoAway),
"a HUP-while-draining event landing mid-TCP-segment must not \
corrupt a legitimate in-progress header block into a \
connection error: {:?}",
connection.core.state
);
let next_block = peer_encoder.encode([(&b"x-sozu-marker"[..], &b"legitimate-value"[..])]);
assert_eq!(
next_block.len(),
1,
"the peer must now reference its dynamic entry, not re-send a literal"
);
let mut decoded = Vec::new();
let status = connection
.core
.hpack
.decoder_mut()
.decode_with_cb(&next_block, |k, v| {
decoded.push((k.into_owned(), v.into_owned()));
});
assert!(
status.is_ok(),
"the connection decoder must still resolve the peer's dynamic \
table after a HUP-while-draining event landing mid-TCP-segment, \
got {status:?}"
);
assert_eq!(
decoded,
vec![(b"x-sozu-marker".to_vec(), b"legitimate-value".to_vec())],
"stream 1's field block must have been decoded byte-for-byte, \
not clobbered by a HUP-while-draining event landing between \
two TCP segments of the same CONTINUATION frame"
);
}
// ── The HPACK encoder across one multi-stream write pass ────────────
//
// `converter.rs`'s own unit tests drive ONE `H2BlockConverter` over ONE
// kawa. Nothing there can see a property that only exists across the
// streams of a single `write_streams` pass, and those are precisely the
// two the per-`kawa.prepare` converter scoping has to preserve: the
// RFC 7541 §6.3 size-update prefix belongs to the first header block of
// the pass and to no other, and every block of the pass is encoded
// against the SAME encoder dynamic table.
/// Split a captured H2 byte stream into its HEADERS frame payloads,
/// each paired with the stream it belongs to, in wire order. DATA and
/// control frames are skipped; a truncated capture is a test bug and
/// asserts rather than silently returning a short list.
fn headers_blocks(wire: &[u8]) -> Vec<(StreamId, Vec<u8>)> {
let mut blocks = Vec::new();
let mut offset = 0usize;
while offset + parser::FRAME_HEADER_SIZE <= wire.len() {
let payload_len = ((wire[offset] as usize) << 16)
| ((wire[offset + 1] as usize) << 8)
| wire[offset + 2] as usize;
let frame_type = wire[offset + 3];
let stream_id = u32::from_be_bytes([
wire[offset + 5],
wire[offset + 6],
wire[offset + 7],
wire[offset + 8],
]) & 0x7fff_ffff;
let start = offset + parser::FRAME_HEADER_SIZE;
let end = start + payload_len;
assert!(
end <= wire.len(),
"captured frame must be complete: {payload_len} payload bytes \
declared at offset {offset} of a {} byte capture",
wire.len()
);
if frame_type == 1 {
blocks.push((stream_id, wire[start..end].to_vec()));
}
offset = end;
}
blocks
}
/// `true` when `block` opens with an RFC 7541 §6.3 dynamic-table-size
/// update. The representation prefixes partition the first byte with no
/// overlap — `1xxxxxxx` indexed, `01xxxxxx` literal-with-indexing,
/// `0001xxxx` literal-never-indexed, `0000xxxx` literal-without-indexing
/// — so the `001xxxxx` pattern identifies the update on its own,
/// whatever integer follows it.
fn starts_with_size_update(block: &[u8]) -> bool {
block.first().is_some_and(|b| b & 0xE0 == 0x20)
}
/// Drive exactly ONE `write_streams` pass that emits a response header
/// block on every `stream_ids` entry, and return those blocks in the
/// order they reached the wire together with whatever size-update the
/// connection still has queued afterwards.
///
/// Each stream is opened by a real HEADERS frame from the peer and then
/// filled with sozu's own 404 default answer through
/// `answers::set_default_answer`, the same chokepoint the routing layer
/// uses — so the blocks captured here are the ones production encodes.
///
/// `pending_table_size_update` is installed the way `handle_settings_frame`
/// installs it: the encoder cap and the queued signal move together.
fn drive_one_write_pass(
stream_ids: &[StreamId],
pending_table_size_update: Option<u32>,
) -> (Vec<(StreamId, Vec<u8>)>, Option<u32>) {
use std::io::{Read, Write};
let pool = Rc::new(RefCell::new(Pool::with_capacity(8, 40, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
let mut peer_encoder = crate::protocol::mux::hpack::Encoder::new();
for &stream_id in stream_ids {
let field_block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/write-pass"[..]),
]);
let mut frame = Vec::with_capacity(parser::FRAME_HEADER_SIZE + field_block.len());
frame.extend_from_slice(&(field_block.len() as u32).to_be_bytes()[1..]);
frame.push(1); // HEADERS
frame.push(parser::FLAG_END_STREAM | parser::FLAG_END_HEADERS);
frame.extend_from_slice(&stream_id.to_be_bytes());
frame.extend_from_slice(&field_block);
peer.write_all(&frame)
.expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if connection
.core
.stream_table
.streams()
.contains_key(&stream_id)
{
break;
}
std::thread::yield_now();
}
assert!(
connection
.core
.stream_table
.streams()
.contains_key(&stream_id),
"stream {stream_id} must be accepted before the write pass"
);
}
let answers = context.listener.borrow().get_answers().clone();
let open: Vec<GlobalStreamId> = connection
.core
.stream_table
.streams()
.values()
.copied()
.collect();
for global_stream_id in open {
crate::protocol::mux::answers::set_default_answer(
&mut context.streams[global_stream_id],
&mut connection.core.readiness,
404,
&answers.borrow(),
);
}
// Discard whatever the handshake already put on the wire, so the
// capture below holds this write pass and nothing else.
let mut discard = Vec::new();
let _ = peer.read_to_end(&mut discard);
if let Some(size) = pending_table_size_update {
connection
.core
.hpack
.set_encoder_max_table_size(size as usize);
}
connection.core.pending_table_size_update = pending_table_size_update;
connection.writable(&mut context, EndpointClient(&mut router));
let mut wire = Vec::new();
let _ = peer.read_to_end(&mut wire);
(
headers_blocks(&wire),
connection.core.pending_table_size_update,
)
}
/// Two properties of a `write_streams` pass, both invisible to any
/// single-stream test, pinned on the bytes two streams actually receive:
///
/// 1. the RFC 7541 §6.3 dynamic-table-size-update prefix is written to
/// the FIRST header block of the pass and to no other, and the
/// connection's mirror is cleared only because a block carried it;
/// 2. both blocks are encoded against ONE encoder dynamic table, so a
/// peer replaying them in order with a single decoder stays in sync —
/// and a decoder that never saw the first block CANNOT read the
/// second, which is the negative half that makes assertion 2
/// discriminating rather than vacuous.
///
/// Scoping the `H2BlockConverter` to a single `kawa.prepare` call is
/// what puts both at risk: the converter no longer spans the per-stream
/// loop, so the signal and the encoder now cross from one stream to the
/// next through [`converter::H2ConverterPass`] rather than through one
/// long-lived struct.
///
/// TO SEE THIS RED, break either half:
/// (a) delete `self.pending_table_size_update = converter.pending_table_size_update;`
/// from `converter::H2ConverterPass::reclaim` — every block of the
/// pass then re-emits the prefix, and the second assertion fails with
/// `only the FIRST header block of a pass carries the size update;
/// stream 3 opened with 0x3f`;
/// (b) pass a fresh `crate::protocol::mux::hpack::Encoder::new()` to `pass.converter(..)`
/// in `write_streams` instead of `self.hpack.encoder_mut()` — every
/// block becomes self-contained and the LAST assertion fails with
/// `the second block must depend on the dynamic table the first one
/// built`.
#[test]
fn one_write_pass_prefixes_its_first_header_block_only_and_shares_one_encoder() {
// 4096 is the HPACK default table size, so `[0x3f, 0xe1, 0x1f]` below
// is the canonical §6.3 encoding: prefix bits `001`, a 5-bit prefix
// integer saturated to 31, then 4096 - 31 = 4065 as two continuation
// octets (0x61 | 0x80, 0x1f). Written out rather than produced with
// the same encoder the production path uses, so the test cannot
// agree with a broken encoder.
const TABLE_SIZE: u32 = 4096;
const SIZE_UPDATE_PREFIX: [u8; 3] = [0x3f, 0xe1, 0x1f];
let (blocks, residual) = drive_one_write_pass(&[1, 3], Some(TABLE_SIZE));
assert_eq!(
blocks.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
vec![1, 3],
"one pass must emit one header block per open stream, in the \
priority order (equal urgency, ascending stream id)"
);
assert_eq!(
&blocks[0].1[..SIZE_UPDATE_PREFIX.len()],
&SIZE_UPDATE_PREFIX,
"the first header block of the pass must open with the \
dynamic-table-size-update for {TABLE_SIZE}"
);
assert!(
!starts_with_size_update(&blocks[1].1),
"only the FIRST header block of a pass carries the size update; \
stream {} opened with {:#04x}",
blocks[1].0,
blocks[1].1[0]
);
assert_eq!(
residual, None,
"the connection clears its mirror once a block carried the signal"
);
// Positive half: the peer's single decoder replays the pass in order.
let mut peer_decoder = crate::protocol::mux::hpack::Decoder::new();
peer_decoder.set_max_allowed_table_size(TABLE_SIZE as usize);
for (stream_id, block) in &blocks {
let mut decoded = Vec::new();
let status = peer_decoder.decode_with_cb(block, |key, value| {
decoded.push((key.into_owned(), value.into_owned()));
});
assert!(
status.is_ok(),
"a peer replaying the pass in order must decode stream \
{stream_id}'s block: {status:?}"
);
assert!(
decoded.contains(&(b":status".to_vec(), b"404".to_vec())),
"stream {stream_id}'s block must carry the 404 status line, \
got {decoded:?}"
);
}
// Negative half: the second block is not self-contained. It indexes
// dynamic-table entries only the first block created, so a decoder
// that never saw the first block cannot read it. Without this, a
// per-stream encoder would satisfy every assertion above.
let mut fresh_decoder = crate::protocol::mux::hpack::Decoder::new();
fresh_decoder.set_max_allowed_table_size(TABLE_SIZE as usize);
let status = fresh_decoder.decode_with_cb(&blocks[1].1, |_, _| {});
assert!(
status.is_err(),
"the second block must depend on the dynamic table the first one \
built — a decoder that never saw the first block must fail on \
it, otherwise the two blocks came from two encoders"
);
}
// ── RFC 9113 §3.4 client connection preface, read-fragmentation axis ──
//
// `Connection::new_h2_server` arms `expect_read` with
// `CLIENT_PREFACE_SIZE` (the 24-octet magic string plus a 9-octet
// SETTINGS frame header). When a read comes back SHORT of that request,
// the short-read branch of `H2Shell::readable` runs an early guard
// so a client that is plainly not speaking H2 is dropped without waiting
// for the rest of the window.
//
// Nothing about a TCP segment or a TLS record guarantees it lands on the
// 24-octet boundary, so that guard has to tolerate EVERY split of a
// byte-perfect preface while still refusing a wrong one.
/// The whole accumulated window, at every chunk size, is the axis that
/// matters here — not one hand-picked split. The failure this pins is
/// invisible at exactly 24 (the window never grows past the magic
/// string, so a prefix test against it still answers correctly) and at
/// exactly 33 (the request is filled, so the short-read branch is never
/// entered at all) — the two sizes a hand-written test is most likely to
/// pick. So the sweep collects every failing size instead of asserting
/// inside the loop, which would stop at the first one and hide the band.
///
/// To SEE THIS RED: in the `H2State::ClientPreface` arm of
/// `H2Shell::readable`'s short-read branch, compare the whole
/// accumulated window instead of the part the constant can cover —
/// `if !pri.starts_with(i)` in place of
/// `if !pri.starts_with(&i[..i.len().min(pri.len())])`. `starts_with`
/// asks whether the window is a prefix OF the 24-octet constant, so it
/// answers false for every window longer than 24 whatever the window
/// holds, and this reports `[1, 25, 26, 27, 28, 29, 30, 31, 32]`.
#[test]
fn a_byte_perfect_client_preface_survives_every_read_fragmentation() {
use std::io::Write;
// Built from the wire bytes, not from `serializer::H2_PRI`, so this
// also pins the constant's value rather than agreeing with whatever
// it happens to hold.
let mut wire = Vec::with_capacity(CLIENT_PREFACE_SIZE);
wire.extend_from_slice(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n");
// SETTINGS, empty payload, no flags, stream 0 — RFC 9113 §6.5.
wire.extend_from_slice(&[0, 0, 0, 4, 0, 0, 0, 0, 0]);
assert_eq!(
wire.len(),
CLIENT_PREFACE_SIZE,
"the probe must be exactly the window the server asks for"
);
let mut disconnected = Vec::new();
for chunk in 1..=40usize {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let mut delivered = 0usize;
let mut closed = false;
'delivery: for piece in wire.chunks(chunk) {
peer.write_all(piece).expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
delivered += piece.len();
// Drive until THIS piece has been consumed before writing the
// next one. Two loopback writes the connection has not read
// between coalesce in the kernel receive queue, which would
// hand `socket_read` a full 33-octet window, take the
// not-short path, and never reach the guard under test.
for _ in 0..64 {
if !matches!(connection.core.state, H2State::ClientPreface) {
break 'delivery;
}
if matches!(
connection.readable(&mut context, EndpointClient(&mut router)),
MuxResult::CloseSession
) {
closed = true;
break 'delivery;
}
if connection.core.stream_table.expect_read()
== Some((H2StreamId::Zero, CLIENT_PREFACE_SIZE - delivered))
{
break;
}
std::thread::yield_now();
}
}
if closed
|| matches!(
connection.core.state,
H2State::ClientPreface | H2State::Error | H2State::GoAway
)
{
disconnected.push(chunk);
}
}
assert!(
disconnected.is_empty(),
"a byte-perfect client preface must be accepted however the \
reads are fragmented; these chunk sizes were disconnected \
instead: {disconnected:?}"
);
}
/// The other half of the same guard: clipping the comparison to the part
/// the magic string can cover must not blunt it. A window that is wrong
/// INSIDE the first 24 octets is still refused while the request is
/// unfilled — which is the only moment the early guard can act, since
/// `parser::preface` in the `H2State::ClientPreface` arm is reached only
/// once the whole `CLIENT_PREFACE_SIZE` window has been read.
///
/// Corrupting the FIRST and the LAST octet of the magic string proves
/// the comparison still spans all 24, and sweeping the window across the
/// 25..=32 band proves the fix did not turn the band into a hole that
/// lets a non-H2 client linger.
///
/// To SEE THIS RED: drop the guard's `!`, or clip the comparison to
/// fewer octets than the magic string holds — e.g.
/// `&i[..i.len().min(pri.len() - 1)]`, which stops refusing the
/// `corrupt_at = 23` cases.
#[test]
fn an_invalid_client_preface_is_still_refused_before_the_window_is_filled() {
use std::io::Write;
let mut survivors = Vec::new();
for corrupt_at in [0usize, 23] {
for window in (corrupt_at + 1)..CLIENT_PREFACE_SIZE {
let mut wire = Vec::with_capacity(CLIENT_PREFACE_SIZE);
wire.extend_from_slice(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n");
wire.extend_from_slice(&[0, 0, 0, 4, 0, 0, 0, 0, 0]);
wire[corrupt_at] ^= 0xff;
wire.truncate(window);
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
peer.write_all(&wire).expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
// The window is short of `CLIENT_PREFACE_SIZE`, so the
// connection never leaves `H2State::ClientPreface` on its
// own: whatever refuses it here is the early guard.
for _ in 0..64 {
if matches!(
connection.readable(&mut context, EndpointClient(&mut router)),
MuxResult::CloseSession
) {
break;
}
std::thread::yield_now();
}
if !matches!(connection.core.state, H2State::Error) {
survivors.push((corrupt_at, window));
}
}
}
assert!(
survivors.is_empty(),
"a preface wrong inside its first 24 octets must be refused \
before the request is filled; these (corrupt_at, window) pairs \
survived: {survivors:?}"
);
}
// ── Property coverage: the HPACK encoder across a multi-stream pass ──
//
// The deterministic test above fixes ONE shape: two streams, the HPACK
// default table size, the size update landing on the first of exactly
// two blocks. This property generalises the stream count (2..=5) and the
// advertised table size, which decides both how many octets the §6.3
// prefix occupies and how much dynamic table the later blocks can index.
//
// It is a sibling of `reassembly_property` below rather than a case
// inside it: that one drives the READ path and its oracle is "one
// decoder stays in sync across a fragmented header block", while this
// one drives the WRITE path and its oracle is "one encoder, one prefix,
// N blocks a single peer decoder replays". One `quickcheck` verdict over
// two unrelated state machines would say nothing about either.
//
// The negative half of the deterministic test — a fresh decoder must
// FAIL on a later block — is deliberately left out here: a generated
// table size small enough to evict the dynamic table makes every block
// self-contained, which is correct behaviour and would fail that
// assertion.
mod write_pass_property {
use quickcheck::{Arbitrary, Gen, TestResult, quickcheck};
use super::*;
/// One generated write pass: how many streams share it, and the
/// `SETTINGS_HEADER_TABLE_SIZE` the peer advertised just before it.
#[derive(Debug, Clone, Copy)]
struct WritePassPlan {
streams: u8,
table_size: u16,
}
impl Arbitrary for WritePassPlan {
fn arbitrary(g: &mut Gen) -> Self {
WritePassPlan {
streams: *g.choose(&[2u8, 3, 4, 5]).expect("non-empty slice"),
// Bounded by the HPACK default a fresh `Decoder`
// accepts, mirrored onto the peer decoder below; a
// larger advertisement is a SETTINGS-path concern, not
// a write-pass one.
table_size: u16::arbitrary(g) % 4097,
}
}
}
fn drive(plan: WritePassPlan) -> TestResult {
let stream_ids: Vec<StreamId> =
(0..u32::from(plan.streams)).map(|i| 1 + 2 * i).collect();
let (blocks, residual) =
drive_one_write_pass(&stream_ids, Some(u32::from(plan.table_size)));
if blocks
.iter()
.map(|(id, _)| *id)
.ne(stream_ids.iter().copied())
{
return TestResult::error(format!(
"plan {plan:?}: expected one block per stream in priority \
order {stream_ids:?}, got {:?}",
blocks.iter().map(|(id, _)| *id).collect::<Vec<_>>()
));
}
if !starts_with_size_update(&blocks[0].1) {
return TestResult::error(format!(
"plan {plan:?}: the first block must open with the \
size update, got {:#04x}",
blocks[0].1[0]
));
}
for (stream_id, block) in blocks.iter().skip(1) {
if starts_with_size_update(block) {
return TestResult::error(format!(
"plan {plan:?}: stream {stream_id} re-emitted the \
size update the first block already carried"
));
}
}
if residual.is_some() {
return TestResult::error(format!(
"plan {plan:?}: the mirror must be cleared once a block \
carried the signal, still {residual:?}"
));
}
let mut peer_decoder = crate::protocol::mux::hpack::Decoder::new();
peer_decoder.set_max_allowed_table_size(usize::from(plan.table_size));
for (stream_id, block) in &blocks {
let mut decoded = Vec::new();
let status = peer_decoder.decode_with_cb(block, |key, value| {
decoded.push((key.into_owned(), value.into_owned()));
});
if status.is_err() {
return TestResult::error(format!(
"plan {plan:?}: a peer replaying the pass in order \
desynced on stream {stream_id}: {status:?}"
));
}
if !decoded.contains(&(b":status".to_vec(), b"404".to_vec())) {
return TestResult::error(format!(
"plan {plan:?}: stream {stream_id} decoded to \
{decoded:?}, expected the 404 status line"
));
}
}
TestResult::passed()
}
quickcheck! {
fn qc_one_write_pass_prefixes_its_first_header_block_only(plan: WritePassPlan) -> TestResult {
drive(plan)
}
}
}
// ── Property coverage: interleaved reassembly + control-frame flushes ──
//
// The four deterministic pinning tests above each fix ONE interleaving
// (an unrelated WINDOW_UPDATE flush, a graceful_goaway, a HUP-while-
// draining event between frames, a HUP-while-draining event mid-TCP-
// segment) at ONE point. This property generalises them: a
// `quickcheck`-driven `ReassemblyPlan` splits a real HPACK field block
// into 2..=5 CONTINUATION fragments and independently chooses, before
// each one, whether to interleave nothing, an unrelated WINDOW_UPDATE
// flush, a `graceful_goaway`, or a HUP-while-draining event — the same
// four triggers, now composed in every order and count `quickcheck`
// cares to generate — and, independently again, whether that interleave
// lands BETWEEN two frames or in the MIDDLE of one frame's own TCP-level
// write (review of `e1c3c2fb`, B2/B3: the two shapes are not
// interchangeable — `zero.storage` is empty in the former and holds a
// genuine partial `socket_read()` in the latter, and only the latter
// reaches the narrower guard the four deterministic tests individually
// pin). `quickcheck` is already a workspace dependency (see
// `lib/src/router/mod.rs`'s `qc_router_hostname_resolution_matches_
// the_documented_semantics`, whose `Arbitrary`/`quickcheck!` shape this
// mirrors).
//
// Any out-of-bounds read or other panic inside `drive()` fails the
// `quickcheck` run directly — there is no separate assertion for it.
// CONTINUATION-flood abort and the CVE-2024-27316 refusal path are
// deliberately NOT folded into this property: they end the block in a
// *different* terminal state (refused, not decoded), which would split
// this property's single pass/fail oracle in two. That path already has
// dedicated deterministic coverage —
// `a_refused_stream_keeps_the_hpack_decoder_in_sync` and
// `a_refused_padded_prioritized_stream_keeps_the_hpack_decoder_in_sync`
// above.
mod reassembly_property {
use quickcheck::{Arbitrary, Gen, TestResult, quickcheck};
use super::*;
/// One interleaved event the property driver may inject before a
/// CONTINUATION fragment. Mirrors the four deterministic pinning
/// tests' triggers.
#[derive(Debug, Clone, Copy)]
enum Interleave {
None,
UnrelatedWindowUpdateFlush,
HupWhileDraining,
GracefulGoaway,
}
impl Arbitrary for Interleave {
fn arbitrary(g: &mut Gen) -> Self {
*g.choose(&[
Interleave::None,
Interleave::UnrelatedWindowUpdateFlush,
Interleave::HupWhileDraining,
Interleave::GracefulGoaway,
])
.expect("the choice slice above is non-empty")
}
}
/// One interleave point, paired with whether it lands between two
/// frames (`mid_frame == false`, the original shape) or in the
/// middle of the following fragment's own TCP-level write
/// (`mid_frame == true` — a genuine partial `socket_read()`, the
/// shape B2 proved is NOT interchangeable with the former).
#[derive(Debug, Clone, Copy)]
struct InterleavePoint {
interleave: Interleave,
mid_frame: bool,
}
impl Arbitrary for InterleavePoint {
fn arbitrary(g: &mut Gen) -> Self {
InterleavePoint {
interleave: Interleave::arbitrary(g),
mid_frame: bool::arbitrary(g),
}
}
}
/// How many CONTINUATION frames to split the field block into
/// (2..=5) and which [`InterleavePoint`] to inject before each one
/// after the first — exactly `splits - 1` points, one per fragment
/// boundary, never `splits` (there is no point "before the first
/// fragment": the HEADERS frame that carries it is what starts the
/// reassembly).
#[derive(Debug, Clone)]
struct ReassemblyPlan {
splits: u8,
points: Vec<InterleavePoint>,
}
impl Arbitrary for ReassemblyPlan {
fn arbitrary(g: &mut Gen) -> Self {
let splits = 2 + (u8::arbitrary(g) % 4); // 2..=5
let points = (0..splits - 1)
.map(|_| InterleavePoint::arbitrary(g))
.collect();
ReassemblyPlan { splits, points }
}
}
/// Apply one interleave. `GracefulGoaway` checks
/// `connection.drain.draining()` — the AUTHORITATIVE state, not a
/// separately tracked bool — before calling `graceful_goaway`:
/// once the connection is already draining (whether a prior point
/// in this same plan called `graceful_goaway` itself, OR a prior
/// `HupWhileDraining` point set it via the test-only
/// `__test_set_draining()` backdoor), a SECOND `graceful_goaway`
/// call would be the RFC 9113 §6.8 FINAL GOAWAY
/// (`GracefulDrainDecision::AlreadyDraining`), which drops
/// `expect_read` and makes the block permanently uncompletable — a
/// different scenario than this property drives, so a
/// `GracefulGoaway` point reached while already draining is
/// treated as `None` instead. A local `bool` tracking only
/// "did GracefulGoaway itself fire" would miss the
/// `HupWhileDraining` case and was exactly the bug `quickcheck`
/// found while writing this property (review of `e1c3c2fb`, B3).
fn apply_interleave<L>(
connection: &mut H2Shell<mio::net::TcpStream>,
context: &mut Context<L>,
router: &mut Router,
interleave: Interleave,
) where
L: ListenerHandler + L7ListenerHandler,
{
match interleave {
Interleave::None => {}
Interleave::UnrelatedWindowUpdateFlush => {
connection.core.queue_window_update(0, 65_535);
connection.writable(context, EndpointClient(router));
}
Interleave::HupWhileDraining => {
connection.core.drain.__test_set_draining();
connection.core.readiness.event.insert(Ready::HUP);
connection.writable(context, EndpointClient(router));
connection.core.readiness.event.remove(Ready::HUP);
}
Interleave::GracefulGoaway => {
if !connection.core.drain.draining() {
let now = connection.core.now;
connection.graceful_goaway(now);
}
}
}
}
/// Build the connection, split a real field block into
/// `plan.splits` fragments, and send them as HEADERS + N-1
/// CONTINUATION frames, injecting `plan.points[i - 1]` before
/// fragment `i` (`i >= 1`) — either between the two frames, or
/// split across two TCP-level writes of that fragment's OWN
/// payload with the interleave firing in between. Asserts the
/// connection stays usable and the block decodes byte-for-byte
/// regardless of which interleaves fired or where.
fn drive(plan: ReassemblyPlan) -> TestResult {
use std::io::Write;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 20, 16_384)));
let (mut connection, mut peer) = test_h2_connection(&pool, None);
// The interleaved WINDOW_UPDATE flush really writes to the socket
// since #1604 (it used to be deferred while a block was being
// reassembled). Once sozu's end sends data, Linux switches it to
// delayed ACKs ("pingpong" mode), so the peer's last segment is
// acknowledged up to 40 ms late, and the peer's Nagle holds its
// next small CONTINUATION until that ACK: longer than the
// yield-bounded read loops below wait. H2 clients disable Nagle.
peer.set_nodelay(true)
.expect("the loopback peer must accept TCP_NODELAY");
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.state = H2State::Header;
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
let mut peer_encoder = crate::protocol::mux::hpack::Encoder::new();
let field_block = peer_encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/qc-multiframe"[..]),
(&b"x-sozu-qc-marker"[..], &b"qc-value"[..]),
]);
let splits = plan.splits as usize;
if field_block.len() < splits * 2 {
// Too few bytes to split `splits` ways meaningfully.
return TestResult::discard();
}
let chunk = (field_block.len() / splits).max(1);
let mut fragments: Vec<&[u8]> = Vec::with_capacity(splits);
let mut rest = &field_block[..];
for i in 0..splits {
if i + 1 == splits {
fragments.push(rest);
} else {
let (a, b) = rest.split_at(chunk);
fragments.push(a);
rest = b;
}
}
if fragments.iter().any(|f| f.is_empty()) {
return TestResult::discard();
}
let first = fragments[0];
let mut frame = Vec::with_capacity(9 + first.len());
frame.extend_from_slice(&(first.len() as u32).to_be_bytes()[1..]);
frame.push(1); // HEADERS
frame.push(parser::FLAG_END_STREAM);
frame.extend_from_slice(&1u32.to_be_bytes());
frame.extend_from_slice(first);
if peer.write_all(&frame).is_err() || peer.flush().is_err() {
return TestResult::discard();
}
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::ContinuationHeader(_)) {
break;
}
std::thread::yield_now();
}
if !matches!(connection.core.state, H2State::ContinuationHeader(_)) {
return TestResult::discard();
}
for (idx, fragment) in fragments.iter().enumerate().skip(1) {
let point = plan.points[idx - 1];
let is_last = idx + 1 == fragments.len();
let end_headers_flag = if is_last { parser::FLAG_END_HEADERS } else { 0 };
if point.mid_frame && fragment.len() >= 2 {
// Split THIS fragment's own payload into two TCP-level
// writes, firing the interleave in between — a genuine
// partial `socket_read()`, not a gap between frames.
let mid = fragment.len() / 2;
let (segment_1, segment_2) = fragment.split_at(mid);
let mut header_and_segment_1 = Vec::with_capacity(9 + segment_1.len());
header_and_segment_1
.extend_from_slice(&(fragment.len() as u32).to_be_bytes()[1..]);
header_and_segment_1.push(9); // CONTINUATION
header_and_segment_1.push(end_headers_flag);
header_and_segment_1.extend_from_slice(&1u32.to_be_bytes());
header_and_segment_1.extend_from_slice(segment_1);
if peer.write_all(&header_and_segment_1).is_err() || peer.flush().is_err() {
return TestResult::discard();
}
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if matches!(connection.core.state, H2State::ContinuationFrame(_))
&& matches!(
connection.core.stream_table.expect_read(),
Some((H2StreamId::Zero, remaining)) if remaining == segment_2.len()
)
{
break;
}
std::thread::yield_now();
}
if !matches!(connection.core.state, H2State::ContinuationFrame(_)) {
// The segment split did not land as a genuine
// partial read (e.g. too small to observe) — treat
// as a discard rather than a false failure.
return TestResult::discard();
}
apply_interleave(&mut connection, &mut context, &mut router, point.interleave);
if peer.write_all(segment_2).is_err() || peer.flush().is_err() {
return TestResult::discard();
}
} else {
apply_interleave(&mut connection, &mut context, &mut router, point.interleave);
let mut cframe = Vec::with_capacity(9 + fragment.len());
cframe.extend_from_slice(&(fragment.len() as u32).to_be_bytes()[1..]);
cframe.push(9); // CONTINUATION
cframe.push(end_headers_flag);
cframe.extend_from_slice(&1u32.to_be_bytes());
cframe.extend_from_slice(fragment);
if peer.write_all(&cframe).is_err() || peer.flush().is_err() {
return TestResult::discard();
}
}
for _ in 0..64 {
connection.readable(&mut context, EndpointClient(&mut router));
if !matches!(
connection.core.state,
H2State::ContinuationHeader(_) | H2State::ContinuationFrame(_)
) {
break;
}
std::thread::yield_now();
}
}
if matches!(connection.core.state, H2State::Error | H2State::GoAway) {
return TestResult::error(format!(
"connection reached {:?} while reassembling under plan {plan:?}",
connection.core.state
));
}
let next_block = peer_encoder.encode([(&b"x-sozu-qc-marker"[..], &b"qc-value"[..])]);
let mut decoded = Vec::new();
let status = connection
.core
.hpack
.decoder_mut()
.decode_with_cb(&next_block, |k, v| {
decoded.push((k.into_owned(), v.into_owned()));
});
if status.is_err() {
return TestResult::error(format!(
"decoder desynced after plan {plan:?}: {status:?}"
));
}
if decoded != vec![(b"x-sozu-qc-marker".to_vec(), b"qc-value".to_vec())] {
return TestResult::error(format!(
"decoded {decoded:?} after plan {plan:?}, expected the qc marker header"
));
}
TestResult::passed()
}
quickcheck! {
fn qc_h2_header_reassembly_survives_interleaved_control_frame_flushes(plan: ReassemblyPlan) -> TestResult {
drive(plan)
}
}
}
// ── #1454: the write path over a REAL `FrontRustls` ─────────────────
//
// Every `socket_wants_write()` branch exercised above this point is
// exercised over `BackpressuredTlsSocket` — a TCP handler with a modelled
// answer. #1454 rules that shape out for closing it: `FrontRustls` is the
// only production handler that can answer `true`, and a fixture that fakes
// the answer on a TCP handler tests the fake. These are the first tests in
// the tree that build a `H2Shell<FrontRustls>`;
// `git grep -cE 'H2Shell<FrontRustls>' -- '*.rs'` answered 0 on every
// commit before this one, including on the `write_streams` inversion's head.
//
// What only the real handler can settle: `(size > 0, WouldBlock)` is not a
// scripted pair here. It is what `FrontRustls::socket_write_vectored`
// returns when rustls absorbs plaintext into its own record buffer while
// `write_tls` blocks against a full kernel send queue — `buffered_size` and
// `can_write` are two independent quantities sharing one return value.
// `update_readiness` classifies a pass as stalled iff `size == 0`, so that
// pair is NOT a stall and the flush loop must go round again. Everything
// written against a scripted pair assumes the production handler produces
// it; nothing proved it until these tests.
//
// The handshake runs in memory between a real `rustls::ServerConnection`
// and a real `rustls::ClientConnection`, and only then is the settled
// server session attached to a loopback socket whose kernel queues are
// pinned small. That order matters: a flight never has to fit through the
// shrunken queue, so the backpressure under test is entirely the test's
// and never the handshake's.
use crate::socket::FrontRustls;
/// Accepts whatever certificate the frontend serves.
///
/// `lib/assets/certificate.pem` is self-signed and carries NO
/// subjectAltName, so rustls's own web-PKI verifier rejects it whatever
/// root store it is given — a CN-only certificate is not verifiable under
/// RFC 6125 §6.4.4 as rustls implements it. The client here is a
/// decryption oracle for bytes the frontend wrote, not a party whose trust
/// decision is under test, so it asserts validity outright. Same shape as
/// `e2e`'s `Verifier`, which exists for the same certificate.
#[derive(Debug)]
struct AcceptAnyServerCertificate;
impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCertificate {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
]
}
}
/// SNI the test certificate is registered under.
const TEST_SNI: &str = "lolcatho.st";
/// Kernel send/receive queue size for the loopback pair, in bytes. Linux
/// doubles and floors the request, so the effective queue is a few KiB —
/// an order of magnitude under rustls's own 64 KiB record buffer, which is
/// what makes `socket_write_vectored` return a partial count AND
/// `WouldBlock` together instead of one or the other.
const PINNED_SOCKET_QUEUE: usize = 4096;
/// Bytes the tests below queue as a response. Larger than rustls's record
/// buffer, so one `socket_write_vectored` cannot absorb it all.
const QUEUED_RESPONSE_LEN: usize = 256 * 1024;
/// Blocks the response is split into, so a gather offers several slices.
const QUEUED_RESPONSE_CHUNK_LEN: usize = QUEUED_RESPONSE_LEN / 8;
/// Hard bound on the drive loops below. Reaching it is a test failure with
/// the byte shortfall attached, never a silent pass.
const MAX_DRIVE_TICKS: usize = 4096;
/// The response bytes, leaked once so blocks can be `Store::Static`.
///
/// A repeating non-trivial pattern rather than a constant fill: a
/// comparison against a constant fill cannot see bytes delivered out of
/// order or a block delivered twice, which is half of what "no truncation"
/// has to mean.
///
/// Laid out as whole DATA frames on stream 1, each 16 KiB on the wire: a
/// stream's `kawa.out` only ever holds whole H2 frames, and since #1604
/// the write pass reads their headers to find where the frame a partial
/// write cut ends (`h2_transmit::frame_tail`). The pattern runs through
/// the headers as well, so the oracle still compares every byte.
fn queued_response_body() -> &'static [u8] {
const FRAME_LEN: usize = 16 * 1024;
const PAYLOAD_LEN: usize = FRAME_LEN - 9;
const _: () = assert!(QUEUED_RESPONSE_LEN.is_multiple_of(FRAME_LEN));
static BODY: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
BODY.get_or_init(|| {
let mut body: Vec<u8> = (0..QUEUED_RESPONSE_LEN)
.map(|index| (index % 251) as u8)
.collect();
for frame in body.chunks_mut(FRAME_LEN) {
frame[..9].copy_from_slice(&[
(PAYLOAD_LEN >> 16) as u8,
(PAYLOAD_LEN >> 8) as u8,
PAYLOAD_LEN as u8,
0,
0,
0,
0,
0,
1,
]);
}
Box::leak(body.into_boxed_slice())
})
}
/// Move one TLS flight from `from` to `to`, in memory.
fn pump_tls<A, B>(
from: &mut rustls::ConnectionCommon<A>,
to: &mut rustls::ConnectionCommon<B>,
) {
let mut wire = Vec::new();
while from.wants_write() {
from.write_tls(&mut wire)
.expect("a TLS flight must serialize into memory");
}
if wire.is_empty() {
return;
}
let mut cursor = std::io::Cursor::new(wire.as_slice());
while (cursor.position() as usize) < wire.len() {
to.read_tls(&mut cursor)
.expect("a TLS flight must be readable from memory");
to.process_new_packets()
.expect("a TLS flight must process cleanly");
}
}
/// A settled TLS 1.3 `FrontRustls` over a loopback socket whose kernel
/// queues are pinned small, the peer end of that socket, and the client
/// session that decrypts what the frontend writes.
///
/// The peer and the client session are returned rather than kept here
/// because dropping either would close the connection under the handler
/// being tested, and because together they are the only oracle for "the
/// bytes actually arrived".
fn handshaken_front_rustls() -> (FrontRustls, std::net::TcpStream, rustls::ClientConnection) {
let provider = std::sync::Arc::new(crate::crypto::default_provider());
let resolver = std::sync::Arc::new(crate::tls::MutexCertificateResolver::default());
resolver
.0
.lock()
.expect("the test resolver lock must be available")
.add_certificate(&sozu_command::proto::command::AddCertificate {
address: sozu_command::proto::command::SocketAddress::new_v4(127, 0, 0, 1, 8443),
certificate: sozu_command::proto::command::CertificateAndKey {
certificate: include_str!("../../../assets/certificate.pem").to_owned(),
key: include_str!("../../../assets/key.pem").to_owned(),
names: vec![TEST_SNI.to_owned()],
..Default::default()
},
expired_at: None,
})
.expect("the test certificate must load into the resolver");
let mut server_config = rustls::ServerConfig::builder_with_provider(provider.clone())
.with_protocol_versions(&[&rustls::version::TLS13])
.expect("the test provider must support TLS 1.3")
.with_no_client_auth()
.with_cert_resolver(resolver);
// TLS 1.3 queues NewSessionTicket straight after the client Finished.
// A server that sends tickets answers `socket_wants_write()` with
// `true` before this fixture has queued a single response byte, and
// every test below would then read `true` for the wrong reason.
server_config.send_tls13_tickets = 0;
let client_config = rustls::ClientConfig::builder_with_provider(provider)
.with_protocol_versions(&[&rustls::version::TLS13])
.expect("the test provider must support TLS 1.3")
.dangerous()
.with_custom_certificate_verifier(std::sync::Arc::new(AcceptAnyServerCertificate))
.with_no_client_auth();
let mut server = rustls::ServerConnection::new(std::sync::Arc::new(server_config))
.expect("the test server session must initialize");
let mut client = rustls::ClientConnection::new(
std::sync::Arc::new(client_config),
rustls::pki_types::ServerName::try_from(TEST_SNI)
.expect("the test SNI must be a valid DNS name"),
)
.expect("the test client session must initialize");
for _ in 0..MAX_DRIVE_TICKS {
if !client.is_handshaking()
&& !server.is_handshaking()
&& !client.wants_write()
&& !server.wants_write()
{
break;
}
pump_tls(&mut client, &mut server);
pump_tls(&mut server, &mut client);
}
assert!(
!server.is_handshaking() && !client.is_handshaking(),
"premise: the in-memory handshake must complete, or the session \
under test buffers plaintext instead of encrypting it"
);
assert!(
!server.wants_write(),
"premise: a settled handshake must leave the server holding no \
records, or socket_wants_write() answers true before this fixture \
has queued one response byte"
);
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("the test listener must bind");
// Set on the listener so the accepted socket inherits it and the
// advertised window starts small.
socket2::SockRef::from(&listener)
.set_recv_buffer_size(PINNED_SOCKET_QUEUE)
.expect("the test listener receive queue must shrink");
let address = listener
.local_addr()
.expect("the test listener must expose its address");
let front = std::net::TcpStream::connect(address).expect("the loopback connect must land");
let (peer, _peer_address) = listener.accept().expect("the test peer must be accepted");
socket2::SockRef::from(&front)
.set_send_buffer_size(PINNED_SOCKET_QUEUE)
.expect("the frontend send queue must shrink");
socket2::SockRef::from(&peer)
.set_recv_buffer_size(PINNED_SOCKET_QUEUE)
.expect("the peer receive queue must shrink");
front
.set_nonblocking(true)
.expect("mio requires a non-blocking stream");
peer.set_nonblocking(true)
.expect("the peer must not block the drive loop");
let socket = FrontRustls {
stream: mio::net::TcpStream::from_std(front),
session: server,
peer_disconnected: false,
peer_reset: false,
tls_fatal: false,
session_ulid: Ulid::generate(),
configured_peer: None,
recv_memory: Default::default(),
};
(socket, peer, client)
}
/// A server `ConnectionH2` over that handler.
///
/// `state` is a parameter for the same reason
/// `connection_with_backpressure` takes one: the close decision reads it
/// as `H2State::GoAway` and the write pass reads it as `H2State::Header`.
pub(in crate::protocol::mux) fn rustls_h2_connection(
pool: &Rc<RefCell<Pool>>,
state: H2State,
) -> (
H2Shell<FrontRustls>,
std::net::TcpStream,
rustls::ClientConnection,
) {
let (socket, peer, client) = handshaken_front_rustls();
let mut connection = H2Shell::new(
Ulid::generate(),
socket,
Position::Server,
&mut PoolBufferSource::new(Rc::downgrade(pool)),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
Duration::from_secs(30),
Some((H2StreamId::Zero, CLIENT_PREFACE_SIZE)),
Ready::WRITABLE | Ready::HUP | Ready::ERROR,
)
.expect("a pool with free buffers must yield an H2 connection");
connection.core.state = state;
(connection, peer, client)
}
/// Read every TLS record the peer's kernel currently holds, decrypt it,
/// and append the plaintext to `received`.
fn drain_peer(
client: &mut rustls::ClientConnection,
peer: &mut std::net::TcpStream,
received: &mut Vec<u8>,
) {
loop {
match client.read_tls(peer) {
Ok(0) => break,
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break,
Err(error) => {
panic!("the peer must read the frontend's TLS records: {error:?}")
}
}
client
.process_new_packets()
.expect("the peer must decrypt what the frontend wrote");
take_plaintext(client, received);
}
take_plaintext(client, received);
}
/// Drain the client session's decrypted plaintext buffer.
///
/// `WouldBlock` here means "nothing decrypted yet", not an error, and
/// `read_to_end` appends whatever it did read before reporting it.
fn take_plaintext(client: &mut rustls::ClientConnection, received: &mut Vec<u8>) {
use std::io::Read as _;
let mut plaintext = Vec::new();
match client.reader().read_to_end(&mut plaintext) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
Err(error) => panic!("the peer must decrypt the frontend's records: {error:?}"),
}
received.extend_from_slice(&plaintext);
}
/// Keep flushing the handler's buffered records while the peer reads,
/// until rustls holds nothing. Returns whether it got there.
fn flush_until_drained(
socket: &mut FrontRustls,
client: &mut rustls::ClientConnection,
peer: &mut std::net::TcpStream,
received: &mut Vec<u8>,
) -> bool {
for _ in 0..MAX_DRIVE_TICKS {
if !socket.socket_wants_write() {
return true;
}
// Loopback delivery is softirq-driven, not synchronous with
// `write_tls`: give it a slot rather than spinning the CPU.
std::thread::yield_now();
drain_peer(client, peer, received);
socket.socket_write(&[]);
}
!socket.socket_wants_write()
}
/// A TLS frontend whose final GOAWAY went out without `close_notify`
/// behind it: the close its next writable pass decides queues the alert
/// first and waits a pass for it, then starts the lingering close. The
/// first pass's close moves the state to `Error`; the pending linger is
/// what keeps the second pass from closing outright. The client reads
/// the alert, then the EOF of the half-close.
///
/// TO SEE THIS RED: in `H2Shell::linger_instead_of_closing`, drop the
/// `self.core.linger = Linger::Pending { deadline };` before the deferral.
/// The second pass then returns `CloseSession`.
#[test]
fn a_linger_deferred_behind_close_notify_starts_on_the_next_pass() {
use std::io::Read as _;
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, mut peer, mut client) = rustls_h2_connection(&pool, H2State::GoAway);
connection.core.drain.__test_set_draining();
connection.core.graceful_goaway = true;
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let first = connection.writable(&mut context, EndpointClient(&mut router));
assert!(
matches!(first, MuxResult::Continue) && !connection.core.is_lingering(),
"the first pass waits for close_notify, got {first:?}"
);
let mut second = MuxResult::Continue;
for _ in 0..MAX_DRIVE_TICKS {
connection.core.readiness.event.insert(Ready::WRITABLE);
second = connection.writable(&mut context, EndpointClient(&mut router));
if connection.core.is_lingering() || !matches!(second, MuxResult::Continue) {
break;
}
std::thread::yield_now();
}
assert!(
matches!(second, MuxResult::Continue) && connection.core.is_lingering(),
"once close_notify is out the connection lingers, got {second:?}"
);
let mut received = Vec::new();
let mut closed = false;
for _ in 0..MAX_DRIVE_TICKS {
drain_peer(&mut client, &mut peer, &mut received);
let mut plaintext = [0u8; 16];
match client.reader().read(&mut plaintext) {
Ok(0) => {
closed = true;
break;
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::yield_now()
}
Err(error) => panic!("the client must see a clean close: {error:?}"),
}
}
assert!(
closed,
"the client reads close_notify, then the end of the stream"
);
}
/// The final GOAWAY and the TLS `close_notify` leave in ONE write, and the
/// peer reads the GOAWAY before the close.
///
/// Drives what a client GOAWAY on an idle connection sets off:
/// `goaway(NoError)`, the `writable` pass that flushes it and starts the
/// lingering close, and `close`. Every TLS write in between is counted through
/// `crate::socket::tls_writes`, which counts each `write_tls` holding at
/// least one record, i.e. each `writev(2)` `FrontRustls` issues. The wire
/// is then read raw, so the ORDER is checked on the records themselves
/// (TLS 1.3 hides the inner type, not the length: 5 + 17 + 1 + 16 = 39 for
/// the GOAWAY, 5 + 2 + 1 + 16 = 24 for the alert), and decrypted, so the
/// client sees the 17-byte GOAWAY and then a clean end of stream, which
/// rustls reports only once it has read `close_notify`.
///
/// TO SEE THIS RED: in `H2Shell::flush_output_to_socket`, drop the
/// `output_flush_closes_connection` branch so every output flush goes
/// through `socket_write`. The GOAWAY then leaves alone and `close` sends the
/// alert in a second write: this test fails on `the final GOAWAY and
/// close_notify must share one TLS write`, with 2 writes. For the order:
/// in `FrontRustls::socket_write_then_close`, call `send_close_notify`
/// before `writer().write`; it fails on the records, `[24, 39]`.
#[test]
fn the_final_goaway_and_close_notify_leave_in_one_tls_write() {
use std::io::Read as _;
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, mut peer, mut client) = rustls_h2_connection(&pool, H2State::Header);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let writes_before = crate::socket::tls_writes();
let queued = connection.core.goaway(H2Error::NoError);
assert!(
matches!(queued, MuxResult::Continue),
"premise: the GOAWAY must serialise, got {queued:?}"
);
let result = connection.writable(&mut context, EndpointClient(&mut router));
// The close that pass decides is a lingering one: the write side is
// shut down behind the GOAWAY and `close_notify`, and what the client
// still sends is drained (`H2Shell::linger_instead_of_closing`).
assert!(
matches!(result, MuxResult::Continue) && connection.core.is_lingering(),
"the pass that flushed the final GOAWAY must start the lingering close, got {result:?}"
);
connection.close(&mut context, EndpointClient(&mut router));
assert_eq!(
crate::socket::tls_writes() - writes_before,
1,
"the final GOAWAY and close_notify must share one TLS write"
);
let mut wire = Vec::new();
for _ in 0..MAX_DRIVE_TICKS {
let mut chunk = [0u8; 256];
match peer.read(&mut chunk) {
Ok(0) => break,
Ok(size) => wire.extend_from_slice(&chunk[..size]),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if wire.len() >= 39 + 24 {
break;
}
std::thread::yield_now();
}
Err(error) => panic!("the peer must read the frontend's records: {error:?}"),
}
}
let mut records = Vec::new();
let mut offset = 0;
while offset + 5 <= wire.len() {
let length = 5 + usize::from(u16::from_be_bytes([wire[offset + 3], wire[offset + 4]]));
records.push(length);
offset += length;
}
assert_eq!(
records,
vec![39, 24],
"the peer must receive the GOAWAY record, then the close_notify record"
);
let mut cursor = std::io::Cursor::new(wire.as_slice());
while (cursor.position() as usize) < wire.len() {
client
.read_tls(&mut cursor)
.expect("the peer must read the frontend's records");
client
.process_new_packets()
.expect("the peer must decrypt the frontend's records");
}
let mut frame = [0u8; 64];
let size = client
.reader()
.read(&mut frame)
.expect("the GOAWAY must be readable before the close");
assert_eq!(size, 17, "the GOAWAY frame is 9 + 8 bytes");
assert_eq!(frame[3], 0x07, "the first plaintext must be a GOAWAY frame");
assert_eq!(
&frame[13..17],
&[0, 0, 0, 0],
"the GOAWAY must carry NO_ERROR"
);
assert_eq!(
client
.reader()
.read(&mut frame)
.expect("a close_notify ends the stream cleanly"),
0,
"the close_notify must follow the GOAWAY"
);
}
/// `FrontRustls` answers a blocked write with bytes taken AND `WouldBlock`,
/// and `update_readiness` calls that pair not-stalled.
///
/// This is the premise every scripted `(size > 0, WouldBlock)` test in this
/// file rests on, taken here from the production handler instead of from a
/// fixture: rustls absorbs plaintext into its record buffer while
/// `write_tls` blocks against the kernel, so `buffered_size` and
/// `can_write` disagree and one return value carries both answers.
///
/// TO SEE THIS RED: in `FrontRustls::socket_write_vectored`, report
/// nothing absorbed on a blocked write — change the `!can_write` arm of
/// the final result from `(buffered_size, SocketResult::WouldBlock)` to
/// `(0, SocketResult::WouldBlock)`. That is the under-reporting half of
/// the same class the over-reporting one below belongs to, and the caller
/// then re-offers bytes rustls already holds. This test fails on `rustls
/// takes plaintext off the caller's hands even while the kernel is full,
/// so the handler must report the bytes it absorbed`. Measured: `1092
/// passed; 6 failed` — every test in this block and nothing else, so the
/// whole class was invisible to the suite.
///
/// Not a red recipe, and measured as such: deleting `can_write = false;`
/// from the `ErrorKind::WouldBlock` arm of the partial-absorb draining
/// loop leaves the entire suite green. The post-loop flush block sets the
/// same flag for the same reason a moment later, so the `WouldBlock` half
/// of this pair is guarded twice in production and no single-site deletion
/// of it is observable here.
#[test]
fn a_real_rustls_frontend_reports_bytes_taken_and_would_block_together() {
let (mut socket, _peer, _client) = handshaken_front_rustls();
let body = queued_response_body();
assert!(
!socket.socket_wants_write(),
"premise: the fixture must start with rustls holding nothing"
);
let (size, status) = socket.socket_write_vectored(&[IoSlice::new(body)]);
assert!(
size > 0,
"rustls takes plaintext off the caller's hands even while the \
kernel is full, so the handler must report the bytes it absorbed"
);
assert!(
size < body.len(),
"premise: the offer must exceed what one call can absorb, or the \
partial-write path that produces this pair is never entered \
(absorbed {size} of {})",
body.len()
);
assert_eq!(
status,
SocketResult::WouldBlock,
"a frontend whose kernel send queue is full must report WouldBlock"
);
assert!(
socket.socket_wants_write(),
"the records rustls could not push must still be waiting, which is \
the only reason any close path has to ask"
);
let mut readiness = Readiness {
event: Ready::WRITABLE,
interest: Ready::WRITABLE | Ready::HUP | Ready::ERROR,
};
assert!(
!update_readiness_after_write(size, status, &mut readiness),
"a write that moved bytes is not a stall however it reported its \
status: a pass that ends here leaves the rest of the response \
queued behind a WouldBlock the rule does not treat as one"
);
}
/// The empty flush the close paths issue really does hand rustls's records
/// to the kernel — `true` before it and `false` after, once the peer reads.
///
/// `socket_write(&[])` is the middle call of every flush triple in
/// `h2.rs`: those sites discard its `(size, status)` entirely and learn
/// whether it landed only by asking `socket_wants_write()` again. That
/// reconstruction is only sound if a zero-length write flushes at all, and
/// `FrontRustls::socket_write`'s main loop exits immediately for an empty
/// buffer — the flush lives in a block after it, reached by no other path.
///
/// TO SEE THIS RED: in `FrontRustls::socket_write`, delete the post-loop
/// `if !is_error && !is_closed && can_write && self.session.wants_write()`
/// block. `socket_write(&[])` becomes a no-op, the records never reach the
/// kernel, and this test fails on `once the peer reads, the empty flush
/// must hand every record to the kernel: rustls still holds records after
/// 4096 flushes against a draining peer`. Measured: `1093 passed; 5
/// failed` — five of the six here and nothing that predates them, so the
/// block that comment calls load-bearing had no unit coverage at all.
#[test]
fn an_empty_flush_drains_the_records_a_blocked_write_left_behind() {
let (mut socket, mut peer, mut client) = handshaken_front_rustls();
let body = queued_response_body();
let (absorbed, _status) = socket.socket_write_vectored(&[IoSlice::new(body)]);
assert!(
socket.socket_wants_write(),
"premise: a blocked kernel must leave records pending, or neither \
arm below is reachable"
);
// The kernel is still full: the flush cannot land and the query
// answers `true` on both sides of it. This is the "true twice" arm.
socket.socket_write(&[]);
assert!(
socket.socket_wants_write(),
"a flush against a full kernel leaves the records where they were, \
so the triple's second query must still answer true"
);
// Now the peer reads, and the same flush must land. This is the
// "true then false" arm.
let mut received = Vec::new();
assert!(
flush_until_drained(&mut socket, &mut client, &mut peer, &mut received),
"once the peer reads, the empty flush must hand every record to \
the kernel: rustls still holds records after {MAX_DRIVE_TICKS} \
flushes against a draining peer"
);
for _ in 0..MAX_DRIVE_TICKS {
if received.len() >= absorbed {
break;
}
std::thread::yield_now();
drain_peer(&mut client, &mut peer, &mut received);
}
assert_eq!(
received.len(),
absorbed,
"every plaintext byte rustls reported absorbing must reach the peer"
);
assert_eq!(
received.as_slice(),
&body[..absorbed],
"the peer must receive those bytes unchanged and in order"
);
}
/// A response larger than one pass can deliver reaches the peer in full,
/// driven through `writable()` over the production TLS handler.
///
/// This is #1454's truncation vector end to end, with no scripted answer
/// anywhere: the `(size > 0, WouldBlock)` and `(0, WouldBlock)` pairs come
/// from `FrontRustls` against a real full kernel, the stall and its resume
/// come from `write_streams`, and the oracle is the bytes a real
/// `rustls::ClientConnection` decrypts on the other end of the socket. The
/// assertion is byte equality, not a call count, so it says nothing about
/// how the write machine is shaped and everything about what it delivers.
///
/// TO SEE THIS RED: in `FrontRustls::socket_write_vectored`, report the
/// full offer instead of what rustls took — replace the final
/// `(buffered_size, SocketResult::WouldBlock)` with
/// `(total_len, SocketResult::WouldBlock)`. That is the over-reporting
/// shape `socket_write`/`socket_write_vectored`'s own comments call the
/// 4.5 MB truncation-class bug: `h2_transmit::confirm` then consumes bytes
/// the socket never encrypted and they are gone. Measured: `1095 passed; 3
/// failed`, this one on `the response was truncated at 65536 of 262144
/// bytes after 4096 passes, with 0 blocks still queued`. The pass empties
/// `kawa.out` against bytes that were never sent, `finalize_write` reads
/// that as everything written and quiesces, and the remaining 196608 bytes
/// have no route to the wire at all — the connection is not short, it is
/// hung, and the tick cap is what converts that into a failure instead of
/// a wait. Nothing that predates this block moves.
#[test]
fn a_blocked_rustls_frontend_delivers_every_queued_byte_across_passes() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut peer, mut client) = rustls_h2_connection(&pool, H2State::Header);
let mut context = test_context(&pool);
let body = queued_response_body();
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
connection
.core
.stream_table
.register(REGISTERED_STREAM_ID, gid, connection.core.now);
for chunk in body.chunks(QUEUED_RESPONSE_CHUNK_LEN) {
context.streams[gid]
.back
.out
.push_back(kawa::OutBlock::Store(kawa::Store::Static(chunk)));
}
assert_prepare_gate_is_shut(&context, gid);
assert_eq!(
connection.core.stream_table.expect_write(),
None,
"premise: no parked write, so the first pass takes the main loop"
);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let mut received = Vec::new();
let mut ticks = 0usize;
loop {
// What the event loop does: report the socket writable, run the
// pass, and let the peer read whatever reached the kernel.
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(&mut context, EndpointClient(&mut router));
std::thread::yield_now();
drain_peer(&mut client, &mut peer, &mut received);
if received.len() >= body.len() {
break;
}
ticks += 1;
assert!(
ticks < MAX_DRIVE_TICKS,
"the response was truncated at {} of {} bytes after {ticks} \
passes, with {} blocks still queued",
received.len(),
body.len(),
context.streams[gid].back.out.len()
);
// Owed, not undelivered: once `kawa.out` is empty and rustls
// holds nothing, every byte has been handed to the kernel and
// `finalize_write` may legitimately quiesce while the last of it
// is still crossing loopback. Asserting on `received` here would
// blame the write machine for the harness's own race.
let still_owed =
!context.streams[gid].back.out.is_empty() || connection.socket.socket_wants_write();
assert!(
!still_owed || connection.core.readiness.interest.is_writable(),
"a pass that still owes bytes must keep WRITABLE interest, or \
no later event reaches this connection and the response is \
truncated at {} of {} bytes with {} blocks queued",
received.len(),
body.len(),
context.streams[gid].back.out.len()
);
}
assert_eq!(
received.len(),
body.len(),
"the peer must receive every queued byte"
);
assert_eq!(
received.as_slice(),
body,
"the peer must receive the queued bytes unchanged and in order"
);
assert!(
context.streams[gid].back.out.is_empty(),
"every queued block must be consumed once the response is delivered"
);
assert_eq!(
context.streams[gid].metrics.bout,
body.len(),
"the stream must account exactly the bytes the socket took"
);
}
/// `force_disconnect`'s server arm, over the handler whose
/// `socket_wants_write()` is the only production implementation that can
/// answer `true`: it re-arms while rustls holds records and closes once
/// they are gone.
///
/// One test for both arms because the TRANSITION is the claim — the same
/// connection, the same records, and the only thing that changes is
/// whether the peer read them. A pair of connections would prove the two
/// answers of a table `h2_close::force_disconnect_action` already tables
/// exhaustively; what has never been proved is that this caller reads a
/// real handler's answer at all.
///
/// TO SEE THIS RED: in `ConnectionH2::force_disconnect`, replace
/// `let tls_wants_write = self.tls_wants_write();` with
/// `let tls_wants_write = false;` — the decision stops reading the socket,
/// and the call closes the session while records are pending, which sends
/// FIN and destroys them. This test fails on `a disconnect with records
/// pending must not close the session: closing sends FIN and destroys the
/// records rustls still holds, which the peer reads as a truncated
/// response`. Measured: `1096 passed; 2 failed` — this test and
/// `force_disconnect_re_arms_while_tls_records_are_pending`, the same
/// decision with one witness over a modelled handler and one over the
/// production one.
#[test]
fn force_disconnect_over_a_real_rustls_frontend_waits_for_the_records_to_drain() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut peer, mut client) = rustls_h2_connection(&pool, H2State::GoAway);
let body = queued_response_body();
let (absorbed, _status) = connection
.socket
.socket_write_vectored(&[IoSlice::new(body)]);
assert!(
absorbed > 0 && connection.socket.socket_wants_write(),
"premise: the blocked kernel must leave real records pending"
);
assert!(
!connection.core.peer_gone_after_final_goaway(),
"premise: the peer must still be live, or the decision \
short-circuits on its first boolean and never reads the socket"
);
assert!(
matches!(connection.force_disconnect(), MuxResult::Continue),
"a disconnect with records pending must not close the session: \
closing sends FIN and destroys the records rustls still holds, \
which the peer reads as a truncated response"
);
assert!(
connection.core.readiness.interest.is_writable(),
"the delayed close must keep WRITABLE interest so the write path \
can still flush"
);
// The records were written straight to the socket, outside any
// writable pass, so no refusal is recorded and the delayed close
// re-raises WRITABLE for the write path to run once more. A write the
// same pass saw refused would leave it to the kernel's edge instead
// (`a_refused_goaway_flush_waits_for_the_kernel_edge_then_closes`).
assert!(
connection.core.readiness.event.is_writable(),
"with no refused write recorded, ensure_tls_flushed must re-signal \
the WRITABLE event so the write path runs again"
);
let mut received = Vec::new();
assert!(
flush_until_drained(
&mut connection.socket,
&mut client,
&mut peer,
&mut received
),
"premise for the second arm: the records must actually drain once \
the peer reads"
);
assert!(
matches!(connection.force_disconnect(), MuxResult::CloseSession),
"with rustls holding nothing, the same call must close: delaying \
further would strand a connection with no reason left to wait"
);
}
/// The `(H2State::Error, Position::Server)` arm of
/// `ConnectionH2::dispatch_writable_state` reads the real handler's
/// POST-flush answer: it keeps the session open while rustls still holds
/// records — leaving WRITABLE to the kernel's edge, since the flush was
/// refused — and closes once they are gone. The test drains the records
/// itself between the two passes (`flush_until_drained`); the second pass
/// only has to see that nothing is left.
///
/// That arm has no flush of its own — `H2Shell::writable`'s preamble
/// already issued this pass's `socket_write(&[])` and discarded both the
/// size and the status it returned, and the `tls_wants_write` the arm is
/// handed is the read after it, so that query is the only thing that can
/// tell this site whether the flush landed. Until now the arm was
/// exercised only as `h2_close::error_close_action`'s pure table; no test
/// reached it through a connection whose handler could answer `true`,
/// because none existed.
///
/// TO SEE THIS RED: in that arm, replace
/// `h2_close::error_close_action(tls_wants_write)` with
/// `h2_close::error_close_action(false)` — the decision stops reading the
/// socket and closes with records pending, sending FIN over plaintext the
/// peer never received. Measured, this test fails on `a pass that finds
/// records still pending after its own flush must not close the session:
/// the close destroys plaintext the peer has not received and it reads the
/// response as truncated`. Measured: `1116 passed; 1 failed` — it is the
/// only test in the crate that moves.
#[test]
fn a_rustls_frontend_in_error_state_stays_open_until_its_records_drain() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut peer, mut client) = rustls_h2_connection(&pool, H2State::Error);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let body = queued_response_body();
let (absorbed, _status) = connection
.socket
.socket_write_vectored(&[IoSlice::new(body)]);
assert!(
absorbed > 0 && connection.socket.socket_wants_write(),
"premise: the blocked kernel must leave real records pending, or \
this arm reads its only input as false and the test proves nothing"
);
connection.core.readiness.event.insert(Ready::WRITABLE);
assert!(
matches!(
connection.writable(&mut context, EndpointClient(&mut router)),
MuxResult::Continue
),
"a pass that finds records still pending after its own flush must \
not close the session: the close destroys plaintext the peer has \
not received and it reads the response as truncated"
);
// The kernel refused the flush: it owes this socket its next
// WRITABLE edge, so no synthetic one is queued (sozu-proxy/sozu#1788).
assert!(
connection.core.readiness.interest.is_writable(),
"the delayed close must keep WRITABLE interest so the kernel edge \
is delivered"
);
assert!(
!connection.core.readiness.event.is_writable(),
"a flush the kernel refused must leave WRITABLE to the kernel's \
next edge, got {:?}",
connection.core.readiness
);
let mut received = Vec::new();
assert!(
flush_until_drained(
&mut connection.socket,
&mut client,
&mut peer,
&mut received
),
"premise for the second arm: the records must actually drain once \
the peer reads"
);
connection.core.readiness.event.insert(Ready::WRITABLE);
assert!(
matches!(
connection.writable(&mut context, EndpointClient(&mut router)),
MuxResult::CloseSession
),
"with rustls holding nothing, the same pass must close: the bytes \
are all in the kernel and waiting longer strands the session"
);
}
/// The `H2State::GoAway` arm of `ConnectionH2::dispatch_writable_state`
/// and the `ConnectionH2::dispatch_writable_state_after_flush` that
/// settles it — the triple whose own comment calls it the primary
/// truncation vector under HAProxy chaining — over the production TLS
/// handler: it keeps the session open while rustls still holds records,
/// leaving WRITABLE to the kernel's edge, and disconnects once they are
/// gone. As in the Error-arm test, the records are drained by the test
/// between the passes, not by the second pass.
///
/// This is the triple #1454 names. Of the three, `finalize_write`'s is
/// driven by `a_blocked_rustls_frontend_delivers_every_queued_byte_across_passes`
/// above and the Error arm by
/// `a_rustls_frontend_in_error_state_stays_open_until_its_records_drain`; this
/// one completes the set. The fourth `socket_write(&[])` site,
/// `flush_output_buffer`, is deliberately not targeted here: it keeps the
/// status its flush returned instead of re-querying, so it is not this
/// shape and a test aimed at it would prove nothing about the vector.
///
/// The state assertion is not decoration. Falling through this arm reaches
/// `force_disconnect`, which has a record guard of its own and answers
/// `MuxResult::Continue` too — so the result alone cannot tell a correct
/// hold from a fall-through that only looks correct. `H2State::Error` is
/// what the fall-through leaves behind, and nothing else sets it here.
///
/// TO SEE THIS RED: in `ConnectionH2::dispatch_writable_state_after_flush`,
/// replace the `h2_close::goaway_close_action(TlsFlushPhase::AfterFlush,
/// false, tls_wants_write)` third argument with `false`. The arm
/// stops reading whether the flush `H2Shell::writable` performed for
/// it landed, falls through to
/// `force_disconnect`, and this test fails on `a GoAway pass whose flush
/// did not land must stay in GoAway`. Measured: `1115 passed; 2 failed` —
/// this test and `a_flush_that_does_not_drain_keeps_the_connection_open`,
/// the same arm with one witness over a modelled handler and one over the
/// production one.
#[test]
fn a_rustls_frontend_in_goaway_stays_open_until_its_records_drain() {
let pool = make_pool_for_invariant_16();
let (mut connection, mut peer, mut client) = rustls_h2_connection(&pool, H2State::GoAway);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let body = queued_response_body();
let (absorbed, _status) = connection
.socket
.socket_write_vectored(&[IoSlice::new(body)]);
assert!(
absorbed > 0 && connection.socket.socket_wants_write(),
"premise: the blocked kernel must leave real records pending"
);
assert!(
!connection.core.peer_gone_after_final_goaway(),
"premise: the peer must still be live, or the decision \
short-circuits on its first boolean and never reads the socket"
);
connection.core.readiness.event.insert(Ready::WRITABLE);
assert!(
matches!(
connection.writable(&mut context, EndpointClient(&mut router)),
MuxResult::Continue
),
"a GoAway pass that finds records still pending after its own \
flush must not close: FIN here destroys plaintext the peer never \
received"
);
assert!(
matches!(connection.core.state, H2State::GoAway),
"a GoAway pass whose flush did not land must stay in GoAway: \
reaching Error means the arm fell through to force_disconnect \
and its own record guard, not this one, kept the session alive"
);
// The kernel refused the flush: it owes this socket its next
// WRITABLE edge, so no synthetic one is queued (sozu-proxy/sozu#1788).
assert!(
!connection.core.readiness.event.is_writable(),
"a flush the kernel refused must leave WRITABLE to the kernel's \
next edge, got {:?}",
connection.core.readiness
);
let mut received = Vec::new();
assert!(
flush_until_drained(
&mut connection.socket,
&mut client,
&mut peer,
&mut received
),
"premise for the second arm: the records must actually drain once \
the peer reads"
);
connection.core.readiness.event.insert(Ready::WRITABLE);
assert!(
matches!(
connection.writable(&mut context, EndpointClient(&mut router)),
MuxResult::CloseSession
),
"with rustls holding nothing, the same pass must disconnect: every \
byte is in the kernel and waiting longer strands the session"
);
}
// ── #1498: a DRIVEN TLS close_notify over a real `FrontRustls` ─────
//
// `H2Shell::begin_tls_close` is the seam that reaches
// `FrontRustls::socket_close`, which is
// `rustls::ServerConnection::send_close_notify`. Replacing its body with
// `{}` at 0dddedf2 left the WHOLE suite green — `sozu-lib` `1114 passed;
// 0 failed`, and no `sozu-e2e` test reddened on the mutation either —
// while the same mutation on either of its two siblings from the same
// extraction reddens at once. Measured against this branch's own `1115
// passed` baseline: `tls_wants_write() -> false` gives `1104 passed; 11
// failed` and a no-op `flush_tls_records()` gives `1110 passed; 5
// failed`, ten and four of those respectively being tests OTHER than this
// one — which is the asymmetry the whole file turns on. #1484's
// coverage audit reached the same conclusion from the other direction,
// listing `initiate_close_notify` among the `socket_wants_write` branches
// its six `H2Shell<FrontRustls>` tests could not reach, for want of a
// driven close_notify.
//
// The hole is structural rather than an oversight. Every handler the suite
// drives a close over — `mio::net::TcpStream` and
// `BackpressuredTlsSocket` — inherits `SocketHandler::socket_close`'s EMPTY
// trait default, so to all of them the real seam and a no-op are the same
// thing. `FrontRustls` is the only implementation in the tree that
// overrides it, which is why the test below has to build one.
//
// And the assertion has to be on the ALERT, not on a FIN. That distinction
// is the whole point of the alert: rustls answers `Ok(0)` from its reader
// for a stream closed WITH a close_notify and
// `UnexpectedEof("peer closed connection without sending TLS
// close_notify")` for one closed without, so only the alert lets a peer
// tell a complete response from a connection cut mid-stream. A test that
// asserted a FIN had arrived would pass against the no-op mutant, which
// still closes the socket.
/// The frontend idle timeout on a server H2 connection makes the peer
/// receive a TLS close_notify alert.
///
/// The route is the production one and the whole of it:
/// `Mux::timeout` -> `Mux::delay_close_for_frontend_flush` ->
/// `Connection::initiate_close_notify` ->
/// `H2Shell::initiate_close_notify` -> `begin_tls_close` ->
/// `FrontRustls::socket_close` -> `send_close_notify`.
///
/// `Mux::delay_close_for_frontend_flush` is the SOLE caller of
/// `Connection::initiate_close_notify`, and it is itself called from four
/// sites in `Mux::ready_inner` and this one in `Mux::timeout_inner`.
/// `Mux::shutting_down` is not among them: it drives frontend I/O and can
/// flush records the alert has already generated, but it never asks for
/// one. The timeout is chosen over the four `ready_inner` sites only
/// because they need a `dyn ProxySession` and a `dyn L7Proxy`; the seam
/// under test is the same on all five.
///
/// The flush is `Connection::flush_output_buffer` — the same call
/// `Mux::shutting_down_inner` makes for exactly this purpose, on a
/// connection whose output queue is empty so nothing but the alert reaches
/// the wire. `FrontRustls::socket_write(&[])` takes its "flush pending TLS
/// records even if no application data was written" block, which is what
/// hands the record to `write_tls`.
///
/// TO SEE THIS RED: replace the body of `H2Shell::begin_tls_close`
/// with `{}`. rustls is then never asked for the alert, `tls_wants_write()`
/// answers false, `initiate_close_notify` returns false and the flush has
/// nothing to push, so this test fails on `the peer must decrypt a TLS
/// close_notify alert`. Measured: `1114 passed; 1 failed` — this test and
/// nothing else, which is what a hole this complete looks like once it has
/// exactly one witness. Note `close_notify_sent` is still set by the
/// mutant, so no assertion on that flag could discriminate — only the
/// bytes the peer receives can.
#[test]
fn a_frontend_timeout_makes_the_peer_receive_a_tls_close_notify() {
use std::io::Read as _;
use crate::{StateResult, protocol::SessionState};
let pool = make_pool_for_invariant_16();
let (mut connection, mut peer, mut client) = rustls_h2_connection(&pool, H2State::Header);
// `Mux::consume_timer_entry` re-validates the wheel entry against the
// core's own deadline and returns `Continue` having done NOTHING when
// that deadline is still in the future. `ConnectionH2::new` arms one a
// full `timeout_duration` out, so move it into the past: this is the
// idle deadline genuinely elapsing, which is what the event loop would
// be reacting to.
connection.core.timeout_deadline = Some(Instant::now() - Duration::from_secs(60));
let frontend_token = mio::Token(0);
let mut mux = crate::protocol::mux::Mux {
configured_frontend_timeout: Duration::from_secs(30),
frontend_token,
frontend: crate::protocol::mux::Connection::H2(connection),
router: Router::new(Duration::from_secs(30), Duration::from_secs(30)),
// No streams at all, so the per-stream loop leaves `should_close`
// true and `should_write` false, which is the only combination
// that reaches `delay_close_for_frontend_flush`.
context: test_context(&pool),
session_ulid: Ulid::generate(),
timeouts: crate::protocol::mux::MuxTimeouts::new(
crate::timer::TimeoutContainer::new_empty(Duration::from_secs(30)),
),
backend_registry: crate::protocol::mux::BackendRegistry::default(),
backends: Rc::default(),
};
let mut metrics = SessionMetrics::new(None);
{
let crate::protocol::mux::Connection::H2(h2) = &mux.frontend else {
unreachable!("the frontend was built as H2")
};
assert!(
!h2.socket.socket_wants_write(),
"premise: the fixture must start with rustls holding nothing, \
or the records this test attributes to the alert could be the \
handshake's"
);
}
assert!(
!client
.process_new_packets()
.expect("the peer must decrypt what the frontend wrote")
.peer_has_closed(),
"premise: the peer must not have seen a close before the timeout \
runs, or the closing assertion is reading a state it inherited"
);
let result = mux.timeout(frontend_token, &mut metrics);
let mut received = Vec::new();
let mut peer_saw_close_notify = false;
for _ in 0..MAX_DRIVE_TICKS {
mux.frontend.flush_output_buffer();
// Loopback delivery is softirq-driven, not synchronous with
// `write_tls`: give it a slot rather than spinning the CPU.
std::thread::yield_now();
drain_peer(&mut client, &mut peer, &mut received);
peer_saw_close_notify = client
.process_new_packets()
.expect("the peer must decrypt what the frontend wrote")
.peer_has_closed();
if peer_saw_close_notify {
break;
}
}
assert!(
peer_saw_close_notify,
"the peer must decrypt a TLS close_notify alert: without it a \
client cannot tell this orderly shutdown from a stream truncated \
mid-response, and reports a protocol error instead of a clean end"
);
let mut sentinel = [0u8; 1];
assert!(
matches!(client.reader().read(&mut sentinel), Ok(0)),
"the alert must be what ended the peer's stream, stated in \
rustls's own vocabulary: its reader answers Ok(0) only for a \
stream closed with a close_notify, UnexpectedEof for a bare FIN \
and WouldBlock for neither — so this is the assertion a FIN \
cannot satisfy"
);
assert!(
received.is_empty(),
"nothing but the alert may reach the peer on this path: decrypted \
plaintext here would mean the connection also wrote a frame after \
asking rustls to close, which RFC 8446 §6.1 has the peer discard \
({} bytes)",
received.len()
);
assert_eq!(
result,
StateResult::Continue,
"a timeout that queued an alert must keep the session alive until \
the records drain: closing here sends FIN over the very bytes the \
close_notify was generated to deliver"
);
}
// ── Connection-level idle deadline: what counts as liveness ────────
//
// LIFECYCLE §9 invariant 9 says the connection timer resets only on
// application activity, and `ConnectionH2::poll_read_target` spells out
// the reason in prose: a peer sending periodic PINGs must not prevent
// timeout detection on a stuck session. The READ side honoured it. The
// WRITE side did not, because `H2Shell::write_streams` armed at pass
// entry and `writable()` reaches that function on every proxying-state
// pass — including the pass whose only output was the PING or SETTINGS
// acknowledgement `flush_pending_control_frames` had just drained. So the
// rule held in one direction and was undone in the other, for exactly the
// frame types it names (sozu-proxy/sozu#1489).
//
// Every test below pairs the negative with a POSITIVE leg on the same
// connection. A test that only asserted "the deadline did not move" would
// pass just as well against a build that never armed at all, which is the
// opposite defect and a worse one: it turns the frontend timeout into an
// unconditional session cap.
/// Long enough that the per-stream idle deadline — a separate mechanism,
/// deliberately untouched here — cannot retire the registered stream part
/// way through a measurement and change what the later rows are measuring.
const LIVENESS_TIMEOUT: Duration = Duration::from_secs(600);
/// A server connection whose frontend timeout and per-stream idle cap are
/// both [`LIVENESS_TIMEOUT`], with ONE registered stream holding no queued
/// response yet, plus the pass-zero instant every assertion below is
/// expressed against.
///
/// The stream is registered directly rather than opened with a HEADERS
/// frame, and it starts EMPTY on purpose: a stream with queued output
/// would let the write pass that flushes a PING ACK also flush that
/// output, and arm the deadline for a reason the negative leg is not
/// trying to measure.
fn liveness_fixture(
pool: &Rc<RefCell<Pool>>,
) -> (
H2Shell<BackpressuredTlsSocket>,
Context<TestListener>,
GlobalStreamId,
Instant,
std::net::TcpStream,
) {
let (mut connection, peer) = connection_with_backpressure(pool, 0, 0, H2State::Header);
let t0 = connection.core.now;
connection.core.timeout_duration = LIVENESS_TIMEOUT;
connection.core.stream_idle_timeout = LIVENESS_TIMEOUT;
connection.core.timeout_deadline = t0.checked_add(LIVENESS_TIMEOUT);
connection
.core
.stream_table
.set_expect_read(Some((H2StreamId::Zero, 9)));
let mut context = test_context(pool);
let gid = context
.create_stream(Ulid::generate(), 1 << 16)
.expect("test context must create a stream");
connection
.core
.stream_table
.register(REGISTERED_STREAM_ID, gid, connection.core.now);
(connection, context, gid, t0, peer)
}
/// A non-ACK PING. `ConnectionH2::handle_ping_frame` answers it by
/// serialising a PING ACK into `self.zero` and parking
/// `expect_write(Some(H2StreamId::Zero))`, which is what gives the write
/// path something to send.
fn liveness_ping_frame() -> Vec<u8> {
let mut frame = vec![0, 0, 8, 6, 0, 0, 0, 0, 0];
frame.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
frame
}
/// A non-ACK SETTINGS carrying no entry.
/// `ConnectionH2::handle_settings_frame` answers it with
/// `serializer::SETTINGS_ACKNOWLEDGEMENT`, parking the same way.
fn liveness_settings_frame() -> Vec<u8> {
vec![0, 0, 0, 4, 0, 0, 0, 0, 0]
}
/// A connection-level WINDOW_UPDATE. The control frame that queues NO
/// acknowledgement, and therefore the one the pre-image already handled
/// correctly — it is here so the fix is shown not to have moved it.
fn liveness_window_update_frame() -> Vec<u8> {
let mut frame = vec![0, 0, 4, 8, 0, 0, 0, 0, 0];
frame.extend_from_slice(&1024u32.to_be_bytes());
frame
}
/// A HEADERS frame opening `stream_id`, END_STREAM | END_HEADERS.
fn liveness_headers_frame(stream_id: u32) -> Vec<u8> {
let mut encoder = crate::protocol::mux::hpack::Encoder::new();
let block = encoder.encode([
(&b":method"[..], &b"GET"[..]),
(&b":scheme"[..], &b"https"[..]),
(&b":authority"[..], &b"example.com"[..]),
(&b":path"[..], &b"/"[..]),
]);
let mut frame = Vec::with_capacity(9 + block.len());
frame.extend_from_slice(&(block.len() as u32).to_be_bytes()[1..]);
frame.push(1);
frame.push(parser::FLAG_END_STREAM | parser::FLAG_END_HEADERS);
frame.extend_from_slice(&stream_id.to_be_bytes());
frame.extend_from_slice(&block);
frame
}
/// Spin until `n` bytes the peer wrote have reached the connection's
/// socket.
///
/// Not belt-and-braces: without it the read loop below can quiesce on a
/// `WouldBlock` that only means "not yet", and every assertion downstream
/// then measures a frame that was never delivered — which is a test that
/// passes for the wrong reason, in the direction of the negative legs.
fn liveness_await_bytes(connection: &H2Shell<BackpressuredTlsSocket>, n: usize) {
let mut buf = vec![0u8; n];
for _ in 0..1_000_000 {
if let Ok(got) = connection.socket.socket_ref().peek(&mut buf)
&& got >= n
{
return;
}
std::thread::yield_now();
}
panic!("the peer's {n} bytes never reached the connection socket");
}
/// Deliver `frame` at `at` and run the READ half only, the way
/// `Mux::ready_inner` runs it: `readable()` while `filter_interest()` is
/// readable.
///
/// Stopping before the write half is what lets a test assert the premise —
/// that the acknowledgement really was queued — instead of inferring it
/// from the deadline it is about to measure.
fn liveness_read(
connection: &mut H2Shell<BackpressuredTlsSocket>,
context: &mut Context<TestListener>,
router: &mut Router,
peer: &mut std::net::TcpStream,
at: Instant,
frame: &[u8],
) {
context.now = at;
peer.write_all(frame).expect("loopback write must complete");
peer.flush().expect("loopback flush must complete");
liveness_await_bytes(connection, frame.len());
connection.core.readiness.interest.insert(Ready::READABLE);
connection.core.readiness.event.insert(Ready::READABLE);
for _ in 0..64 {
if !connection.core.readiness.filter_interest().is_readable() {
let mut leftover = [0u8; 1];
assert!(
!matches!(connection.socket.socket_ref().peek(&mut leftover), Ok(1)),
"the read half stopped with the frame still unread, so \
nothing below is measuring the frame it names"
);
return;
}
connection.readable(context, EndpointClient(&mut *router));
}
panic!(
"the read half never settled: {:?}",
connection.core.readiness
);
}
/// Run the WRITE half at the clock the read half left: `writable()` while
/// `filter_interest()` is writable. This is the pass that used to arm the
/// frontend deadline unconditionally.
fn liveness_write(
connection: &mut H2Shell<BackpressuredTlsSocket>,
context: &mut Context<TestListener>,
router: &mut Router,
) {
for _ in 0..64 {
if !connection.core.readiness.filter_interest().is_writable() {
return;
}
connection.writable(context, EndpointClient(&mut *router));
}
panic!(
"the write half never settled: {:?}",
connection.core.readiness
);
}
/// Queue `blocks` as the registered stream's response and drive ONE
/// `writable()` pass at `at`. Returns the bytes that reached the socket
/// across the whole connection's lifetime so far.
fn liveness_flush_stream(
connection: &mut H2Shell<BackpressuredTlsSocket>,
context: &mut Context<TestListener>,
router: &mut Router,
gid: GlobalStreamId,
at: Instant,
blocks: &[&'static [u8]],
) -> usize {
for block in blocks {
context.streams[gid]
.back
.out
.push_back(kawa::OutBlock::Store(kawa::Store::Static(block)));
}
context.now = at;
connection.core.readiness.interest.insert(Ready::WRITABLE);
connection.core.readiness.event.insert(Ready::WRITABLE);
connection.writable(context, EndpointClient(&mut *router));
context.streams[gid].metrics.bout
}
/// The deadline a connection publishes, as an offset from pass zero, so a
/// failure reads in the units the issue's table is written in.
fn liveness_deadline(connection: &H2Shell<BackpressuredTlsSocket>, t0: Instant) -> Duration {
connection
.core
.poll_timeout()
.expect("a live H2 connection must publish a deadline")
.saturating_duration_since(t0)
}
/// A peer that sends nothing but PINGs must not hold a stuck session open.
///
/// The PING is answered — `handle_ping_frame` parks the ACK on
/// `expect_write` and arms WRITABLE — so the write path genuinely runs and
/// genuinely has bytes to emit. What it must not do is read that as the
/// peer being alive on a stream: the ACK is the proxy talking to itself.
///
/// TO SEE THIS RED: put `self.arm_timeout();` back as the first statement
/// of `H2Shell::write_streams`. Measured on that revert, this test
/// fails on `a PING is answered but is not stream activity`, with
/// `left: 610s, right: 600.01s` — the deadline follows the PING instead of
/// staying where the request left it. It is one of FOUR failures, the
/// siblings being the three tests below; every other test in the suite
/// passes.
///
/// Neutering the OTHER half instead — `if size > 0` to `if false` in
/// `ConnectionH2::handle_write` — reddens this test too, on its positive
/// leg (`left: 600.01s, right: 1130s`). That is the pair that makes it a
/// measurement: neither a build that always arms nor a build that never
/// arms can satisfy it.
#[test]
fn a_ping_only_peer_does_not_postpone_the_connection_deadline() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 8, 16384)));
let (mut connection, mut context, gid, t0, mut peer) = liveness_fixture(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
assert_eq!(
liveness_deadline(&connection, t0),
LIVENESS_TIMEOUT,
"premise: the connection starts one full timeout out from pass zero"
);
// The request. The READ side already honoured the rule, and this leg
// is the control for it: a build that stopped arming on HEADERS would
// make every "did not move" assertion below true for the wrong reason.
let request_at = Duration::from_millis(10);
liveness_read(
&mut connection,
&mut context,
&mut router,
&mut peer,
t0 + request_at,
&liveness_headers_frame(3),
);
liveness_write(&mut connection, &mut context, &mut router);
assert_eq!(
liveness_deadline(&connection, t0),
request_at + LIVENESS_TIMEOUT,
"HEADERS is application activity and must arm the deadline: \
without this leg the assertions below cannot tell a fix from a \
build that never arms at all"
);
// The defect. Two PINGs, 490 seconds apart, either of which used to
// renew the session on its own.
for ping_at in [Duration::from_secs(10), Duration::from_secs(500)] {
liveness_read(
&mut connection,
&mut context,
&mut router,
&mut peer,
t0 + ping_at,
&liveness_ping_frame(),
);
assert!(
!connection.core.output.is_empty(),
"premise: the PING must really have been answered and queued, \
so the write pass below is the ACK-only pass this test is \
about and not a no-op"
);
liveness_write(&mut connection, &mut context, &mut router);
assert!(
connection.core.output.is_empty(),
"premise: the ACK flush must have completed, which is what \
lets `flush_pending_control_frames` answer Proceed and fall \
through into the proxying-state write pass"
);
assert_eq!(
liveness_deadline(&connection, t0),
request_at + LIVENESS_TIMEOUT,
"a PING is answered but is not stream activity: the deadline \
must stay where the request left it, or a peer sending \
periodic PINGs holds a stuck session open indefinitely"
);
}
// A WINDOW_UPDATE queues no acknowledgement, so the pre-image already
// left it alone. Pinned so the fix is shown not to have moved it.
liveness_read(
&mut connection,
&mut context,
&mut router,
&mut peer,
t0 + Duration::from_secs(520),
&liveness_window_update_frame(),
);
liveness_write(&mut connection, &mut context, &mut router);
assert_eq!(
liveness_deadline(&connection, t0),
request_at + LIVENESS_TIMEOUT,
"a WINDOW_UPDATE queues nothing and must not arm the deadline"
);
// The positive leg, on the same connection: outbound application data
// IS activity. Without it every assertion above is satisfied by a
// build that never arms the deadline at all.
let response_at = Duration::from_secs(530);
let written = liveness_flush_stream(
&mut connection,
&mut context,
&mut router,
gid,
t0 + response_at,
&[FIRST_BLOCK, SECOND_BLOCK],
);
assert_eq!(
written, TOTAL_QUEUED,
"premise for the positive leg: the pass must really have moved the \
response bytes, or the deadline below moved without stream data"
);
assert_eq!(
liveness_deadline(&connection, t0),
response_at + LIVENESS_TIMEOUT,
"a write pass that moved a stream's bytes IS liveness and must arm \
the deadline: the fix withdraws arming from acknowledgement-only \
passes, not from the write path"
);
}
/// The same contract for SETTINGS, the other control frame that queues an
/// acknowledgement.
///
/// Not a duplicate of the PING test: `handle_settings_frame` reaches the
/// park through an entirely different body — the per-identifier apply
/// loop, the `pending_table_size_update` signal and
/// `serializer::SETTINGS_ACKNOWLEDGEMENT` rather than
/// `serializer::gen_ping_acknowledgement` — and the issue's table measured
/// both moving independently.
///
/// TO SEE THIS RED: put `self.arm_timeout();` back as the first statement
/// of `H2Shell::write_streams`. Measured on that revert, this test
/// fails on `a SETTINGS ACK is not stream activity`, with
/// `left: 640s, right: 600.01s`. Neutering the other half instead —
/// `if size > 0` to `if false` in `ConnectionH2::handle_write` — reddens
/// it on its positive leg, `left: 600.01s, right: 650s`.
#[test]
fn a_settings_only_peer_does_not_postpone_the_connection_deadline() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 8, 16384)));
let (mut connection, mut context, gid, t0, mut peer) = liveness_fixture(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let request_at = Duration::from_millis(10);
liveness_read(
&mut connection,
&mut context,
&mut router,
&mut peer,
t0 + request_at,
&liveness_headers_frame(3),
);
liveness_write(&mut connection, &mut context, &mut router);
assert_eq!(
liveness_deadline(&connection, t0),
request_at + LIVENESS_TIMEOUT,
"premise: the request armed the deadline, so a later move is \
attributable to the frame that caused it"
);
liveness_read(
&mut connection,
&mut context,
&mut router,
&mut peer,
t0 + Duration::from_secs(40),
&liveness_settings_frame(),
);
assert!(
!connection.core.output.is_empty(),
"premise: the SETTINGS must really have been acknowledged and \
queued, so the write pass below is the ACK-only pass"
);
liveness_write(&mut connection, &mut context, &mut router);
assert!(
connection.core.output.is_empty(),
"premise: the ACK flush must have completed and fallen through \
into the proxying-state write pass"
);
assert_eq!(
liveness_deadline(&connection, t0),
request_at + LIVENESS_TIMEOUT,
"a SETTINGS ACK is not stream activity: the deadline must stay \
where the request left it"
);
let response_at = Duration::from_secs(50);
let written = liveness_flush_stream(
&mut connection,
&mut context,
&mut router,
gid,
t0 + response_at,
&[FIRST_BLOCK, SECOND_BLOCK],
);
assert_eq!(
written, TOTAL_QUEUED,
"premise for the positive leg: the pass must really have moved the \
response bytes"
);
assert_eq!(
liveness_deadline(&connection, t0),
response_at + LIVENESS_TIMEOUT,
"outbound application data must still arm the deadline"
);
}
/// A write pass that emits a stream's bytes arms the deadline; the pass
/// before it, which had nothing queued, does not.
///
/// The PING and SETTINGS tests above each carry a positive leg, so this
/// one is not the only guard against a build that never arms. What it adds
/// is the pair measured on ONE connection with NOTHING but the queued
/// response changing between the two passes: no frame is read, so neither
/// `handle_headers_frame` nor `poll_read_target`'s DATA arm can be what
/// moved the deadline, and the write path is the only remaining candidate.
///
/// TO SEE THIS RED: change the `if size > 0` gate in
/// `ConnectionH2::handle_write` to `if false`. The empty-pass assertion
/// still holds and this test then fails on `a write pass that moved a
/// stream's bytes must arm the deadline`, with `left: 600s, right: 620s`.
/// Restoring `self.arm_timeout()` to the top of
/// `H2Shell::write_streams` reddens the OTHER assertion instead, `a
/// write pass that emitted nothing is not liveness`, with
/// `left: 610s, right: 600s`.
#[test]
fn a_write_pass_that_moves_stream_bytes_arms_the_connection_deadline() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 8, 16384)));
let (mut connection, mut context, gid, t0, _peer) = liveness_fixture(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
// A write pass with nothing queued: the control that makes the
// assertion below a measurement of the bytes and not of the pass.
let empty_at = Duration::from_secs(10);
let written = liveness_flush_stream(
&mut connection,
&mut context,
&mut router,
gid,
t0 + empty_at,
&[],
);
assert_eq!(
written, 0,
"premise: a pass with nothing queued moves no byte"
);
assert_eq!(
liveness_deadline(&connection, t0),
LIVENESS_TIMEOUT,
"a write pass that emitted nothing is not liveness"
);
let response_at = Duration::from_secs(20);
let written = liveness_flush_stream(
&mut connection,
&mut context,
&mut router,
gid,
t0 + response_at,
&[FIRST_BLOCK, SECOND_BLOCK],
);
assert_eq!(
written, TOTAL_QUEUED,
"premise: the pass must really have moved the response bytes"
);
assert_eq!(
liveness_deadline(&connection, t0),
response_at + LIVENESS_TIMEOUT,
"a write pass that moved a stream's bytes must arm the deadline"
);
}
/// Draining changes nothing about what counts as liveness.
///
/// `Mux::drive_frontend_shutdown_io` sets `force_h2_write` unconditionally
/// for an H2 frontend, so a draining connection still in `H2State::Header`
/// reaches `H2Shell::write_streams` on every `shutting_down()` poll —
/// which is why arming at pass entry made a draining session immune to the
/// frontend timeout for the whole drain, whether or not a byte moved. The
/// drain keeps its deadline the same way every other pass does: by
/// flushing a stream's bytes.
///
/// TO SEE THIS RED: change the `if size > 0` gate in
/// `ConnectionH2::handle_write` to `if false`. This test then fails on `a
/// draining pass that flushed a stream's bytes must arm the deadline`,
/// with `left: 600s, right: 620s`. Restoring `self.arm_timeout()` to the
/// top of `H2Shell::write_streams` reddens the other assertion, `a
/// draining pass that emitted nothing is not liveness either`, with
/// `left: 610s, right: 600s`.
#[test]
fn a_draining_write_pass_that_moves_stream_bytes_still_arms_the_deadline() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 8, 16384)));
let (mut connection, mut context, gid, t0, _peer) = liveness_fixture(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
connection.core.drain.__test_set_draining();
let empty_at = Duration::from_secs(10);
let written = liveness_flush_stream(
&mut connection,
&mut context,
&mut router,
gid,
t0 + empty_at,
&[],
);
assert_eq!(
written, 0,
"premise: a draining pass with nothing queued moves no byte"
);
assert_eq!(
liveness_deadline(&connection, t0),
LIVENESS_TIMEOUT,
"a draining pass that emitted nothing is not liveness either: \
`drive_frontend_shutdown_io` forces a write pass on every \
shutting-down poll, so arming here renews the session for free"
);
let response_at = Duration::from_secs(20);
let written = liveness_flush_stream(
&mut connection,
&mut context,
&mut router,
gid,
t0 + response_at,
&[FIRST_BLOCK, SECOND_BLOCK],
);
assert_eq!(
written, TOTAL_QUEUED,
"premise: the draining pass must really have moved the response \
bytes, so the drain has time to finish delivering them"
);
assert_eq!(
liveness_deadline(&connection, t0),
response_at + LIVENESS_TIMEOUT,
"a draining pass that flushed a stream's bytes must arm the \
deadline: the drain has to be able to finish delivering a \
response the peer is still reading"
);
}
// ── LIFECYCLE §9 invariant 14 — the backend accounting ledger ────────
//
// Invariant 14 is a balance: every stream removed outside
// `Connection::end_stream` must release the `active_requests` charge its
// creation took. Before #1340's Question 12 that balance was spread over
// six sites, each reaching a `Rc<RefCell<Backend>>` through
// `Position::Client` and mutating it in place, and nothing could observe
// the pair — a missed decrement showed up as load-balancing drift in
// production, days later.
//
// The core now emits `BackendDelta`s and the embedder performs them, so
// the balance is a property of one observable list. These tests read that
// list directly, and then apply it to a real `Backend` and assert the
// counter returns to where it started, which is the invariant itself
// rather than a proxy for it.
/// Sum a ledger into the net `active_requests` change it describes, and
/// the number of connection releases it carries.
fn reconcile(deltas: &[BackendDelta]) -> (i64, usize) {
let mut net = 0i64;
let mut closed = 0usize;
for delta in deltas {
match delta.change {
BackendChange::StreamsStarted(count) => net += count as i64,
BackendChange::StreamsEnded(count) => net -= count as i64,
BackendChange::ConnectionClosed => closed += 1,
// Only `Mux::dial_backend` emits it, and no scenario here
// dials: an H2 connection's own paths never release a
// reservation.
BackendChange::DialFailed(_) => {
unreachable!("no invariant-14 scenario dials a backend")
}
}
}
(net, closed)
}
/// Everything one of these scenarios needs: a live backend connection in
/// `Connected`, the registry entry behind it, and the peer that keeps the
/// loopback socket open.
struct LedgerFixture {
connection: Connection<SessionTcpStream>,
registry: crate::protocol::mux::BackendRegistry,
backend: Rc<RefCell<crate::backends::Backend>>,
_peer: std::net::TcpStream,
}
fn ledger_fixture(pool: &Rc<RefCell<Pool>>) -> LedgerFixture {
let (socket, peer) = connected_socket();
let address: std::net::SocketAddr =
"127.0.0.1:2".parse().expect("backend address must parse");
let backend = Rc::new(RefCell::new(crate::backends::Backend::new(
"ledger-backend",
address,
None,
None,
None,
)));
let mut registry = crate::protocol::mux::BackendRegistry::default();
let backend_id = registry.id_for(&backend);
let session_ulid = Ulid::generate();
let mut connection = Connection::new_h2_client(
session_ulid,
SessionTcpStream::new(socket, session_ulid, Some(address)),
"ledger-cluster".into(),
backend_id,
&mut PoolBufferSource::new(Rc::downgrade(pool)),
Duration::from_secs(30),
H2FloodConfig::default(),
H2ConnectionConfig::default(),
Duration::from_secs(30),
None,
)
.expect("the test pool must hand out a buffer for the H2 backend");
// `new_h2_client` opens in `Connecting`, where the charge is taken by
// the embedder at the transition rather than by `start_stream`. Move
// it the way a completed dial does, so these scenarios exercise the
// `Connected` guard the three reset paths read.
if let Position::Client(_, _, status) = connection.position_mut() {
*status = BackendStatus::Connected;
}
LedgerFixture {
connection,
registry,
backend,
_peer: peer,
}
}
/// Open `count` streams on the backend through the production path, and
/// return their global ids.
fn open_streams(
fixture: &mut LedgerFixture,
context: &mut Context<TestListener>,
count: usize,
) -> Vec<GlobalStreamId> {
(0..count)
.map(|_| {
let gid = context
.create_stream(Ulid::generate(), 65_535)
.expect("the test pool must hand out stream buffers");
assert!(
fixture.connection.start_stream(gid, context),
"the backend must accept the stream this scenario opens"
);
gid
})
.collect()
}
/// Apply a ledger and read the counter it leaves behind.
fn settle(fixture: &mut LedgerFixture, context: &mut Context<TestListener>) -> usize {
let backends = RefCell::new(crate::backends::BackendMap::with_seed(0));
for delta in std::mem::take(&mut context.backend_deltas) {
fixture.registry.apply(delta, &backends);
}
fixture.backend.borrow().active_requests
}
/// The nominal path: what `Connection::start_stream` charges,
/// `Connection::end_stream` releases, and the close releases the
/// connection slot exactly once.
#[test]
fn invariant_14_start_and_end_stream_balance() {
let pool = make_pool_for_invariant_16();
let mut context = test_context(&pool);
let mut fixture = ledger_fixture(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let gids = open_streams(&mut fixture, &mut context, 3);
assert_eq!(
reconcile(&context.backend_deltas),
(3, 0),
"three started streams must charge exactly three"
);
for gid in gids {
fixture.connection.end_stream(gid, &mut context);
}
fixture
.connection
.close(&mut context, EndpointClient(&mut router));
assert_eq!(
reconcile(&context.backend_deltas),
(0, 1),
"every charge must be released, and the connection released once"
);
assert_eq!(
settle(&mut fixture, &mut context),
0,
"applying the ledger must return active_requests to zero"
);
}
/// A refused `start_stream` must charge nothing at all — not charge and
/// then correctly undo. The rollback that used to sit here is the shape
/// this asserts is gone: a ledger with a `+1` and a `-1` in it would also
/// reconcile to zero, so the assertion is on the list being EMPTY.
#[test]
fn invariant_14_a_refused_start_stream_charges_nothing() {
let pool = make_pool_for_invariant_16();
let mut context = test_context(&pool);
let mut fixture = ledger_fixture(&pool);
// Drive the peer's MAX_CONCURRENT_STREAMS to zero so the very next
// `start_stream` is refused by RFC 9113 §5.1.2's check.
let Connection::H2(shell) = &mut fixture.connection else {
unreachable!("the fixture was built as H2");
};
shell.core.peer_settings.settings_max_concurrent_streams = 0;
let gid = context
.create_stream(Ulid::generate(), 65_535)
.expect("the test pool must hand out stream buffers");
assert!(
!fixture.connection.start_stream(gid, &mut context),
"premise: the backend must refuse this stream"
);
assert!(
context.backend_deltas.is_empty(),
"a refused start_stream must leave no entry in the ledger at all, \
not a charge and a compensating release: got {:?}",
context.backend_deltas
);
assert_eq!(
settle(&mut fixture, &mut context),
0,
"a refused start_stream must leave active_requests untouched"
);
}
/// An inbound `RST_STREAM` removes the stream without going through
/// `Connection::end_stream`, so it owes the release itself.
#[test]
fn invariant_14_an_inbound_rst_stream_releases_its_charge() {
let pool = make_pool_for_invariant_16();
let mut context = test_context(&pool);
let mut fixture = ledger_fixture(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let _gids = open_streams(&mut fixture, &mut context, 2);
let Connection::H2(shell) = &mut fixture.connection else {
unreachable!("the fixture was built as H2");
};
let reset_id = *shell
.core
.stream_table
.streams()
.keys()
.next()
.expect("the scenario opened two streams");
shell.core.handle_rst_stream_frame(
parser::RstStream {
stream_id: reset_id,
error_code: H2Error::Cancel as u32,
},
&mut context,
EndpointClient(&mut router),
);
assert_eq!(
reconcile(&context.backend_deltas),
(1, 0),
"the reset stream must release its charge, and only its own"
);
assert_eq!(
settle(&mut fixture, &mut context),
1,
"one of the two streams is gone, so exactly one charge remains"
);
}
/// On a backend connection (`Position::Client`) a `RST_STREAM` the backend
/// sends before its response starts — typically `REFUSED_STREAM` under
/// load — is not the Rapid Reset signature: Sōzu opened the stream and the
/// reset costs it nothing. Three hundred of them, sixty at a time, must
/// not trip the pre-response reset cap.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_rst_stream_frame`, drop the
/// `self.position.is_client()` term from `response_started`. Every reset
/// then counts as pre-response, and with one reset per opened stream the
/// 251st crosses `h2_max_rst_stream_abusive_lifetime`.
#[test]
fn backend_resets_before_the_response_are_not_rapid_reset() {
const ROUNDS: usize = 5;
const PER_ROUND: usize = 60;
let pool = Rc::new(RefCell::new(Pool::with_capacity(
4,
2 * ROUNDS * PER_ROUND + 8,
16_384,
)));
let mut context = test_context(&pool);
let mut fixture = ledger_fixture(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
for round in 0..ROUNDS {
let _gids = open_streams(&mut fixture, &mut context, PER_ROUND);
let Connection::H2(shell) = &mut fixture.connection else {
unreachable!("the fixture was built as H2");
};
let open: Vec<StreamId> = shell.core.stream_table.streams().keys().copied().collect();
assert_eq!(open.len(), PER_ROUND, "round {round}: premise");
for stream_id in open {
let _ = shell.core.handle_rst_stream_frame(
parser::RstStream {
stream_id,
error_code: H2Error::RefusedStream as u32,
},
&mut context,
EndpointClient(&mut router),
);
assert!(
!matches!(shell.core.state, H2State::GoAway | H2State::Error),
"round {round}: backend reset of stream {stream_id} must not send GOAWAY"
);
}
}
}
// ── RST caps: what a frontend reset counts toward ───────────────────
/// A frontend H2 connection whose received-RST lifetime cap has the floor
/// `lifetime_floor`, every other threshold at its default, so the
/// scenarios below reach the cap in a handful of resets instead of
/// 200 000.
fn frontend_rst_fixture(
lifetime_floor: u64,
) -> (
Rc<RefCell<Pool>>,
H2Shell<mio::net::TcpStream>,
std::net::TcpStream,
Context<TestListener>,
Router,
) {
let pool = Rc::new(RefCell::new(Pool::with_capacity(8, 512, 16_384)));
let (mut connection, peer) = test_h2_connection(&pool, None);
let config = H2FloodConfig::from_optional(
None,
None,
None,
None,
None,
None,
None,
Some(lifetime_floor),
None,
None,
None,
None,
None,
None,
);
connection.core.flood_detector =
h2_flood_detector::H2FloodDetector::new(config, connection.core.now);
let context = test_context(&pool);
let router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
(pool, connection, peer, context, router)
}
/// Register client stream `stream_id` on `connection` as a stream routed
/// to a backend, with its response started or not.
fn open_routed_frontend_stream(
connection: &mut H2Shell<mio::net::TcpStream>,
context: &mut Context<TestListener>,
stream_id: StreamId,
response_started: bool,
) {
let gid = context
.create_stream(Ulid::generate(), 65_535)
.expect("the test pool must hand out stream buffers");
let stream = &mut context.streams[gid];
stream.context.backend_id = Some(Rc::from("probe-backend"));
if response_started {
stream.back.parsing_phase = kawa::ParsingPhase::Body;
}
let now = connection.core.now;
connection.core.stream_table.register(stream_id, gid, now);
}
/// Feed one client `RST_STREAM(CANCEL)` for `stream_id` through
/// `handle_rst_stream_frame`, and report whether the connection is now
/// going away.
fn client_cancels(
connection: &mut H2Shell<mio::net::TcpStream>,
context: &mut Context<TestListener>,
router: &mut Router,
stream_id: StreamId,
) -> bool {
let _ = connection.core.handle_rst_stream_frame(
parser::RstStream {
stream_id,
error_code: H2Error::Cancel as u32,
},
context,
EndpointClient(router),
);
matches!(connection.core.state, H2State::GoAway | H2State::Error)
}
/// A client that cancels every stream after its response started — a
/// gRPC client abandoning server streams, a browser leaving a page while
/// downloads run — resets exactly as many streams as a backend answered.
/// The received-RST lifetime cap must never trip on it.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_rst_stream_frame`, count the
/// reset stream with `record_stream_opened` after `record_rst_lifetime`
/// instead of before. Each check then sees one more reset than answered
/// streams, and the reset just past the floor sends GOAWAY.
#[test]
fn resets_after_the_response_never_trip_the_lifetime_cap() {
let (_pool, mut connection, _peer, mut context, mut router) = frontend_rst_fixture(4);
for n in 0..64u32 {
let stream_id = 2 * n + 1;
open_routed_frontend_stream(&mut connection, &mut context, stream_id, true);
assert!(
!client_cancels(&mut connection, &mut context, &mut router, stream_id),
"reset {} of a stream whose response started must not send GOAWAY",
n + 1
);
}
}
/// A frontend connection whose pre-response cap has the floor `floor`,
/// every other threshold at its default (soft refusal at 50 %).
fn frontend_pre_response_fixture(
floor: u64,
) -> (
Rc<RefCell<Pool>>,
H2Shell<mio::net::TcpStream>,
std::net::TcpStream,
Context<TestListener>,
Router,
) {
let pool = Rc::new(RefCell::new(Pool::with_capacity(8, 512, 16_384)));
let (mut connection, peer) = test_h2_connection(&pool, None);
let config = H2FloodConfig::from_optional(
None,
None,
None,
None,
None,
None,
None,
None,
Some(floor),
None,
None,
None,
None,
None,
);
connection.core.flood_detector =
h2_flood_detector::H2FloodDetector::new(config, connection.core.now);
let context = test_context(&pool);
let router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
(pool, connection, peer, context, router)
}
/// A client that ignores `REFUSED_STREAM` and resets the streams Sōzu
/// refused under flood pressure still reaches the pre-response cap: a
/// refused stream never got a response, so its reset is a pre-response
/// one even though the stream was never registered. A reset of a stream
/// that is simply gone keeps counting as response-started.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_rst_stream_frame`, delete
/// the `flood_refused_streams` arm. The resets then count as
/// response-started, the pre-response count stops at the soft threshold,
/// and no GOAWAY is ever sent.
#[test]
fn resets_of_streams_refused_under_flood_count_as_pre_response() {
const FLOOR: u64 = 10;
let (_pool, mut connection, _peer, mut context, mut router) =
frontend_pre_response_fixture(FLOOR);
let refused = (FLOOR as u32 + 1) * 2;
for n in 0..refused {
let stream_id = 2 * n + 1;
connection
.core
.stream_table
.observe_peer_stream_id(stream_id);
connection.core.note_flood_refused_stream(stream_id);
}
assert_eq!(
connection.core.flood_refused_streams,
Some((1, 2 * refused - 1)),
"consecutive refusals form one run"
);
// Resets of ids past the run: gone, not refused, never pre-response,
// and no reason to stop refusing.
for n in 0..=FLOOR as u32 {
let stream_id = 2 * (refused + n) + 1;
assert!(
!client_cancels(&mut connection, &mut context, &mut router, stream_id),
"a reset of a stream outside the refused run must not count as pre-response"
);
}
assert!(!connection.core.flood_refusal_ignored);
let mut tripped_at = None;
for n in 0..refused {
if client_cancels(&mut connection, &mut context, &mut router, 2 * n + 1) {
tripped_at = Some(u64::from(n) + 1);
break;
}
}
assert_eq!(
tripped_at,
Some(FLOOR + 1),
"resets of refused streams must reach the pre-response cap just past its floor"
);
assert!(
connection.core.flood_refusal_ignored,
"a client that resets a refused stream gets no more refusals"
);
}
/// An accepted stream ends the refused run; the next refusal starts a new
/// one, so a stream that was served never counts as refused.
#[test]
fn an_accepted_stream_ends_the_flood_refused_run() {
let (_pool, mut connection, _peer, mut context, _router) =
frontend_pre_response_fixture(10);
connection.core.note_flood_refused_stream(1);
connection.core.note_flood_refused_stream(3);
assert_eq!(connection.core.flood_refused_streams, Some((1, 3)));
connection
.core
.create_stream(5, &mut context)
.expect("the pool must hand out stream buffers");
connection.core.note_flood_refused_stream(7);
assert_eq!(
connection.core.flood_refused_streams,
Some((7, 7)),
"a refusal after an accepted stream starts a new run"
);
}
/// A frontend connection over a [`PacedSocket`] with streams 1 and 3
/// open, a pre-response floor of 10 and a glitch limit of `glitch_limit`,
/// its SETTINGS acknowledged, and `pre_response_resets` pre-response
/// resets on record. Six or more put it in the soft state: past half the
/// floor, more than the streams it got answered (none).
fn soft_refusal_connection(
glitch_limit: Option<u32>,
pre_response_resets: u32,
) -> (
Rc<RefCell<Pool>>,
H2Shell<PacedSocket>,
Context<TestListener>,
Router,
std::net::TcpStream,
) {
let (pool, mut connection, context, router, peer) = two_requests_read(usize::MAX);
connection.core.flood_detector = h2_flood_detector::H2FloodDetector::new(
H2FloodConfig::from_optional(
None,
None,
None,
None,
None,
None,
glitch_limit,
None,
Some(10),
None,
None,
None,
None,
None,
),
connection.core.now,
);
connection.core.settings_sent_at = None;
for _ in 0..pre_response_resets {
assert!(
connection
.core
.flood_detector
.record_rst_received(false, false, connection.core.now)
.is_none(),
"premise: the resets on record stay under the cap"
);
}
(pool, connection, context, router, peer)
}
/// A complete `GET /` HEADERS frame on `stream_id`, from the static
/// table and literals without indexing, so it decodes whatever the
/// peer's dynamic table holds.
fn static_get_headers(stream_id: StreamId) -> Vec<u8> {
let mut block = vec![0x82, 0x84, 0x87, 0x01, 11];
block.extend_from_slice(b"example.com");
orphan_frame(
1,
parser::FLAG_END_STREAM | parser::FLAG_END_HEADERS,
stream_id,
block.len() as u32,
&block,
)
}
/// Feed `inbound` to `connection`, flush what it answers, and return
/// every frame on the wire so far.
fn frames_after(
connection: &mut H2Shell<PacedSocket>,
context: &mut Context<TestListener>,
router: &mut Router,
inbound: &[u8],
) -> Vec<PeerFrame> {
connection.socket.inbound.extend(inbound);
// A pass may stop after a frame that queued output, so drive more
// passes than there are frames.
for _ in 0..256 {
connection.core.readiness.event.insert(Ready::READABLE);
if connection.core.readiness.filter_interest().is_readable() {
connection.readable(context, EndpointClient(router));
}
connection.core.readiness.event.insert(Ready::WRITABLE);
if connection.core.readiness.filter_interest().is_writable() {
connection.writable(context, EndpointClient(router));
}
}
peer_frames(&connection.socket.wire).expect("whole frames on the wire")
}
/// The error code of the first frame of type `kind` (3 RST_STREAM, 7
/// GOAWAY) on `stream_id` (0 for GOAWAY) in `frames`.
fn error_code_of(frames: &[PeerFrame], kind: u8, stream_id: StreamId) -> Option<u32> {
let at = if kind == 7 { 4 } else { 0 };
frames
.iter()
.find(|(k, _, sid, _)| *k == kind && *sid == stream_id)
.map(|(_, _, _, payload)| {
u32::from_be_bytes(payload[at..at + 4].try_into().expect("an error code"))
})
}
/// RFC 9113 §5.1.1: a new stream's id must be greater than every id the
/// client already used, refused ones included. After stream 65 is refused
/// under flood pressure, HEADERS on 55 opens no stream: it is a
/// connection error of type PROTOCOL_ERROR, and the refused run stays
/// `(65, 65)`.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_header_state`, compare a new
/// client stream's id with `last_stream_id` only, as before the fix. Stream
/// 55 is then taken for a new stream and refused too, and the debug build
/// panics with `the refused run ends at the stream just refused`.
#[test]
fn headers_below_a_refused_stream_id_is_a_protocol_error() {
let (_pool, mut connection, mut context, mut router, _peer) =
soft_refusal_connection(None, 6);
let mut wire = static_get_headers(65);
wire.extend(static_get_headers(55));
let frames = frames_after(&mut connection, &mut context, &mut router, &wire);
assert_eq!(
error_code_of(&frames, 3, 65),
Some(H2Error::RefusedStream as u32),
"premise: stream 65 is refused under flood pressure, got {frames:?}"
);
assert_eq!(
error_code_of(&frames, 7, 0),
Some(H2Error::ProtocolError as u32),
"HEADERS on an id below a refused one must end the connection with \
GOAWAY(PROTOCOL_ERROR), got {frames:?}"
);
assert_eq!(
error_code_of(&frames, 3, 55),
None,
"stream 55 must not be refused as a new stream"
);
assert_eq!(connection.core.flood_refused_streams, Some((65, 65)));
}
/// Soft refusals count toward the glitch budget once the client
/// acknowledged Sōzu's SETTINGS, like the draining and
/// SETTINGS_MAX_CONCURRENT_STREAMS refusals: a client that keeps opening
/// streams it is refused, without resetting any, ends with
/// GOAWAY(ENHANCE_YOUR_CALM).
///
/// TO SEE THIS RED: in `ConnectionH2::handle_header_state`, drop the
/// `record_glitch` call of the soft-refusal branch. Every stream is then
/// refused and no GOAWAY ever comes.
#[test]
fn soft_refusals_count_toward_the_glitch_budget() {
const GLITCH_LIMIT: u32 = 20;
let (_pool, mut connection, mut context, mut router, _peer) =
soft_refusal_connection(Some(GLITCH_LIMIT), 6);
let mut wire = Vec::new();
for n in 0..2 * GLITCH_LIMIT {
wire.extend(static_get_headers(5 + 2 * n));
}
let frames = frames_after(&mut connection, &mut context, &mut router, &wire);
assert_eq!(
error_code_of(&frames, 3, 5),
Some(H2Error::RefusedStream as u32),
"premise: the first new stream is refused under flood pressure"
);
assert_eq!(
error_code_of(&frames, 7, 0),
Some(H2Error::EnhanceYourCalm as u32),
"a client that keeps opening refused streams must exhaust the glitch \
budget, got {} RST_STREAM frames and no GOAWAY",
frames.iter().filter(|(kind, ..)| *kind == 3).count()
);
assert!(
frames.iter().filter(|(kind, ..)| *kind == 3).count() <= GLITCH_LIMIT as usize + 1,
"the glitch budget bounds the refusals"
);
}
/// A RST_STREAM the client sends on the wire for a stream refused under
/// flood pressure goes through the closed-stream branch of
/// `ConnectionH2::handle_header_state`, which counts one glitch, and then
/// reaches `ConnectionH2::handle_rst_stream_frame`: refusals stop for the
/// connection, so its next stream is opened, and the reset counts as a
/// pre-response one, so with ten pre-response resets on record it
/// trips the cap's floor of 10.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_rst_stream_frame`, delete
/// the `flood_refused_streams` arm: `a client that resets a refused
/// stream gets no more refusals` fails, stream 7 is refused, and the
/// reset counts as response-started, so no GOAWAY comes.
#[test]
fn a_client_reset_of_a_refused_stream_on_the_wire_ends_the_refusals() {
let rst = |stream_id: StreamId| {
orphan_frame(3, 0, stream_id, 4, &(H2Error::Cancel as u32).to_be_bytes())
};
let (_pool, mut connection, mut context, mut router, _peer) =
soft_refusal_connection(None, 6);
let frames = frames_after(
&mut connection,
&mut context,
&mut router,
&static_get_headers(5),
);
assert_eq!(
error_code_of(&frames, 3, 5),
Some(H2Error::RefusedStream as u32),
"premise: stream 5 is refused under flood pressure"
);
let glitches = connection.core.flood_detector.glitch_count();
frames_after(&mut connection, &mut context, &mut router, &rst(5));
assert_eq!(
connection.core.flood_detector.glitch_count(),
glitches + 1,
"the reset of a refused stream costs one glitch"
);
assert!(
connection.core.flood_refusal_ignored,
"a client that resets a refused stream gets no more refusals"
);
let frames = frames_after(
&mut connection,
&mut context,
&mut router,
&static_get_headers(7),
);
assert_eq!(
error_code_of(&frames, 3, 7),
None,
"no stream is refused after the client reset a refused one"
);
assert!(
connection.core.stream_table.get(7).is_some(),
"stream 7 is opened"
);
// Ten pre-response resets on record: the reset of a refused stream is
// the eleventh, past the floor.
let (_pool, mut connection, mut context, mut router, _peer) =
soft_refusal_connection(None, 10);
let mut wire = static_get_headers(5);
wire.extend(rst(5));
let frames = frames_after(&mut connection, &mut context, &mut router, &wire);
assert_eq!(
error_code_of(&frames, 3, 5),
Some(H2Error::RefusedStream as u32),
"premise: stream 5 is refused under flood pressure"
);
assert_eq!(
error_code_of(&frames, 7, 0),
Some(H2Error::EnhanceYourCalm as u32),
"the reset of a refused stream must count as pre-response, got {frames:?}"
);
}
/// HEADERS without END_STREAM, `len` bytes of DATA split in frames of at
/// most 16 KiB, and a trailer section with END_STREAM on `stream_id`:
/// what a client streaming an upload with request trailers sends before
/// it reads anything back.
fn upload_with_trailers(stream_id: StreamId, len: usize) -> Vec<u8> {
let mut block = vec![0x83, 0x84, 0x87, 0x01, 11];
block.extend_from_slice(b"example.com");
let mut wire = orphan_frame(
1,
parser::FLAG_END_HEADERS,
stream_id,
block.len() as u32,
&block,
);
wire.extend(refused_stream_data(stream_id, len, 0));
// `x-t: 1`, a literal with incremental indexing and a new name: it
// becomes the newest dynamic table entry, index 62 (RFC 7541 §6.2.1).
let trailers = [0x40, 3, b'x', b'-', b't', 1, b'1'];
wire.extend(orphan_frame(
1,
parser::FLAG_END_HEADERS | parser::FLAG_END_STREAM,
stream_id,
trailers.len() as u32,
&trailers,
));
wire
}
/// `len` bytes of DATA on `stream_id` in frames of at most 16 KiB, the
/// last one carrying `last_flags`.
fn refused_stream_data(stream_id: StreamId, len: usize, last_flags: u8) -> Vec<u8> {
let mut wire = Vec::new();
let mut left = len;
while left > 0 {
let size = left.min(16_384);
left -= size;
let flags = if left == 0 { last_flags } else { 0 };
wire.extend(orphan_frame(
0,
flags,
stream_id,
size as u32,
&vec![b'x'; size],
));
}
wire
}
/// RFC 9113 §5.1: the frames a client sent on a stream before it read
/// Sōzu's `REFUSED_STREAM` are ignored, like those on a stream Sōzu
/// reset. A refused upload carrying request trailers costs the client
/// that stream only: no `RST_STREAM(STREAM_CLOSED)` for its DATA, no
/// GOAWAY for its trailers, and the streams already open, and the
/// connection, carry on.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_header_state`, drop the
/// `remember_refused` call of the soft-refusal branch. The DATA
/// frame then takes the closed-stream branch, and the trailers end the
/// connection with GOAWAY(PROTOCOL_ERROR).
#[test]
fn trailers_on_a_refused_stream_are_ignored() {
let (_pool, mut connection, mut context, mut router, _peer) =
soft_refusal_connection(None, 6);
let glitches = connection.core.flood_detector.glitch_count();
let frames = frames_after(
&mut connection,
&mut context,
&mut router,
&upload_with_trailers(5, 1000),
);
assert_eq!(
error_code_of(&frames, 3, 5),
Some(H2Error::RefusedStream as u32),
"premise: stream 5 is refused under flood pressure, got {frames:?}"
);
assert_eq!(
frames
.iter()
.filter(|(kind, _, sid, _)| *kind == 3 && *sid == 5)
.count(),
1,
"the refused stream gets its REFUSED_STREAM and nothing more, got {frames:?}"
);
assert_eq!(
error_code_of(&frames, 7, 0),
None,
"frames on a refused stream must not end the connection, got {frames:?}"
);
assert_eq!(
connection.core.flood_detector.glitch_count(),
glitches + 1,
"only the refusal itself costs a glitch"
);
assert!(
connection.core.stream_table.get(1).is_some()
&& connection.core.stream_table.get(3).is_some(),
"the streams already open survive"
);
assert!(
!matches!(connection.core.state, H2State::GoAway | H2State::Error),
"the connection survives, got {:?}",
connection.core.state
);
// The ignored trailer block was still decoded (RFC 9113 §4.3): the
// entry it indexed is the newest in the connection's dynamic table.
let mut decoded = Vec::new();
let status = connection
.core
.hpack
.decoder_mut()
.decode_with_cb(&[0x80 | 62], |k, v| {
decoded.push((k.into_owned(), v.into_owned()));
});
assert!(status.is_ok(), "index 62 must resolve, got {status:?}");
assert_eq!(
decoded,
vec![(b"x-t".to_vec(), b"1".to_vec())],
"the ignored trailers must keep the HPACK decoder in step with the peer"
);
// The connection reads on: a later PING is answered.
let frames = frames_after(&mut connection, &mut context, &mut router, &orphan_ping());
assert!(
frames.iter().any(|(kind, flags, _, payload)| *kind == 6
&& *flags & 1 == 1
&& payload == b"pingpong"),
"the connection must keep reading after the ignored frames, got {frames:?}"
);
}
/// RFC 9113 §5.1 and §6.9: DATA a client sent on a refused stream within
/// the stream's initial window — a browser upload filling it in 16 KiB
/// frames — is ignored without a glitch or a `RST_STREAM(STREAM_CLOSED)`
/// per frame, and still counts toward the connection's receive window,
/// which Sōzu credits back.
///
/// TO SEE THIS RED: in `ConnectionH2::handle_header_state`, drop the
/// `remember_refused` call of the soft-refusal branch. Each of
/// the four DATA frames then costs a glitch and a RST_STREAM.
#[test]
fn data_on_a_refused_stream_is_ignored_and_credited() {
let (_pool, mut connection, mut context, mut router, _peer) =
soft_refusal_connection(None, 6);
// Half of this is the threshold at which a stream-0 WINDOW_UPDATE
// credits the received bytes back.
connection.core.connection_config.initial_connection_window = 65_535;
let mut block = vec![0x83, 0x84, 0x87, 0x01, 11];
block.extend_from_slice(b"example.com");
let mut wire = orphan_frame(1, parser::FLAG_END_HEADERS, 5, block.len() as u32, &block);
let window = connection.core.local_settings.settings_initial_window_size as usize;
wire.extend(refused_stream_data(5, window, parser::FLAG_END_STREAM));
let glitches = connection.core.flood_detector.glitch_count();
let frames = frames_after(&mut connection, &mut context, &mut router, &wire);
assert_eq!(
error_code_of(&frames, 3, 5),
Some(H2Error::RefusedStream as u32),
"premise: stream 5 is refused under flood pressure, got {frames:?}"
);
assert_eq!(
frames
.iter()
.filter(|(kind, _, sid, _)| *kind == 3 && *sid == 5)
.count(),
1,
"DATA on a refused stream must not be answered with RST_STREAM, got {frames:?}"
);
assert_eq!(
connection.core.flood_detector.glitch_count(),
glitches + 1,
"DATA within the stream's window on a refused stream costs no glitch"
);
let credited: u32 = frames
.iter()
.filter(|(kind, _, sid, _)| *kind == 8 && *sid == 0)
.map(|(_, _, _, payload)| {
u32::from_be_bytes(payload[..4].try_into().expect("an increment")) & 0x7fff_ffff
})
.sum();
assert_eq!(
credited as usize, window,
"the ignored DATA must be credited to the connection window exactly once"
);
assert_eq!(
error_code_of(&frames, 7, 0),
None,
"no GOAWAY, got {frames:?}"
);
}
/// A drain refuses new streams; a client that sent a whole upload with
/// request trailers on one of them before it read the GOAWAY sees its
/// stream refused, and the drain still ends with GOAWAY(NO_ERROR).
///
/// TO SEE THIS RED: in `ConnectionH2::refuse_stream_and_discard`, drop
/// the `remember_refused` call. The trailers then end the
/// connection with GOAWAY(PROTOCOL_ERROR) before the drain completes.
#[test]
fn a_drain_with_a_refused_upload_carrying_trailers_ends_with_no_error() {
let (_pool, mut connection, mut context, mut router, _peer) = two_requests_read(usize::MAX);
connection.core.drain.__test_set_draining();
let frames = frames_after(
&mut connection,
&mut context,
&mut router,
&upload_with_trailers(5, 1000),
);
assert_eq!(
error_code_of(&frames, 3, 5),
Some(H2Error::RefusedStream as u32),
"premise: the drain refuses stream 5, got {frames:?}"
);
let now = connection.core.now;
let _ = connection.core.graceful_goaway(now);
let frames = frames_after(&mut connection, &mut context, &mut router, &[]);
assert_eq!(
error_code_of(&frames, 7, 0),
Some(H2Error::NoError as u32),
"the drain must end with GOAWAY(NO_ERROR), got {frames:?}"
);
}
/// A mix of cancels: one stream in ten reset before its response,
/// seventeen in twenty after it, the rest completed. The pre-response
/// resets are the pre-response cap's business; the received-RST lifetime
/// cap compares only the other resets with the answered streams, and must
/// not trip.
///
/// TO SEE THIS RED: in `H2FloodDetector::record_rst_lifetime`, compare
/// `total_rst_received_lifetime` instead of the resets outside the
/// pre-response count with `streams_opened`. The pre-response resets then
/// push the count past the answered streams and the cap sends GOAWAY.
#[test]
fn pre_response_resets_do_not_count_toward_the_lifetime_cap() {
let (_pool, mut connection, _peer, mut context, mut router) = frontend_rst_fixture(4);
let mut stream_id = 1;
for block in 0..5 {
for slot in 0..20 {
if slot == 19 {
// A stream that completed: counted where the recycle path
// counts it (`try_recycle_server_stream`).
connection.core.flood_detector.record_stream_opened();
continue;
}
let response_started = slot >= 2;
open_routed_frontend_stream(
&mut connection,
&mut context,
stream_id,
response_started,
);
assert!(
!client_cancels(&mut connection, &mut context, &mut router, stream_id),
"block {block}, slot {slot}: a 10 % pre-response / 85 % \
post-response / 5 % clean mix must not send GOAWAY"
);
stream_id += 2;
}
}
}
/// The received-RST lifetime cap still bounds resets of streams that are
/// already gone: here every answered stream is reset twice, so these
/// resets outnumber the answered streams as soon as the floor is passed.
/// Guard against neutering the cap.
#[test]
fn resets_of_streams_already_gone_trip_the_lifetime_cap() {
let (_pool, mut connection, _peer, mut context, mut router) = frontend_rst_fixture(4);
let mut tripped_after = None;
for n in 0..16u32 {
let stream_id = 2 * n + 1;
open_routed_frontend_stream(&mut connection, &mut context, stream_id, true);
if client_cancels(&mut connection, &mut context, &mut router, stream_id)
|| client_cancels(&mut connection, &mut context, &mut router, stream_id)
{
tripped_after = Some(n + 1);
break;
}
}
assert_eq!(
tripped_after,
Some(3),
"the reset that takes the count past the floor of 4 while the \
resets outnumber the answered streams must send GOAWAY"
);
}
/// A stream Sōzu answers 502/503/504 without selecting a backend — no
/// backend in the cluster, the session or buffer limit hit before a
/// backend was picked, a routing error — never cost a backend anything,
/// and must not count toward the RST caps' denominator.
///
/// TO SEE THIS RED: add `|| matches!(stream.context.status,
/// Some(502..=504))` back to `routed_to_a_backend`.
#[test]
fn a_5xx_answered_without_a_backend_is_not_routed() {
let pool = make_pool_for_invariant_16();
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let answers = context.listener.borrow().get_answers().clone();
for code in [502, 503, 504] {
let gid = context
.create_stream(Ulid::generate(), 65_535)
.expect("the test pool must hand out stream buffers");
crate::protocol::mux::answers::set_default_answer(
&mut context.streams[gid],
&mut connection.core.readiness,
code,
&answers.borrow(),
);
let stream = &context.streams[gid];
assert_eq!(stream.context.status, Some(code), "premise");
assert!(
!routed_to_a_backend(stream),
"a {code} answered before any backend was selected must not count"
);
context.streams[gid].state = StreamState::Recycle;
}
}
/// A stream answered 502/503/504 after a backend was selected for it —
/// the backend refused the connection, timed out, or reset — keeps
/// counting, so a backend outage does not turn the client's ordinary
/// cancels into a Rapid Reset verdict.
///
/// TO SEE THIS RED: drop `|| stream.context.backend_id.is_some()` from
/// `routed_to_a_backend`.
///
/// Past the first failures the outage no longer selects a backend at all:
/// every backend is backing off and the 503 is answered with
/// `backends_unavailable` set, which counts the same way.
///
/// TO SEE THIS RED for the second half: drop
/// `|| stream.context.backends_unavailable` from `routed_to_a_backend`;
/// `test_h2_cancels_during_a_backend_outage_keep_the_connection` then
/// fails end to end too.
#[test]
fn a_5xx_answered_during_a_backend_outage_is_routed() {
let pool = make_pool_for_invariant_16();
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
let answers = context.listener.borrow().get_answers().clone();
for code in [502, 503, 504] {
let gid = context
.create_stream(Ulid::generate(), 65_535)
.expect("the test pool must hand out stream buffers");
context.streams[gid].context.backend_id = Some(Rc::from("down-backend"));
crate::protocol::mux::answers::set_default_answer(
&mut context.streams[gid],
&mut connection.core.readiness,
code,
&answers.borrow(),
);
let stream = &context.streams[gid];
assert_eq!(stream.context.status, Some(code), "premise");
assert!(
routed_to_a_backend(stream),
"a {code} answered for a selected backend must count"
);
context.streams[gid].state = StreamState::Recycle;
}
let gid = context
.create_stream(Ulid::generate(), 65_535)
.expect("the test pool must hand out stream buffers");
context.streams[gid].context.backends_unavailable = true;
crate::protocol::mux::answers::set_default_answer(
&mut context.streams[gid],
&mut connection.core.readiness,
503,
&answers.borrow(),
);
assert!(
routed_to_a_backend(&context.streams[gid]),
"a 503 for a cluster whose backends are all failing must count"
);
}
/// An inbound `GOAWAY` retires every stream above `last_stream_id`
/// without `Connection::end_stream`, so it owes each of their releases.
#[test]
fn invariant_14_an_inbound_goaway_releases_every_retired_charge() {
let pool = make_pool_for_invariant_16();
let mut context = test_context(&pool);
let mut fixture = ledger_fixture(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let _gids = open_streams(&mut fixture, &mut context, 3);
let Connection::H2(shell) = &mut fixture.connection else {
unreachable!("the fixture was built as H2");
};
// `last_stream_id = 0` retires all three: the peer says it processed
// none of them.
shell.core.handle_goaway_frame(
parser::GoAway {
last_stream_id: 0,
error_code: H2Error::NoError as u32,
// Empty debug data: `Slice` is an offset/length pair into
// a buffer this scenario does not carry.
additional_debug_data: kawa::repr::Slice::new(b"", b""),
},
&mut context,
EndpointClient(&mut router),
);
assert_eq!(
reconcile(&context.backend_deltas),
(0, 0),
"every retired stream must release the charge it took"
);
assert_eq!(
settle(&mut fixture, &mut context),
0,
"a GOAWAY that retires every stream must leave no charge behind"
);
}
/// An H1 backend taken back out of the keep-alive pool must be charged
/// for the request it is being reused for.
///
/// This one records a BEHAVIOUR CHANGE, not just a refactor. Before
/// #1340's Question 12 the charge was taken *before* the inner
/// `start_stream`, under a `BackendStatus::Connected` guard — and an H1
/// connection coming out of the pool is `BackendStatus::KeepAlive` at
/// that moment, because `ConnectionH1::start_stream` is what flips it to
/// `Connected`. So the reuse took no charge, while the matching
/// `Connection::end_stream` released one (it runs before
/// `ConnectionH1::end_stream` flips the status back, so its own
/// `Connected` guard passes). Every keep-alive reuse cycle therefore net
/// `-1`d the backend, `saturating_sub` floored it at zero, and a reused
/// H1 connection reported no in-flight request while it served one —
/// exactly the monotonic drift invariant 14 names, biasing least-loaded
/// and PeakEWMA balancing toward backends that are already busy.
///
/// Recording the charge after the inner call fixes it, because the flip
/// has happened by then. Taking it afterwards is also what removes the
/// rollback, so the two properties are one edit.
#[test]
fn invariant_14_an_h1_keep_alive_reuse_is_charged() {
let pool = make_pool_for_invariant_16();
let mut context = test_context(&pool);
let (socket, _peer) = connected_socket();
let address: std::net::SocketAddr =
"127.0.0.1:2".parse().expect("backend address must parse");
let backend = Rc::new(RefCell::new(crate::backends::Backend::new(
"h1-keepalive-backend",
address,
None,
None,
None,
)));
let mut registry = crate::protocol::mux::BackendRegistry::default();
let backend_id = registry.id_for(&backend);
let session_ulid = Ulid::generate();
let mut connection = Connection::new_h1_client(
session_ulid,
SessionTcpStream::new(socket, session_ulid, Some(address)),
"h1-keepalive-cluster".into(),
backend_id,
Duration::from_secs(30),
);
// Park it in the pool, the state `Router::plan_connect`'s H1 reuse arm
// picks a backend up from.
if let Position::Client(_, _, status) = connection.position_mut() {
*status = BackendStatus::KeepAlive;
}
let gid = context
.create_stream(Ulid::generate(), 65_535)
.expect("the test pool must hand out stream buffers");
assert!(
connection.start_stream(gid, &mut context),
"premise: a keep-alive H1 backend must accept the reused stream"
);
assert_eq!(
reconcile(&context.backend_deltas),
(1, 0),
"a keep-alive reuse must charge the request it is being reused for"
);
connection.end_stream(gid, &mut context);
assert_eq!(
reconcile(&context.backend_deltas),
(0, 0),
"and the charge must be released when that request ends"
);
let backends = RefCell::new(crate::backends::BackendMap::with_seed(0));
for delta in std::mem::take(&mut context.backend_deltas) {
registry.apply(delta, &backends);
}
assert_eq!(
backend.borrow().active_requests,
0,
"a full reuse cycle must leave active_requests where it found it"
);
}
/// `cancel_timed_out_streams` reaps an idle stream on the same terms:
/// removed outside `Connection::end_stream`, so it releases its own
/// charge.
#[test]
fn invariant_14_a_timed_out_stream_releases_its_charge() {
let pool = make_pool_for_invariant_16();
let mut context = test_context(&pool);
let mut fixture = ledger_fixture(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let _gids = open_streams(&mut fixture, &mut context, 1);
// Push the mux's clock far past the stream idle timeout the fixture
// configured, so the reaper finds the stream expired.
context.now += Duration::from_secs(120);
let Connection::H2(shell) = &mut fixture.connection else {
unreachable!("the fixture was built as H2");
};
let mut endpoint = EndpointClient(&mut router);
shell
.core
.cancel_timed_out_streams(&mut context, &mut endpoint);
assert_eq!(
reconcile(&context.backend_deltas),
(0, 0),
"a reaped stream must release the charge it took"
);
assert_eq!(
settle(&mut fixture, &mut context),
0,
"reaping the only stream must leave no charge behind"
);
}
// ── `backend_header_time`, the H2 half (sozu-proxy/sozu#426) ─────────
//
// DO NOT READ THIS TEST AS COVERING H1. It drives
// `ConnectionH2::handle_headers_frame` only. The H1 twin lives in
// `ConnectionH1::readable` (`lib/src/protocol/mux/h1.rs`) and is pinned by
// its own test there.
/// Stage a response field block in the connection's stream-0 storage and
/// hand back the `Headers` frame that names it, the way
/// `ConnectionH2::handle_read` hands one to
/// `ConnectionH2::handle_headers_frame`: the fragment is a `(start, len)`
/// window over `ConnectionH2::zero`'s buffer, not an owned slice.
///
/// `0x88` is one HPACK indexed-header-field octet naming static-table
/// entry 8, `:status: 200` (RFC 7541 Appendix A) — a complete, valid
/// response field block in a single byte, so this needs no encoder.
fn stage_response_headers(core: &mut ConnectionH2, stream_id: u32) -> parser::Headers {
core.zero.storage.space()[..1].copy_from_slice(&[0x88]);
core.zero.storage.fill(1);
let buffer = core.zero.storage.buffer();
parser::Headers {
stream_id,
priority: None,
header_block_fragment: kawa::repr::Slice::new(buffer, &buffer[..1]),
end_stream: false,
end_headers: true,
}
}
/// The H2 twin of
/// `the_h1_backend_header_instant_lands_on_the_headers_not_the_response_end`:
/// a backend response HEADERS frame arms the time-to-first-header-byte
/// instant, and it arms it strictly before the response ends.
///
/// `end_stream: false` is what makes it discriminating — the response body
/// is still outstanding, so `backend_stop` (which every one of
/// `ConnectionH2`'s four sites owns) must still be absent while the header
/// instant already exists. An instant placed at any of those sites cannot
/// satisfy that.
///
/// The ordering is asserted on `Duration`s rather than on the millisecond
/// values the histogram stores, for the same reason as its H1 twin: at
/// millisecond resolution a fast test rounds both to 0 and the relation
/// holds however wrong the placement is.
///
/// To SEE THIS RED: move `stream.metrics.backend_headers_received();` out
/// of the `was_initial && self.position.is_client()` branch of
/// `ConnectionH2::handle_headers_frame` and put it immediately after the
/// `stream.metrics.backend_stop();` that opens
/// `ConnectionH2::complete_server_stream` — the nominal `H2::Complete`
/// response-end site. The frame under test carries no END_STREAM, so
/// nothing arms the instant and the test reports no header time at all.
#[test]
fn the_h2_backend_header_instant_lands_on_the_headers_not_the_response_end() {
let pool = make_pool_for_invariant_16();
let mut context = test_context(&pool);
let mut fixture = ledger_fixture(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
let gids = open_streams(&mut fixture, &mut context, 1);
let gid = gids[0];
let metrics = &mut context.streams[gid].metrics;
metrics.backend_id = Some("ledger-backend".into());
metrics.backend_start();
metrics.backend_connected();
// Premise: nothing has been received, so neither instant can be stale.
assert_eq!(context.streams[gid].metrics.backend_header_time(), None);
assert_eq!(context.streams[gid].metrics.backend_stop, None);
let Connection::H2(shell) = &mut fixture.connection else {
unreachable!("the fixture was built as H2");
};
let stream_id = *shell
.core
.stream_table
.streams()
.keys()
.next()
.expect("the scenario opened one stream");
assert!(
context.streams[gid].back.is_initial(),
"premise: the backend read side must still be in its initial \
parsing phase, or `was_initial` would not fire"
);
let headers = stage_response_headers(&mut shell.core, stream_id);
shell
.core
.handle_headers_frame(headers, &mut context, EndpointClient(&mut router));
let after_headers = context.streams[gid]
.metrics
.backend_headers_received
.expect("a backend response HEADERS frame must arm the backend header instant");
assert_eq!(
context.streams[gid].metrics.backend_stop, None,
"the frame carried no END_STREAM, so the response-end marker must \
still be absent"
);
// What `ConnectionH2::mark_end_of_stream` does once the response ends.
context.streams[gid].metrics.backend_stop();
let metrics = &context.streams[gid].metrics;
assert_eq!(
metrics.backend_headers_received,
Some(after_headers),
"the response end must not move the header instant"
);
let header_time = metrics
.backend_header_time()
.expect("a completed H2 response must report a backend header time");
let response_time = metrics
.backend_response_time()
.expect("a completed H2 response must report a backend response time");
assert!(
header_time <= response_time,
"the first response-header byte arrives before the last response \
byte: header_time {header_time:?} must not exceed response_time \
{response_time:?}"
);
}
/// The server preface — SETTINGS, the connection-window enlargement and
/// the ACK of the client's SETTINGS — leaves in ONE socket write.
///
/// On `FrontRustls` every non-empty `socket_write` ends in its own
/// `write_tls`, i.e. its own `writev(2)`. The enlargement used to be
/// queued by `dispatch_writable_state`'s `(ServerSettings, Server)` arm,
/// which runs after `drive_control_flush` already drained `zero`, so it
/// cost a second write pass and a second syscall on every connection.
///
/// TO SEE THIS RED: move the `gen_window_update` call out of the
/// `(ClientSettings, Server)` readable arm back into a
/// `queue_window_update(0, increment)` in the `(ServerSettings, Server)`
/// writable arm. The write count becomes 2 and the first write carries 66
/// bytes, not 79.
#[test]
fn server_preface_settings_window_update_and_ack_share_one_write() {
use std::io::Read as _;
let pool = Rc::new(RefCell::new(Pool::with_capacity(4, 8, 16384)));
let (mut connection, mut peer) =
connection_with_backpressure(&pool, 0, 0, H2State::ClientPreface);
connection
.core
.readiness
.interest
.insert(Ready::READABLE | Ready::WRITABLE);
let mut context = test_context(&pool);
let mut router = Router::new(Duration::from_secs(30), Duration::from_secs(30));
// Client preface + SETTINGS(MAX_CONCURRENT_STREAMS = 100).
let mut preface = serializer::H2_PRI.as_bytes().to_vec();
preface.extend_from_slice(&[0, 0, 6, 4, 0, 0, 0, 0, 0]);
preface.extend_from_slice(&parser::SETTINGS_MAX_CONCURRENT_STREAMS.to_be_bytes());
preface.extend_from_slice(&100u32.to_be_bytes());
let now = connection.core.now;
liveness_read(
&mut connection,
&mut context,
&mut router,
&mut peer,
now,
&preface,
);
liveness_write(&mut connection, &mut context, &mut router);
assert!(
matches!(connection.core.state, H2State::Header),
"premise: the handshake must have reached the proxying state, got {:?}",
connection.core.state
);
assert_eq!(
connection.socket.writes, 1,
"SETTINGS, WINDOW_UPDATE(0) and the SETTINGS ACK must leave in a \
single socket write"
);
let settings_len = parser::FRAME_HEADER_SIZE
+ (parser::SETTINGS_ENTRY_SIZE * parser::SETTINGS_COUNT) as usize;
let expected = settings_len + 13 + serializer::SETTINGS_ACKNOWLEDGEMENT.len();
let mut wire = vec![0u8; expected];
peer.set_read_timeout(Some(Duration::from_secs(5)))
.expect("loopback read timeout must be settable");
peer.read_exact(&mut wire)
.expect("the peer must receive the whole server preface");
assert_eq!(
wire[3], 4,
"the first frame must be SETTINGS (RFC 9113 §3.4)"
);
assert_eq!(wire[4], 0, "the first SETTINGS must not be an ACK");
let window_update = &wire[settings_len..settings_len + 13];
assert_eq!(
window_update[3], 8,
"the second frame must be WINDOW_UPDATE"
);
assert_eq!(&window_update[5..9], &[0, 0, 0, 0], "on the connection");
assert_eq!(
u32::from_be_bytes(window_update[9..13].try_into().unwrap()),
H2ConnectionConfig::default().initial_connection_window - DEFAULT_INITIAL_WINDOW_SIZE,
"the enlargement must grant exactly the configured surplus"
);
assert_eq!(
&wire[settings_len + 13..],
&serializer::SETTINGS_ACKNOWLEDGEMENT,
"the ACK of the client's SETTINGS must close the preface"
);
}
/// A backend that sends SETTINGS again, without granting any connection
/// credit, must not get the one-shot connection-window enlargement again:
/// each repeat would add `initial_connection_window - 65535` to its send
/// window until it passed 2^31-1 (RFC 9113 §6.9.1).
///
/// TO SEE THIS RED: in `ConnectionH2::handle_settings_frame`, gate the
/// enlargement on `self.flow_control.window() <= DEFAULT_INITIAL_WINDOW_SIZE`
/// (our send window) instead of `connection_window_enlarged`. The three
/// SETTINGS then queue three grants, coalesced into one stream-0 frame
/// of three times the surplus. Verified 2026-10-01 against `3d3f077b`.
#[test]
fn repeated_backend_settings_enlarge_the_connection_window_once() {
let pool = Rc::new(RefCell::new(Pool::with_capacity(2, 4, 16384)));
let (mut connection, _peer) = test_h2_connection(&pool, None);
let mut context = test_context(&pool);
connection.core.position = Position::Client(
"settings-cluster".into(),
super::super::BackendId {
slot: super::super::BackendSlot(0),
backend_id: Rc::from("settings-backend"),
address: "127.0.0.1:1".parse().expect("a literal socket address"),
metric_incarnation: Default::default(),
},
BackendStatus::Connected,
);
for _ in 0..3 {
let result = connection.core.handle_settings_frame(
parser::Settings {
settings: vec![],
ack: false,
},
&mut context,
);
assert!(
matches!(result, MuxResult::Continue),
"an empty SETTINGS must be accepted"
);
}
let mut buf = vec![0u8; 13 * 8];
let (written, frames) = connection
.core
.flow_control
.drain_window_updates_into(&mut buf);
let grants: Vec<(u32, u32)> = buf[..written]
.as_chunks::<13>()
.0
.iter()
.map(|frame| {
(
u32::from_be_bytes(frame[5..9].try_into().unwrap()),
u32::from_be_bytes(frame[9..13].try_into().unwrap()),
)
})
.collect();
assert_eq!(frames, grants.len());
assert_eq!(
grants,
vec![(
0,
H2ConnectionConfig::default().initial_connection_window
- DEFAULT_INITIAL_WINDOW_SIZE
)],
"three SETTINGS must enlarge the connection window exactly once"
);
}
}