Skip to main content

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}