sozu_lib/protocol/mux/h2_write_pass.rs
1//! Owned state of ONE [`super::h2::H2Shell::write_streams`] pass.
2//!
3//! Every value here used to be a `let` inside that function. They are grouped
4//! into one struct because the write path is being turned into a drive loop —
5//! a `poll` that names the next transmit, a shell that performs it, a `handle`
6//! that reports it back — and a value the pre-image kept in a stack slot
7//! across the flush loop has to survive that round trip. Extracting the state
8//! first, with the control flow left exactly as it was, keeps the two halves
9//! separately reviewable: this half moves no condition, no ordering and no
10//! saturating-add boundary.
11//!
12//! **This module owns**: the byte counters, flags and scratch vectors of one
13//! write pass, and their initial values.
14//!
15//! **This module also owns**, since the inversion, the pass's PHASE
16//! ([`H2WritePhase`]) and the three values the drive loop has to carry across
17//! the caller boundary without being able to build them at pass start: the
18//! [`super::converter::H2ConverterPass`], the scheduler's loaned `order`
19//! buffer and its [`super::h2_scheduler::ReadyIncrementalCensus`]. They are
20//! LATE-INITIALISED — see [`H2WritePass::adopt_scheduler_pass`] — and the two
21//! reasons they cannot be pass-start fields are different reasons, not one:
22//!
23//! - `order` and `census` are built by `H2Scheduler::begin_pass` and given
24//! back to `H2Scheduler::end_pass`. `begin_pass` enumerates
25//! `stream_table.streams()`, and the `expect_write` resume path that runs
26//! BEFORE it can retire a stream out of that same map through
27//! `ConnectionH2::remove_dead_stream` — so building them at pass start would
28//! change which streams the pass visits.
29//! - The `H2ConverterPass` enumerates no streams, so the reason above does NOT
30//! apply to it; its reason is buffer stranding. Its constructor takes the
31//! three reusable scratch buffers out of `HpackState` (`take_converter_buf`
32//! / `take_lowercase_buf` / `take_cookie_buf`, each a `mem::take`), and only
33//! `into_buffers` hands them back through the matching `put_*` calls. The
34//! resume path's stall ends the pass before both, so a converter built at
35//! pass start would be dropped on every stalled resume, leaving `HpackState`
36//! holding three empty `Vec`s to re-grow — the reuse the pass exists to
37//! provide, discarded on exactly the backpressure path that repeats most.
38//!
39//! **This module does NOT own** the pass's control flow — that is
40//! `ConnectionH2::poll_write_target` — nor the `Vec<IoSlice<'static>>`. The
41//! vector IS buildable at pass start and is still excluded, by design rather
42//! than by necessity: `h2_transmit::gather` returns descriptors carrying a
43//! `'static` lifetime they do not have, pointing into `kawa.storage`, and
44//! `h2_transmit::confirm` must discharge them before the consume that may
45//! relocate that storage. Leaving the vector with the shell keeps that
46//! `unsafe` window exactly as wide as the three adjacent statements that open
47//! and close it, and stops the poll/handle boundary from spanning it. The
48//! shell keeps it as a private field for the connection's lifetime, so its
49//! capacity is reused from one pass to the next and a warm pass allocates
50//! nothing for it.
51//!
52//! The pass is a local threaded by `&mut`, never a field on `ConnectionH2`:
53//! every exit of `write_streams` ends it, and every phase transition falls
54//! through to [`H2WritePhase::End`] or to a `Done`, so it cannot outlive one
55//! call. Resumption ACROSS calls is already expressed by
56//! `H2StreamTable::expect_write`.
57
58use super::{
59 GlobalStreamId, StreamId, StreamState, converter::H2ConverterPass,
60 h2_scheduler::ReadyIncrementalCensus, parser::H2Error,
61};
62
63/// What every late-initialised accessor below panics with.
64///
65/// ONE message and ONE `expect` per accessor, so a violation names the
66/// invariant rather than a line number: the three scheduler-pass values are
67/// absent in [`H2WritePhase::Start`], [`H2WritePhase::Resume`] and
68/// [`H2WritePhase::Ended`], present from the transition into
69/// [`H2WritePhase::Prepare`], and taken back at the first statement of
70/// [`H2WritePhase::End`], which then moves the pass to `Ended`.
71///
72/// Reaching this message means an accessor ran in one of those three phases.
73/// `Ended` exists so that a re-entry after the pass answered cannot be one of
74/// them: `End` releases into `None`, so without a separate terminal phase a
75/// second poll would re-enter `End` and `expect` on an empty `Option`.
76const SCHEDULER_PASS_EXPECT: &str = "the converter, order and census are adopted at the transition into \
77 H2WritePhase::Prepare and released at the first statement of \
78 H2WritePhase::End; only Start, Resume and Ended may observe them absent";
79
80/// Where in its pass [`ConnectionH2::poll_write_target`] is, and the only
81/// thing that tells a re-entry after a transmit apart from a first entry.
82///
83/// [`ConnectionH2::poll_write_target`]: super::h2::ConnectionH2::poll_write_target
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub(super) enum H2WritePhase {
86 /// Nothing has been examined yet. The next poll reads `expect_write` and
87 /// goes to either [`Self::Resume`] or, through
88 /// [`H2WritePass::adopt_scheduler_pass`], to [`Self::Prepare`].
89 ///
90 /// A fifth variant the pre-image had no room for: the parked-stream
91 /// decision needs `&mut ConnectionH2`, which [`H2WritePass::new`] does not
92 /// have, so it cannot be taken when the pass is built.
93 Start,
94 /// Flushing the stream `expect_write` parked on a previous pass. Drains
95 /// or stalls, and never enters the scheduler loop without draining first.
96 Resume {
97 stream_id: StreamId,
98 gid: GlobalStreamId,
99 /// `Some(amount)` when the SAME stream is also parked waiting for read
100 /// buffer space, so a drain that frees `amount` bytes re-arms
101 /// `Ready::READABLE`. Computed once, before the first transmit.
102 cross_read_amount: Option<usize>,
103 /// `Stream::state` as it was BEFORE the flush, which is when the
104 /// pre-image read it. Carried rather than re-read afterwards because
105 /// its consumer, `ConnectionH2::handle_1xx_reset`, decides whether to
106 /// re-arm `Ready::READABLE` on the linked token: a stream retired
107 /// mid-flush must not change which state that check sees.
108 stream_state: StreamState,
109 },
110 /// About to run the eligibility gate, `kawa.prepare`, the window debit and
111 /// `census.note_fired(urgency, ...)` for `order[cursor]`. Entered ONCE per stream: the
112 /// round-again after a partial write re-enters [`Self::Flush`], never
113 /// this, which is what keeps one pass to one prepare per stream.
114 Prepare { cursor: usize },
115 /// `order[cursor]` is prepared; only the flush remains. Re-entered after
116 /// every transmit, and the place the pre-image's
117 /// `while !kawa.out.is_empty()` now lives as a resumption state.
118 Flush {
119 cursor: usize,
120 stream_id: StreamId,
121 gid: GlobalStreamId,
122 /// `Stream::state` as it was before the flush — see
123 /// [`Self::Resume::stream_state`] for why the timing matters.
124 stream_state: StreamState,
125 /// `H2Scheduler::priority`'s answer for this stream, read once in
126 /// [`Self::Prepare`] exactly as the pre-image read it once per loop
127 /// iteration. Re-reading it here would be a second `HashMap` lookup
128 /// per stream per pass on the write hot path.
129 urgency: u8,
130 is_incremental: bool,
131 },
132 /// The scheduler loop is over: release the scheduler-pass values, retire,
133 /// account, and hand the pass's verdict back to the shell, which moves the
134 /// pass to [`Self::Ended`] on the way out.
135 End,
136 /// The pass has already answered. It holds no scheduler-pass value and can
137 /// produce no further work.
138 ///
139 /// A terminal phase rather than leaving [`Self::End`] in place: that arm
140 /// takes the converter, order and census out of the pass as its FIRST
141 /// statement, so re-entering it would `expect` on three empty `Option`s.
142 /// `H2Shell::write_streams` never re-polls — it returns on `Done` and
143 /// on `Finalize` — but `poll_write_target` is `pub`, so its callers are no
144 /// longer enumerable by reading this crate, and the read side's
145 /// `poll_read_target` already has direct unit tests, so a future caller
146 /// driving this core by hand is the likely one to find out.
147 Ended,
148}
149
150/// The `let` bindings of one `write_streams` pass, in the order the pre-image
151/// declared them, plus the phase and the three late-initialised values the
152/// inversion has to carry across the poll/handle boundary.
153pub struct H2WritePass {
154 /// Where the drive loop is. Every transition is made by
155 /// `ConnectionH2::poll_write_target` and by nothing else.
156 pub(super) phase: H2WritePhase,
157 /// Connection-wide `(bytes, streams)` totals used to distribute the
158 /// per-session overhead proportionally when a stream is recycled. Computed
159 /// once at pass start by `ConnectionH2::compute_stream_byte_totals` and
160 /// read by both the resume path and the scheduler loop.
161 pub(super) byte_totals: (usize, usize),
162 /// Whether the last transmit left the socket unable to take more, i.e.
163 /// `update_readiness_after_write`'s verdict — the ONLY place the write
164 /// core records a socket answer, and the sole input to the round-again
165 /// decision. Written by `ConnectionH2::handle_write` and read by
166 /// `ConnectionH2::poll_write_target`.
167 ///
168 /// Cleared at the top of every [`H2WritePhase::Prepare`], because the
169 /// pre-image re-assigned it from each `flush_stream_out` call and a stream
170 /// whose queue is empty flushed zero rounds and therefore read NOT
171 /// stalled. Left sticky, one stream's stall would silently terminate the
172 /// next stream's flush before its first transmit.
173 pub(super) stalled: bool,
174 /// Bytes the `expect_write` resume path moved. Deliberately SEPARATE from
175 /// [`Self::total_bytes_written`]: it never reaches `finalize_write`, so a
176 /// resume-only pass does not read as pass progress and
177 /// `h2_close::finalize_action` quiesces instead of retaining
178 /// `Ready::WRITABLE`. Fusing the two is a behaviour change, pinned by
179 /// `resume_path_bytes_do_not_make_the_pass_progress`.
180 pub(super) resume_bytes: usize,
181 /// Streams that finished their lifecycle during this pass, retired after
182 /// the loop rather than inside it: `try_recycle_server_stream` reads
183 /// `streams().len() == 1` to decide whether one stream inherits the whole
184 /// connection overhead pool, and retiring inline would let a later
185 /// completer of the same pass see that count while other streams are still
186 /// live.
187 pub(super) completed_streams: Vec<(StreamId, GlobalStreamId, Option<mio::Token>)>,
188 /// Whether any scheduler-loop flush reached the socket at all. Set by the
189 /// MAIN LOOP only — the resume path passes `None` for this flag — and read
190 /// by `finalize_write` to decide whether it owes its own flush.
191 pub(super) socket_write: bool,
192 /// Total outbound bytes emitted across all stream flushes this pass —
193 /// `finalize_write` uses this to distinguish a voluntary scheduler
194 /// yield (progress + pending back-buffer, LIFECYCLE §9 invariant 16)
195 /// from a no-progress wait state (e.g. flow-control starvation).
196 ///
197 /// Accumulated with a saturating add, never overwritten: a later stream
198 /// that writes nothing must not erase an earlier stream's progress, which
199 /// `a_later_zero_byte_stream_does_not_erase_the_pass_progress` pins.
200 pub(super) total_bytes_written: usize,
201 /// Collect every fresh RST_STREAM emitted via the converter
202 /// (`initialize` chokepoint or the HPACK over-budget abort path)
203 /// so we can run `account_emitted_rst` for each one AFTER the loop.
204 /// This is an ORDERING requirement, not a borrow workaround: a
205 /// MadeYouReset cap trip makes `account_emitted_rst` return a GOAWAY
206 /// result that ends the pass, and every stream in the pass's
207 /// `order` must get its write before that preemption.
208 pub(super) freshly_emitted_rsts: Vec<H2Error>,
209 /// Flow-control bytes the current stream's `kawa.prepare` consumed.
210 /// Hoisted out of the eligibility gate so the post-flush
211 /// flow-control-stall classification can see how many flow-control bytes
212 /// this pass moved. Reset at the top of every scheduler-loop iteration.
213 pub(super) consumed: i32,
214 /// Bytes the current stream's flush moved. Reset at the top of every
215 /// scheduler-loop iteration and folded into [`Self::total_bytes_written`]
216 /// once that stream's flush has ended.
217 pub(super) stream_bytes: usize,
218 /// The pass's HPACK converter, holding the three scratch buffers it took
219 /// out of `HpackState`. `None` until the scheduler pass opens, and `None`
220 /// again from the moment `H2WritePhase::End` releases it.
221 converter: Option<H2ConverterPass>,
222 /// The scheduler's loaned ordering buffer for this pass, walked by cursor.
223 order: Option<Vec<StreamId>>,
224 /// The pass's same-urgency ready-peer census, returned to
225 /// `H2Scheduler::end_pass` with [`Self::order`].
226 census: Option<ReadyIncrementalCensus>,
227}
228
229/// A tripwire, not the guarantee.
230///
231/// The guarantee that the converter's three pooled buffers reach
232/// `HpackState` again is structural: between
233/// `Self::adopt_scheduler_pass` and the first statement of
234/// `H2WritePhase::End`, `ConnectionH2::poll_write_target` has exactly ONE
235/// `return` — the `Transmit` yield — and `H2Shell::write_streams`' drive
236/// loop answers every `Transmit` and leaves only on `Done` or `Finalize`. So
237/// no pass can end while `Self::converter` is `Some`.
238///
239/// This catches the one way that could stop being true: a future caller of the
240/// now-`pub` `poll_write_target` that stops driving after a `Transmit` — a
241/// caller this crate no longer enumerates by reading its own call sites. The
242/// other way — a caller that polls once MORE after the pass answered — is
243/// closed by `H2WritePhase::Ended` instead, and needs no tripwire because
244/// the converter is already `None` by then.
245/// Dropping the converter there loses the buffers permanently and silently —
246/// `HpackState` simply re-grows three `Vec`s on every pass from then on, with
247/// no error and no metric. `debug_assert!` puts that in the test suite's reach
248/// instead; `thread::panicking()` keeps a real test failure legible rather
249/// than aborting the process on a double panic.
250impl Drop for H2WritePass {
251 fn drop(&mut self) {
252 debug_assert!(
253 std::thread::panicking() || self.converter.is_none(),
254 "a write pass ended holding its converter: the three HPACK scratch \
255 buffers it took out of HpackState are about to be dropped instead \
256 of returned. A caller stopped driving poll_write_target after an \
257 H2WriteTarget::Transmit."
258 );
259 }
260}
261
262impl H2WritePass {
263 /// Begin a pass over `byte_totals`, the connection-wide
264 /// `(bytes, streams)` pair the recycle path distributes overhead against.
265 ///
266 /// Every other field starts empty or zero, exactly as its pre-image `let`
267 /// did. Hoisting those initialisations to pass start is inert: an empty
268 /// `Vec` allocates nothing, and nothing reads any of them before the
269 /// statement that used to declare them.
270 pub fn new(byte_totals: (usize, usize)) -> Self {
271 Self {
272 phase: H2WritePhase::Start,
273 byte_totals,
274 stalled: false,
275 resume_bytes: 0,
276 completed_streams: Vec::new(),
277 socket_write: false,
278 total_bytes_written: 0,
279 freshly_emitted_rsts: Vec::new(),
280 consumed: 0,
281 stream_bytes: 0,
282 converter: None,
283 order: None,
284 census: None,
285 }
286 }
287
288 /// Open the scheduler half of the pass, taking ownership of the three
289 /// values that could not be built when the pass was.
290 ///
291 /// Called exactly once, at the transition into [`H2WritePhase::Prepare`],
292 /// from either [`H2WritePhase::Start`] (nothing was parked) or
293 /// [`H2WritePhase::Resume`] (the parked stream drained). Everything after
294 /// it may use the accessors below; nothing before it may.
295 pub(super) fn adopt_scheduler_pass(
296 &mut self,
297 converter: H2ConverterPass,
298 order: Vec<StreamId>,
299 census: ReadyIncrementalCensus,
300 ) {
301 debug_assert!(
302 self.converter.is_none() && self.order.is_none() && self.census.is_none(),
303 "a write pass opened its scheduler pass twice"
304 );
305 self.converter = Some(converter);
306 self.order = Some(order);
307 self.census = Some(census);
308 }
309
310 /// Close the scheduler half of the pass and hand its three values back.
311 ///
312 /// Called exactly once, as the FIRST statement of [`H2WritePhase::End`] —
313 /// before any `return` that arm can take. The MadeYouReset cap trip
314 /// drops the converter's buffers, because it returns
315 /// between `H2ConverterPass::into_buffers` and the `put_*` calls exactly
316 /// as the pre-image did. The caller moves the pass to
317 /// [`H2WritePhase::Ended`] in the same breath, so "exactly once" is
318 /// enforced by the phase and not only by inspection.
319 pub(super) fn release_scheduler_pass(
320 &mut self,
321 ) -> (H2ConverterPass, Vec<StreamId>, ReadyIncrementalCensus) {
322 (
323 self.converter.take().expect(SCHEDULER_PASS_EXPECT),
324 self.order.take().expect(SCHEDULER_PASS_EXPECT),
325 self.census.take().expect(SCHEDULER_PASS_EXPECT),
326 )
327 }
328
329 /// The pass's HPACK converter, for the ONE `kawa.prepare` of one stream.
330 pub(super) fn converter_mut(&mut self) -> &mut H2ConverterPass {
331 self.converter.as_mut().expect(SCHEDULER_PASS_EXPECT)
332 }
333
334 /// The scheduler's ordering for this pass. The cursor in
335 /// [`H2WritePhase::Prepare`] indexes exactly this slice, so a cursor past
336 /// its end is what ends the scheduler loop.
337 pub(super) fn order(&self) -> &[StreamId] {
338 self.order.as_deref().expect(SCHEDULER_PASS_EXPECT)
339 }
340
341 /// The pass's same-urgency ready-peer census, read for
342 /// `incremental_peer_count` and written by `note_fired` /
343 /// `note_ineligible` — both of which take the urgency, so every count
344 /// and every RFC 9218 §4 leader it holds is per bucket.
345 pub(super) fn census_mut(&mut self) -> &mut ReadyIncrementalCensus {
346 self.census.as_mut().expect(SCHEDULER_PASS_EXPECT)
347 }
348}