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}