Skip to main content

trillium_http/h2/
transport.rs

1//! Per-stream transport handed to handler tasks.
2//!
3//! [`H2Transport`] is the [`AsyncRead`] + [`AsyncWrite`] view of a single HTTP/2 stream. It is
4//! carried on the emitted [`Conn`][crate::Conn] returned from [`H2Driver::next`], and the
5//! runtime adapter spawns a handler task that consumes it. The transport never touches the
6//! underlying TCP connection directly — all I/O coordinates through shared per-stream state
7//! on the [`H2Connection`] driven by the driver task.
8//!
9//! Two paths reach the impls:
10//!
11//! - **Normal HTTP/2 request/response**: handlers usually don't touch [`H2Transport`] directly
12//!   (same sharp edge h1 and h3 document). [`ReceivedBody`][crate::ReceivedBody] reads request body
13//!   bytes through the transport's `AsyncRead`. Response bytes are handed to the driver as a queue
14//!   of [`OutboundPart`]s via [`H2Connection::submit_send`][submit_send]; the send pump frames them
15//!   without ever touching this `AsyncWrite`.
16//!
17//! - **Extended-CONNECT upgrades** ([RFC 8441] WebSocket-over-h2, plus the in-progress
18//!   `draft-ietf-webtrans-http2` for WebTransport-over-h2): after the handler responds 200 to a
19//!   `CONNECT` request with a `:protocol` pseudo-header, [`Conn::send_h2`][crate::Conn::send_h2]
20//!   routes through [`H2Connection::submit_upgrade`][submit_upgrade], which enqueues HEADERS (and
21//!   an optional prelude body) *without* a terminating [`OutboundPart::Close`], leaving the stream
22//!   open as a bidirectional byte channel. The runtime adapter then dispatches
23//!   [`Handler::upgrade`][trillium::Handler::upgrade], which gets an [`Upgrade`][crate::Upgrade]
24//!   wrapping this transport. `AsyncWrite::poll_write` appends to a per-stream outbound ring
25//!   ([`SendState::outbound`]); the send pump drains it into DATA frames bounded by the per-stream
26//!   and connection send windows. `AsyncWrite::poll_close` enqueues [`OutboundPart::Close`] so the
27//!   driver emits the `END_STREAM` terminator and tears the stream down.
28//!
29//! [`H2Driver::next`]: super::H2Driver::next
30//! [`H2Connection`]: super::H2Connection
31//! [submit_send]: super::H2Connection::submit_send
32//! [submit_upgrade]: super::H2Connection::submit_upgrade
33//! [RFC 8441]: https://www.rfc-editor.org/rfc/rfc8441
34
35use super::{
36    H2Connection, H2ErrorCode,
37    stream_state::{StreamEvent, StreamLifecycle},
38};
39use crate::{
40    Body, Buffer, Headers,
41    headers::hpack::{FieldSection, PseudoHeaders},
42};
43use atomic_waker::AtomicWaker;
44use futures_lite::io::{AsyncRead, AsyncWrite};
45use std::{
46    collections::VecDeque,
47    fmt, io,
48    pin::Pin,
49    sync::{
50        Arc, Mutex, MutexGuard,
51        atomic::{AtomicBool, AtomicU64, Ordering},
52    },
53    task::{Context, Poll},
54};
55
56/// A single HTTP/2 stream's transport handle.
57///
58/// Carries a backref to the shared [`H2Connection`], the stream id, and the per-stream
59/// `Arc<StreamState>` used by the read and write sides. Normal HTTP/2 operation reads through
60/// [`ReceivedBody`][crate::ReceivedBody] and writes through the driver's send queue; the
61/// `AsyncRead` / `AsyncWrite` impls here are only reached by code that borrows the transport
62/// directly (typically an upgrade handler after extended CONNECT).
63pub struct H2Transport {
64    connection: Arc<H2Connection>,
65    stream_id: u32,
66    state: Arc<StreamState>,
67}
68
69impl fmt::Debug for H2Transport {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.debug_struct("H2Transport")
72            .field("stream_id", &self.stream_id)
73            .finish_non_exhaustive()
74    }
75}
76
77impl H2Transport {
78    /// Create a transport for a stream that has just been opened by the driver.
79    pub(super) fn new(
80        connection: Arc<H2Connection>,
81        stream_id: u32,
82        state: Arc<StreamState>,
83    ) -> Self {
84        Self {
85            connection,
86            stream_id,
87            state,
88        }
89    }
90
91    /// The stream identifier this transport is bound to.
92    pub fn stream_id(&self) -> u32 {
93        self.stream_id
94    }
95
96    /// The shared [`H2Connection`] backing this stream.
97    pub fn connection(&self) -> &Arc<H2Connection> {
98        &self.connection
99    }
100}
101
102impl Drop for H2Transport {
103    /// Application-side release / cancel signal, decided from the stream's protocol state plus
104    /// whether a response has been handed off:
105    ///
106    /// - **Already removed from the shared map** — the driver beat us to cleanup; no-op.
107    /// - **`Closed`** (both halves done on the wire; client-role streams linger in the map for
108    ///   post-EOF trailer/response access) — set `SendState::transport_dropped` so the driver GCs
109    ///   the lingering entry.
110    /// - **Send half still open + a response was submitted** (`submit_resolved`) — a bidirectional
111    ///   upgrade tunnel the handler is dropping; enqueue `OutboundPart::Close` for a graceful
112    ///   `END_STREAM`.
113    /// - **Anything else** (`HalfClosedLocal` awaiting the peer, or send-open with no response ever
114    ///   submitted) — abandoning the stream; enqueue `RST_STREAM(Cancel)`.
115    fn drop(&mut self) {
116        if !self.connection.streams_lock().contains_key(&self.stream_id) {
117            log::trace!(
118                "h2 stream {}: H2Transport dropped on already-released stream",
119                self.stream_id,
120            );
121            return;
122        }
123
124        let lifecycle = *self.state.lifecycle_lock();
125        if lifecycle.is_closed() {
126            log::trace!(
127                "h2 stream {}: H2Transport dropped on wire-closed stream — releasing",
128                self.stream_id,
129            );
130            self.state
131                .send
132                .transport_dropped
133                .store(true, Ordering::Release);
134        } else if !lifecycle.send_closed()
135            && self.state.send.submit_resolved.load(Ordering::Acquire)
136        {
137            // Send half open and a response is on the wire: an upgrade tunnel being dropped.
138            // Graceful close.
139            log::trace!(
140                "h2 stream {}: H2Transport dropped (upgrade tunnel) — scheduling graceful close",
141                self.stream_id,
142            );
143            self.state.request_close();
144        } else {
145            // Send half closed awaiting the peer, or never-responded: abandon the stream.
146            log::debug!(
147                "h2 stream {}: H2Transport dropped mid-stream — RST_STREAM(Cancel)",
148                self.stream_id,
149            );
150            self.state.request_reset(H2ErrorCode::Cancel);
151        }
152        self.state.send.outbound_write_waker.wake();
153        self.connection.signal_driver(self.stream_id, &self.state);
154    }
155}
156
157impl AsyncRead for H2Transport {
158    fn poll_read(
159        self: Pin<&mut Self>,
160        cx: &mut Context<'_>,
161        out: &mut [u8],
162    ) -> Poll<io::Result<usize>> {
163        if out.is_empty() {
164            return Poll::Ready(Ok(0));
165        }
166
167        // The first `poll_read` is the handler's declaration of intent to consume the request
168        // body — until this point, we've advertised a zero recv window and the peer has sent
169        // nothing beyond HEADERS. Tell the driver to top up our per-stream window now. Later
170        // calls CAS-fail silently and don't re-signal.
171        let recv_state = &self.state.recv;
172        let connection = &*self.connection;
173        if !recv_state.is_reading.swap(true, Ordering::AcqRel) {
174            connection.signal_driver(self.stream_id, &self.state);
175        }
176
177        let mut recv = recv_state.buf.lock().expect("recv buf mutex poisoned");
178
179        // Copy as many bytes as fit from the front of the ring into `out`, then advance the
180        // ring's virtual read cursor. `Buffer::ignore_front` truncates the underlying `Vec` to
181        // zero when we drain fully, so capacity stays bounded by peak in-flight bytes rather
182        // than cumulative traffic.
183        let take = out.len().min(recv.len());
184        if take > 0 {
185            out[..take].copy_from_slice(&recv[..take]);
186            recv.ignore_front(take);
187            drop(recv);
188            recv_state
189                .bytes_consumed
190                .fetch_add(take as u64, Ordering::AcqRel);
191            connection.signal_driver(self.stream_id, &self.state);
192            return Poll::Ready(Ok(take));
193        }
194
195        // Buffer empty. Register the waker *before* releasing the buf lock so a driver push
196        // between this poll and the recv-closed check is guaranteed to wake us:
197        //   1. We take buf lock (driver-push blocked).
198        //   2. We register waker.
199        //   3. We drop buf lock (driver-push may now proceed and fire waker).
200        //   4. We read recv-closed from the lifecycle.
201        //   5. Return Pending or Ready(0); if a push raced through step 3, the waker is registered
202        //      and a fresh poll will see the new bytes.
203        recv_state.waker.register(cx.waker());
204        drop(recv);
205        if self.state.lifecycle_lock().recv_closed() {
206            return Poll::Ready(Ok(0));
207        }
208        Poll::Pending
209    }
210}
211
212impl AsyncWrite for H2Transport {
213    fn poll_write(
214        self: Pin<&mut Self>,
215        cx: &mut Context<'_>,
216        buf: &[u8],
217    ) -> Poll<io::Result<usize>> {
218        // Parity with h1/h3: writing to a stream whose send half is already closed (clean
219        // `END_STREAM`, or reset) is a `BrokenPipe` rather than silently swallowed bytes.
220        // Otherwise we always accept the write — the "don't write at an inappropriate time"
221        // contract is the caller's, same as borrowing the raw transport in h1/h3.
222        if self.state.lifecycle_lock().send_closed() {
223            return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
224        }
225
226        // Append into the per-stream outbound ring. The send pump drains the same ring into DATA
227        // frames bounded by per-stream + connection send windows. Bounded by
228        // `response_buffer_max_len` (the cap h1 and h3 use for their transit buffers): a stalled
229        // peer window means the driver can't drain, the cap is hit, and we return `Pending` so the
230        // handler is throttled. The drain side wakes `outbound_write_waker` after each take.
231        let send = &self.state.send;
232        let cap = self.connection.context.config.response_buffer_max_len;
233        let mut outbound = send.outbound.lock().expect("outbound buf mutex poisoned");
234        if outbound.len() >= cap {
235            // Register first, then re-check under lock to close the race against the drain side
236            // (which takes the same lock to `ignore_front` and then wakes us).
237            send.outbound_write_waker.register(cx.waker());
238            if outbound.len() >= cap {
239                return Poll::Pending;
240            }
241        }
242        let take = (cap - outbound.len()).min(buf.len());
243        log::trace!(
244            "h2 stream {}: H2Transport::poll_write appending {take}/{} bytes to outbound ring",
245            self.stream_id,
246            buf.len(),
247        );
248        outbound.extend_live(&buf[..take]);
249        drop(outbound);
250
251        // Wake the driver (parked on the connection-level waker).
252        self.connection.outbound_waker().wake();
253        Poll::Ready(Ok(take))
254    }
255
256    fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
257        // Best-effort: bytes appended via `poll_write` are already visible to the driver and will
258        // be framed on the next tick. There's no application-level "flushed" state below us.
259        Poll::Ready(Ok(()))
260    }
261
262    fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
263        // Mark the write half closed by enqueuing the `END_STREAM` terminator. Once the driver
264        // drains the remaining outbound ring bytes it frames the terminator and tears the stream
265        // down. Idempotent — `request_close` is a no-op if a terminator is already queued or the
266        // send half is already closed.
267        log::trace!(
268            "h2 stream {}: H2Transport::poll_close enqueuing Close",
269            self.stream_id,
270        );
271        self.state.request_close();
272        self.state.send.outbound_write_waker.wake();
273        self.connection.signal_driver(self.stream_id, &self.state);
274        Poll::Ready(Ok(()))
275    }
276}
277
278/// A unit of outbound work the conn task hands the driver, framed in order by the send pump.
279///
280/// `Headers` opens the response/request; `Body` is an owned source the driver frames lazily under
281/// flow control (no intermediate buffering); `Trailers` and `Close` are alternative `END_STREAM`
282/// terminators; `Reset` abandons the stream (the conn task clears the rest of the queue when it
283/// pushes one — nothing else is valid to send after `RST_STREAM`).
284///
285/// Streaming bytes a handler writes through [`H2Transport`]'s `AsyncWrite` do *not* go here — they
286/// flow through the reused [`SendState::outbound`] ring, which the pump drains before framing a
287/// terminator.
288#[derive(Debug)]
289pub(super) enum OutboundPart {
290    /// Initial HEADERS block — HPACK-encoded by the driver at frame time so the wire order
291    /// matches the dynamic-table mutation order. Shared, so a submitter that keeps its headers
292    /// readable after sending doesn't have to copy them.
293    Headers {
294        pseudos: PseudoHeaders<'static>,
295        headers: Arc<Headers>,
296    },
297    /// An owned body source, framed directly into DATA frames under flow control.
298    Body(Body),
299    /// Trailing HEADERS — carries `END_STREAM`.
300    Trailers(Headers),
301    /// Empty `DATA(END_STREAM)` terminator.
302    Close,
303    /// `RST_STREAM(code)` — conn-task-initiated reset; clears any preceding queued parts.
304    Reset(H2ErrorCode),
305}
306
307impl OutboundPart {
308    /// `true` for the `END_STREAM`/`RST_STREAM` terminators — used by `request_close` to stay
309    /// idempotent and by the send pump to know the ring must be drained first.
310    pub(super) fn is_terminal(&self) -> bool {
311        matches!(self, Self::Trailers(_) | Self::Close | Self::Reset(_))
312    }
313}
314
315/// Shared per-stream state. Owned by an [`Arc`] held jointly by the driver (via the connection's
316/// stream table) and the handler task (via [`H2Transport`]).
317///
318/// The cross-task-visible protocol state machine is the [`StreamLifecycle`] in [`Self::lifecycle`]
319/// — written only by the driver (which feeds it [`StreamEvent`]s as it frames/receives) and read by
320/// both sides. Everything else is data and mailbox plumbing: the recv ring, the outbound parts
321/// queue + streaming ring, and the conn-task → driver signals.
322#[derive(Debug, Default)]
323pub(super) struct StreamState {
324    /// Protocol state (RFC 9113 §5.1). Driver is the sole writer; the handler side reads it for
325    /// recv-EOF (`poll_read`) and send-closed (`poll_write`) decisions. Held under a `Mutex` so
326    /// observe-then-act sequences are atomic.
327    lifecycle: Mutex<StreamLifecycle>,
328
329    /// Recv side: inbound DATA payloads, handler waker, handler-intent signal, trailers.
330    pub(super) recv: RecvState,
331
332    /// Send side: the outbound parts queue, the streaming ring, the driver→conn completion
333    /// channel, and the application-release signal.
334    pub(super) send: SendState,
335
336    /// Whether this stream is already in the connection's queue of signaled streams. Set by
337    /// [`H2Connection::signal_driver`] when it queues the stream, which conn-task code calls
338    /// whenever it produces work for the driver (enqueue a part, raise `is_reading`, increment
339    /// `bytes_consumed`, set `transport_dropped`); cleared by the driver when it takes the stream
340    /// off the queue, *before* it reads the stream's mailbox. So a signal racing the driver
341    /// either lands in what the driver reads or queues the stream again.
342    ///
343    /// Only `signal_driver` may set it: a stream whose flag is set without being queued would
344    /// never be queued again.
345    pub(super) needs_servicing: AtomicBool,
346}
347
348impl StreamState {
349    /// Lock the per-stream protocol lifecycle. Convenience wrapper — every site treats poisoning as
350    /// a programming error.
351    pub(super) fn lifecycle_lock(&self) -> MutexGuard<'_, StreamLifecycle> {
352        self.lifecycle.lock().expect("lifecycle mutex poisoned")
353    }
354
355    /// Apply a [`StreamEvent`] to the lifecycle. Driver-only — the sole writer of protocol state.
356    pub(super) fn apply_event(
357        &self,
358        event: StreamEvent,
359    ) -> Result<(), super::stream_state::StreamProtocolError> {
360        self.lifecycle_lock().on_event(event)
361    }
362
363    /// Stage a full submission of outbound parts atomically, so the send pump sees a complete
364    /// message rather than a partial one (the `SubmitSend` future keys "done" off the queue
365    /// draining to empty). The caller signals the driver.
366    pub(super) fn stage(&self, parts: impl IntoIterator<Item = OutboundPart>) {
367        self.send
368            .queue
369            .lock()
370            .expect("send queue mutex poisoned")
371            .extend(parts);
372    }
373
374    /// Enqueue the `END_STREAM` terminator unless one (or a reset) is already queued or the send
375    /// half is already closed. Idempotent — safe to call from repeated `poll_close` / Drop. The
376    /// caller signals the driver.
377    pub(super) fn request_close(&self) {
378        if self.lifecycle_lock().send_closed() {
379            return;
380        }
381        let mut queue = self.send.queue.lock().expect("send queue mutex poisoned");
382        if queue.back().is_none_or(|p| !p.is_terminal()) {
383            queue.push_back(OutboundPart::Close);
384        }
385    }
386
387    /// Clear any queued parts and request `RST_STREAM(code)`. First-wins: a reset already at the
388    /// back keeps its code. Nothing else is valid to send after a reset, so clearing the queue
389    /// models the §5.1 sequence faithfully. The caller signals the driver.
390    pub(super) fn request_reset(&self, code: H2ErrorCode) {
391        let mut queue = self.send.queue.lock().expect("send queue mutex poisoned");
392        if matches!(queue.back(), Some(OutboundPart::Reset(_))) {
393            return;
394        }
395        queue.clear();
396        queue.push_back(OutboundPart::Reset(code));
397    }
398}
399
400/// Receive-side per-stream state.
401#[derive(Debug, Default)]
402pub(super) struct RecvState {
403    /// Inbound DATA body bytes awaiting handler read. A single persistent ring (append-at-tail,
404    /// `ignore_front`-at-head): the driver appends via `extend_live` when a DATA frame
405    /// arrives; the handler reads from the front and virtually drops consumed bytes. When
406    /// `ignore_front` catches up to the data end the `Buffer` truncates to zero, so the underlying
407    /// `Vec` capacity stays bounded by peak in-flight bytes rather than cumulative traffic — zero
408    /// amortized allocations per DATA frame.
409    pub(super) buf: Mutex<Buffer>,
410
411    /// Handler-task waker, fired by the driver after pushing DATA into `buf` or after the
412    /// lifecycle transitions to recv-closed. Single-waiter: only one task ever polls a given
413    /// `H2Transport`.
414    pub(super) waker: AtomicWaker,
415
416    /// Set by the handler's first [`H2Transport::poll_read`] to declare intent to consume the
417    /// request body. The driver observes the transition and emits a `WINDOW_UPDATE` for this
418    /// stream, topping its recv window up from `SETTINGS_INITIAL_WINDOW_SIZE` (advertised as `0`)
419    /// to the per-stream maximum. Once set, stays set.
420    pub(super) is_reading: AtomicBool,
421
422    /// Bytes the handler has consumed from `buf` since the driver last sampled this counter.
423    /// Incremented by [`H2Transport::poll_read`] using `fetch_add` after each drain; the driver
424    /// reads it via `swap(0)` per tick and emits stream-level + connection-level `WINDOW_UPDATE`
425    /// for the consumed total.
426    pub(super) bytes_consumed: AtomicU64,
427
428    /// Trailers, populated by the driver if a trailing HEADERS frame arrives for this stream.
429    /// Always written *before* the lifecycle transitions to recv-closed, so once the handler
430    /// observes `Ready(0)` on the recv side, any trailers for this request are guaranteed to
431    /// be in place. Boxed, like `response_headers`: every stream carries the slot, but few streams
432    /// fill it, and an inline `Headers` would grow every stream's state for them.
433    pub(super) trailers: Mutex<Option<Box<Headers>>>,
434
435    /// Client-role: response HEADERS field section, populated by the driver on the first non-1xx
436    /// HEADERS frame arrival for a client-initiated stream. Server role doesn't use this slot.
437    /// Single-shot: the conn task takes the `FieldSection` once; subsequent HEADERS arrivals are
438    /// interpreted as trailers. Interim 1xx HEADERS frames are discarded by the driver without
439    /// touching this slot or latching `first_response_headers_seen`. Boxed so that server
440    /// streams, which never fill it, don't carry a `FieldSection`'s worth of empty space.
441    pub(super) response_headers: Mutex<Option<Box<FieldSection<'static>>>>,
442
443    /// Client-role: latching flag for "first HEADERS arrived for this stream." Distinct from
444    /// `response_headers.is_some()` — the conn task drains that slot when it consumes headers, so
445    /// the driver can't use slot occupancy to distinguish "haven't seen HEADERS yet" from "headers
446    /// seen + already taken." Set inside `finalize_response_headers` before that slot is
447    /// populated; checked by `route_headers` on subsequent HEADERS to route them as trailers.
448    /// Never cleared.
449    pub(super) first_response_headers_seen: AtomicBool,
450
451    /// Client-role: waker the conn task registers via
452    /// [`H2Connection::response_headers`][super::H2Connection]; fired by the driver after stashing
453    /// the `FieldSection` *or* on stream removal (so a parked conn task observing the stream gone
454    /// surfaces `NotConnected` instead of hanging).
455    pub(super) response_headers_waker: AtomicWaker,
456}
457
458/// Send-side per-stream state: the conn-task → driver outbound parts queue, the streaming ring for
459/// bidirectional upgrades, the driver → conn-task completion channel, and the application-release
460/// signal.
461///
462/// **Normal response path**: the conn task `stage`s `[Headers, Body?, Close]` once via
463/// [`H2Connection::submit_send`][submit] and waits on `completion_waker` for `submit_resolved` to
464/// flip. The driver drains the queue into its private send cursor, frames the parts, and on the
465/// queue draining to empty stores `completion_result`, sets `submit_resolved = true`, and wakes the
466/// conn task.
467///
468/// **Extended-CONNECT upgrade path** ([RFC 8441]): the conn task `stage`s `[Headers, Body?]` with
469/// *no* `Close`, so the stream stays open after the prelude frames. `submit_resolved` flips when
470/// the queue first drains (i.e. once the prelude is on the wire) — matching h1/h3, not at
471/// `END_HEADERS` — so `submit_upgrade().await` returns and the runtime can dispatch
472/// [`Handler::upgrade`][trillium::Handler::upgrade]. The handler then writes through
473/// [`H2Transport`]'s `AsyncWrite`, which appends to `outbound`; the send pump drains that ring into
474/// DATA. `poll_close` enqueues [`OutboundPart::Close`], the pump drains the ring, frames the
475/// terminator, and tears the stream down.
476///
477/// [submit]: super::H2Connection::submit_send
478/// [RFC 8441]: https://www.rfc-editor.org/rfc/rfc8441
479#[derive(Debug, Default)]
480pub(super) struct SendState {
481    /// Outbound parts the conn task has handed off, drained by the driver into its private send
482    /// cursor when the stream is signaled. Cold: a handful of pushes per stream lifetime.
483    pub(super) queue: Mutex<VecDeque<OutboundPart>>,
484
485    /// Streaming bytes for a bidirectional upgrade: [`H2Transport`]'s `AsyncWrite::poll_write`
486    /// appends here, the send pump drains it into DATA frames before framing a terminator. A
487    /// single reused ring (`ignore_front` at head) — empty for normal responses, which frame an
488    /// owned [`OutboundPart::Body`] directly.
489    pub(super) outbound: Mutex<Buffer>,
490
491    /// Reverse-direction backpressure waker: registered by [`H2Transport::poll_write`] when
492    /// `outbound` hits the cap, fired by the send pump after it drains bytes so a parked writer
493    /// can resume. Without it a slow/unresponsive peer's closed window would let `outbound`
494    /// grow unbounded.
495    pub(super) outbound_write_waker: AtomicWaker,
496
497    /// Set to `true` by the driver once the conn task's [`SubmitSend`][super::SubmitSend] future
498    /// may resolve and its `completion_result` is readable. The future polls this atomic and
499    /// registers on `completion_waker`. Flips when the outbound queue first drains to empty —
500    /// `END_STREAM` for a normal response, the prelude-handoff for an upgrade.
501    pub(super) submit_resolved: AtomicBool,
502
503    /// The driver writes the final result here before flipping `submit_resolved`. The conn task
504    /// takes it once `submit_resolved` is observed true.
505    pub(super) completion_result: Mutex<Option<io::Result<()>>>,
506
507    /// The conn task's waker, registered by `SubmitSend::poll` and fired by the driver after
508    /// `submit_resolved` is set.
509    pub(super) completion_waker: AtomicWaker,
510
511    /// Set by [`H2Transport::Drop`] when the lifecycle is already `Closed` and the application is
512    /// releasing a stream that lingered in the map for post-EOF access (client role). The driver
513    /// picks it up and removes the entry from both maps. Not protocol state — a local
514    /// resource-ownership fact the lifecycle can't represent.
515    pub(super) transport_dropped: AtomicBool,
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::HttpContext;
522
523    #[test]
524    fn request_close_enqueues_single_close() {
525        let state = StreamState::default();
526        *state.lifecycle_lock() = StreamLifecycle::Open;
527        state.request_close();
528        state.request_close();
529        let queue = state.send.queue.lock().unwrap();
530        assert_eq!(queue.len(), 1, "second request_close is a no-op");
531        assert!(matches!(queue.front(), Some(OutboundPart::Close)));
532    }
533
534    #[test]
535    fn request_close_noop_when_send_closed() {
536        let state = StreamState::default();
537        *state.lifecycle_lock() = StreamLifecycle::HalfClosedLocal;
538        state.request_close();
539        assert!(
540            state.send.queue.lock().unwrap().is_empty(),
541            "no terminator queued once the send half is closed"
542        );
543    }
544
545    #[test]
546    fn request_reset_clears_queue_and_is_first_wins() {
547        let state = StreamState::default();
548        *state.lifecycle_lock() = StreamLifecycle::Open;
549        state.stage([OutboundPart::Body(Body::default()), OutboundPart::Close]);
550        state.request_reset(H2ErrorCode::Cancel);
551        state.request_reset(H2ErrorCode::InternalError);
552        let queue = state.send.queue.lock().unwrap();
553        assert_eq!(queue.len(), 1, "queue cleared, single reset");
554        assert!(
555            matches!(
556                queue.front(),
557                Some(OutboundPart::Reset(H2ErrorCode::Cancel))
558            ),
559            "first reset code wins",
560        );
561    }
562
563    #[test]
564    fn poll_write_caps_at_response_buffer_max_len() {
565        use futures_lite::AsyncWrite;
566        use std::task::{Context, Poll, Waker};
567        let mut context = HttpContext::new();
568        context.config.response_buffer_max_len = 16;
569        let connection = H2Connection::new(Arc::new(context));
570        let state = Arc::new(StreamState::default());
571        *state.lifecycle_lock() = StreamLifecycle::Open;
572        let mut transport = H2Transport::new(connection, 1, state);
573        let waker = Waker::noop();
574        let mut cx = Context::from_waker(waker);
575        let buf = [0u8; 32];
576        match Pin::new(&mut transport).poll_write(&mut cx, &buf) {
577            Poll::Ready(Ok(n)) => assert_eq!(n, 16, "should accept exactly cap bytes"),
578            other => panic!("expected Ready(Ok(16)), got {other:?}"),
579        }
580    }
581}