Skip to main content

trillium_http/
upgrade.rs

1use crate::{
2    Buffer, Conn, Headers, HttpContext, KnownHeaderName, Method, ProtocolSession, ReceivedBody,
3    Status, TypeSet, Version,
4    h2::H2Connection,
5    h3::{Frame, H3Connection},
6    headers::qpack::{FieldSection, PseudoHeaders},
7    received_body::{H3TrailerFuture, ReceivedBodyState, write_chunk},
8    util::encoding,
9};
10use encoding_rs::Encoding;
11use fieldwork::Fieldwork;
12use futures_lite::{
13    AsyncWriteExt,
14    io::{AsyncRead, AsyncWrite},
15};
16use std::{
17    borrow::Cow,
18    fmt::{self, Debug, Formatter},
19    io::{self, IoSlice, Write},
20    net::IpAddr,
21    pin::Pin,
22    str,
23    sync::Arc,
24    task::{Context, Poll, ready},
25    time::Instant,
26};
27
28/// Per-protocol outbound framing state for an [`Upgrade`], chosen at the upgrade
29/// transition.
30#[derive(Debug)]
31pub(crate) enum WriteState {
32    /// No framing on the `AsyncWrite` path. HTTP/1.1 without chunked encoding (raw
33    /// passthrough) and HTTP/2 (framed at the connection layer).
34    Raw,
35    /// HTTP/1.1 chunked transfer-encoding.
36    H1Chunked(H1ChunkedState),
37    /// HTTP/3 DATA-frame encoding.
38    H3Framed(H3FramedState),
39}
40
41#[derive(Debug, Default)]
42pub(crate) struct H1ChunkedState {
43    pub(crate) pending: Vec<u8>,
44    pub(crate) terminator_written: bool,
45}
46
47#[derive(Debug, Default)]
48pub(crate) struct H3FramedState {
49    pub(crate) pending: Vec<u8>,
50    pub(crate) terminator_written: bool,
51}
52
53/// Pick outbound framing from http version and the outbound headers' `Transfer-Encoding`.
54/// h3 is always DATA-framed; h1 chunks only when the headers request it; h2 is framed by
55/// the connection driver, so the `AsyncWrite` path stays raw.
56fn compute_write_state(version: Version, outbound_headers: &Headers) -> WriteState {
57    match version {
58        Version::Http1_0 | Version::Http1_1 if has_chunked_encoding(outbound_headers) => {
59            WriteState::H1Chunked(H1ChunkedState::default())
60        }
61        Version::Http3 => WriteState::H3Framed(H3FramedState::default()),
62        _ => WriteState::Raw,
63    }
64}
65
66/// True if `Transfer-Encoding` includes `chunked`. Tolerant of multi-codings like
67/// `gzip, chunked`; no ordering enforcement.
68fn has_chunked_encoding(headers: &Headers) -> bool {
69    headers
70        .token_iter(KnownHeaderName::TransferEncoding)
71        .any(|coding| coding.eq_ignore_ascii_case("chunked"))
72}
73
74/// Parse the inbound `Content-Length`. `None` for chunked, missing, or malformed.
75fn parse_content_length(inbound_headers: &Headers) -> Option<u64> {
76    if inbound_headers.has_header(KnownHeaderName::TransferEncoding) {
77        return None;
78    }
79    inbound_headers.content_length()
80}
81
82/// Drain `pending` to `transport`, returning `Pending` if the transport blocks.
83fn poll_drain_pending<T: AsyncWrite + Unpin>(
84    pending: &mut Vec<u8>,
85    cx: &mut Context<'_>,
86    transport: &mut T,
87) -> Poll<io::Result<()>> {
88    while !pending.is_empty() {
89        match Pin::new(&mut *transport).poll_write(cx, pending) {
90            Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())),
91            Poll::Ready(Ok(n)) => {
92                pending.drain(..n);
93            }
94            Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
95            Poll::Pending => return Poll::Pending,
96        }
97    }
98    Poll::Ready(Ok(()))
99}
100
101/// Drain `pending` until the transport blocks or `pending` is empty, without yielding
102/// `Pending`. The next call resumes the drain.
103fn best_effort_drain<T: AsyncWrite + Unpin>(
104    pending: &mut Vec<u8>,
105    cx: &mut Context<'_>,
106    transport: &mut T,
107) -> io::Result<()> {
108    while !pending.is_empty() {
109        match Pin::new(&mut *transport).poll_write(cx, pending) {
110            Poll::Ready(Ok(0)) => return Err(io::ErrorKind::WriteZero.into()),
111            Poll::Ready(Ok(n)) => {
112                pending.drain(..n);
113            }
114            Poll::Ready(Err(e)) => return Err(e),
115            Poll::Pending => break,
116        }
117    }
118    Ok(())
119}
120
121/// Append an HTTP/3 DATA frame header for `payload_len` bytes to `out`. Caller appends
122/// the payload immediately after.
123fn encode_h3_data_header(out: &mut Vec<u8>, payload_len: u64) {
124    let frame = Frame::Data(payload_len);
125    let header_len = frame.encoded_len();
126    let start = out.len();
127    out.resize(start + header_len, 0);
128    frame.encode(&mut out[start..]);
129}
130
131/// An HTTP upgrade — owns the underlying transport along with all the data from the
132/// originating [`Conn`].
133///
134/// **Reading the transport directly**: drain `buffer` first if it has bytes in it. Reading
135/// via the [`AsyncRead`] impl on `Upgrade` handles this automatically.
136#[derive(Fieldwork)]
137#[fieldwork(get, get_mut, set, with, take, into_field, rename_predicates)]
138pub struct Upgrade<Transport> {
139    /// The http headers the peer sent to us
140    #[field(deprecate(was = "request_headers", since = "1.3.0"))]
141    pub(crate) received_headers: Headers,
142
143    /// The http headers as set before the upgrade was negotiated and sent
144    /// to the peer.
145    #[field(deprecate(was = "response_headers", since = "1.3.0"))]
146    pub(crate) sent_headers: Headers,
147
148    /// The request path
149    #[field(get = false)]
150    pub(crate) path: Cow<'static, str>,
151
152    /// The http request method
153    #[field(copy)]
154    pub(crate) method: Method,
155
156    /// Any state that has been accumulated on the Conn before negotiating the upgrade
157    pub(crate) state: TypeSet,
158
159    /// The underlying io (often a `TcpStream` or similar)
160    pub(crate) transport: Transport,
161
162    /// Any bytes that have been read from the underlying transport already.
163    ///
164    /// It is your responsibility to process these bytes before reading directly from the
165    /// transport.
166    #[field(deref = "[u8]", into_field = false, set = false, with = false)]
167    pub(crate) buffer: Buffer,
168
169    /// The [`HttpContext`] shared for this server
170    #[field(deref = false)]
171    pub(crate) context: Arc<HttpContext>,
172
173    /// the ip address of the connection, if available
174    #[field(copy)]
175    pub(crate) peer_ip: Option<IpAddr>,
176
177    /// the wall-clock time at which the underlying [`Conn`] was constructed
178    #[field(copy)]
179    pub(crate) start_time: Instant,
180
181    /// the :authority http/3 pseudo-header
182    pub(crate) authority: Option<Cow<'static, str>>,
183
184    /// the :scheme http/3 pseudo-header
185    pub(crate) scheme: Option<Cow<'static, str>>,
186
187    /// the [`ProtocolSession`] for this upgrade — h2/h3 connection driver + stream id
188    /// where applicable; `Http1` for upgrades from h1 or synthetic conns.
189    #[field = false]
190    pub(crate) protocol_session: ProtocolSession,
191
192    /// the :protocol http/3 pseudo-header
193    pub(crate) protocol: Option<Cow<'static, str>>,
194
195    /// the http version
196    #[field = "http_version"]
197    pub(crate) version: Version,
198
199    /// the http response status set on the underlying [`Conn`] before the upgrade
200    /// (typically `101 Switching Protocols`, or `200 OK` for CONNECT). `None` if unset.
201    #[field(copy)]
202    pub(crate) status: Option<Status>,
203
204    /// whether this connection was deemed secure by the handler stack
205    pub(crate) secure: bool,
206
207    /// Inbound framing state carried across the upgrade so the inbound state machine
208    /// resumes where the pre-upgrade handler left off. Request-body state on server
209    /// upgrades; response-body state on client upgrades.
210    #[field = false]
211    pub(crate) received_body_state: ReceivedBodyState,
212
213    /// Inbound trailers, populated either by a fully-consumed pre-upgrade body or by
214    /// the post-upgrade inbound state machine. `Some` only when non-empty.
215    #[field(get, get_mut, take, set = false, with = false, into_field = false)]
216    pub(crate) received_trailers: Option<Headers>,
217
218    /// Pre-parsed inbound `Content-Length`. `None` for chunked, missing, or malformed.
219    #[field = false]
220    pub(crate) content_length_in: Option<u64>,
221
222    /// Per-protocol outbound framing state. Decided at the upgrade transition.
223    #[field = false]
224    pub(crate) write_state: WriteState,
225
226    /// Charset of the inbound body, parsed from the inbound `Content-Type`'s `charset`
227    /// parameter at the upgrade transition.
228    #[field = false]
229    pub(crate) inbound_encoding: &'static Encoding,
230
231    /// In-flight QPACK trailer-decode future for inbound h3 trailing HEADERS. Held here
232    /// so its registered waker survives across `poll_read` calls — dropping the future
233    /// would drop the waker the QPACK decoder is parked on, hanging the reader.
234    #[field = false]
235    pub(crate) h3_trailer_decode_in: Option<H3TrailerFuture>,
236
237    /// Accumulator for inbound h3 trailing-HEADERS payload bytes pre-QPACK-decode.
238    /// Separate from [`Self::buffer`] so the inbound state machine doesn't recycle
239    /// accumulated trailer bytes back through the frame decoder and double-count them.
240    #[field = false]
241    pub(crate) h3_trailer_payload_in: Vec<u8>,
242}
243
244impl<Transport> Upgrade<Transport> {
245    #[doc(hidden)]
246    pub fn new(
247        received_headers: Headers,
248        path: impl Into<Cow<'static, str>>,
249        method: Method,
250        transport: Transport,
251        buffer: Buffer,
252        version: Version,
253    ) -> Self {
254        Self {
255            received_headers,
256            sent_headers: Headers::new(),
257            path: path.into(),
258            method,
259            transport,
260            buffer,
261            state: TypeSet::new(),
262            context: Arc::default(),
263            peer_ip: None,
264            start_time: Instant::now(),
265            authority: None,
266            scheme: None,
267            protocol_session: ProtocolSession::Http1,
268            protocol: None,
269            secure: false,
270            version,
271            status: None,
272            received_body_state: ReceivedBodyState::Raw { total: 0 },
273            received_trailers: None,
274            content_length_in: None,
275            write_state: WriteState::Raw,
276            inbound_encoding: encoding_rs::UTF_8,
277            h3_trailer_decode_in: None,
278            h3_trailer_payload_in: Vec::new(),
279        }
280    }
281
282    #[cfg(feature = "unstable")]
283    #[doc(hidden)]
284    #[allow(clippy::too_many_arguments)]
285    pub fn from_parts(
286        received_headers: Headers,
287        sent_headers: Headers,
288        path: Cow<'static, str>,
289        method: Method,
290        transport: Transport,
291        buffer: Buffer,
292        state: TypeSet,
293        context: Arc<HttpContext>,
294        peer_ip: Option<IpAddr>,
295        authority: Option<Cow<'static, str>>,
296        scheme: Option<Cow<'static, str>>,
297        protocol_session: ProtocolSession,
298        protocol: Option<Cow<'static, str>>,
299        version: Version,
300        status: Option<Status>,
301        secure: bool,
302        received_body_state: ReceivedBodyState,
303        received_trailers: Option<Headers>,
304    ) -> Self {
305        let write_state = compute_write_state(version, &sent_headers);
306        let content_length_in = parse_content_length(&received_headers);
307        let inbound_encoding = encoding(&received_headers);
308
309        Self {
310            received_headers,
311            sent_headers,
312            path,
313            method,
314            state,
315            transport,
316            buffer,
317            context,
318            peer_ip,
319            start_time: Instant::now(),
320            authority,
321            scheme,
322            protocol_session,
323            protocol,
324            version,
325            status,
326            secure,
327            received_body_state,
328            received_trailers,
329            content_length_in,
330            write_state,
331            inbound_encoding,
332            h3_trailer_decode_in: None,
333            h3_trailer_payload_in: Vec::new(),
334        }
335    }
336
337    /// the [`H2Connection`] driver for this upgrade, if it originated from an HTTP/2 stream
338    pub fn h2_connection(&self) -> Option<&Arc<H2Connection>> {
339        self.protocol_session.h2_connection()
340    }
341
342    /// the h2 stream id for this upgrade, if it originated from an HTTP/2 stream
343    pub fn h2_stream_id(&self) -> Option<u32> {
344        self.protocol_session.h2_stream_id()
345    }
346
347    /// the [`H3Connection`] driver for this upgrade, if it originated from an HTTP/3 stream
348    pub fn h3_connection(&self) -> Option<&Arc<H3Connection>> {
349        self.protocol_session.h3_connection()
350    }
351
352    /// the h3 stream id for this upgrade, if it originated from an HTTP/3 stream
353    pub fn h3_stream_id(&self) -> Option<u64> {
354        self.protocol_session.h3_stream_id()
355    }
356
357    /// Take any buffered bytes
358    pub fn take_buffer(&mut self) -> Vec<u8> {
359        std::mem::take(&mut self.buffer).into()
360    }
361
362    #[doc(hidden)]
363    pub fn buffer_and_transport_mut(&mut self) -> (&mut Buffer, &mut Transport) {
364        (&mut self.buffer, &mut self.transport)
365    }
366
367    /// borrow the shared state [`TypeSet`] for this application
368    pub fn shared_state(&self) -> &TypeSet {
369        self.context.shared_state()
370    }
371
372    /// the http request path up to but excluding any query component
373    pub fn path(&self) -> &str {
374        match self.path.split_once('?') {
375            Some((path, _)) => path,
376            None => &self.path,
377        }
378    }
379
380    /// retrieves the query component of the path
381    pub fn querystring(&self) -> &str {
382        self.path
383            .split_once('?')
384            .map(|(_, query)| query)
385            .unwrap_or_default()
386    }
387
388    /// Modify the transport type of this upgrade.
389    ///
390    /// This is useful for boxing the transport in order to erase the type argument.
391    pub fn map_transport<T: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static>(
392        self,
393        f: impl Fn(Transport) -> T,
394    ) -> Upgrade<T> {
395        // Manual respread: rustc rejects `..self` across a type parameter change without
396        // the unstable `type_changing_struct_update` feature. New fields on `Upgrade`
397        // need to be added here, in `Conn::map_transport`, and in `From<Conn> for Upgrade`.
398        Upgrade {
399            transport: f(self.transport),
400            path: self.path,
401            method: self.method,
402            state: self.state,
403            buffer: self.buffer,
404            received_headers: self.received_headers,
405            sent_headers: self.sent_headers,
406            context: self.context,
407            peer_ip: self.peer_ip,
408            start_time: self.start_time,
409            authority: self.authority,
410            scheme: self.scheme,
411            protocol_session: self.protocol_session,
412            protocol: self.protocol,
413            version: self.version,
414            status: self.status,
415            secure: self.secure,
416            received_body_state: self.received_body_state,
417            received_trailers: self.received_trailers,
418            content_length_in: self.content_length_in,
419            write_state: self.write_state,
420            inbound_encoding: self.inbound_encoding,
421            h3_trailer_decode_in: self.h3_trailer_decode_in,
422            h3_trailer_payload_in: self.h3_trailer_payload_in,
423        }
424    }
425}
426
427impl<Transport: AsyncWrite + Unpin> Upgrade<Transport> {
428    /// Emit trailing headers and finish the outbound stream. Consumes `self`; further
429    /// writes are statically prevented.
430    ///
431    /// Per-protocol behavior:
432    /// - HTTP/1.1 with `Transfer-Encoding: chunked`: writes the last-chunk marker (`0\r\n`), the
433    ///   trailer section, and a final CRLF, then closes the transport.
434    /// - HTTP/2: enqueues a trailing `HEADERS` frame with `END_STREAM` via the connection driver
435    ///   and returns. The driver finishes the stream after draining any pending DATA frames.
436    /// - HTTP/3: encodes a trailing `HEADERS` frame via QPACK, writes it to the stream, then closes
437    ///   the stream (QUIC `FIN`).
438    /// - HTTP/1.1 without chunked encoding (raw upgrade, CONNECT tunnel, websocket-over-h1):
439    ///   trailers can't be expressed on the wire; dropped with a `log::warn!` and `Ok(())`
440    ///   returned.
441    ///
442    /// # Errors
443    ///
444    /// Returns the underlying [`io::Error`] when the wire write fails, `BrokenPipe` if
445    /// the stream has already been closed, and `NotConnected` if the carried
446    /// `ProtocolSession` is missing the expected driver for h2/h3.
447    pub async fn send_trailers(self, trailers: Headers) -> io::Result<()> {
448        let Self {
449            mut transport,
450            mut write_state,
451            context,
452            protocol_session,
453            ..
454        } = self;
455
456        match &mut write_state {
457            WriteState::H1Chunked(state) => {
458                if state.terminator_written {
459                    return Err(io::ErrorKind::BrokenPipe.into());
460                }
461                state.pending.extend_from_slice(b"0\r\n");
462                crate::conn::write_headers_or_trailers(&mut state.pending, &trailers, &context)
463                    .map_err(io::Error::other)?;
464                state.pending.extend_from_slice(b"\r\n");
465                state.terminator_written = true;
466
467                transport.write_all(&state.pending).await?;
468                state.pending.clear();
469                transport.close().await
470            }
471            WriteState::H3Framed(state) => {
472                if state.terminator_written {
473                    return Err(io::ErrorKind::BrokenPipe.into());
474                }
475                let Some((h3, stream_id)) = protocol_session.as_h3() else {
476                    return Err(io::ErrorKind::NotConnected.into());
477                };
478                let field_section = FieldSection::new(PseudoHeaders::default(), &trailers);
479                h3.encode_field_section_framed(&field_section, &mut state.pending, stream_id)?;
480                state.terminator_written = true;
481
482                transport.write_all(&state.pending).await?;
483                state.pending.clear();
484                transport.close().await
485            }
486            WriteState::Raw => {
487                if let Some((h2, stream_id)) = protocol_session.as_h2() {
488                    h2.submit_trailers(stream_id, trailers)
489                } else {
490                    log::warn!(
491                        "Upgrade::send_trailers called on a raw upgrade with no per-stream \
492                         framing; trailers dropped. Set `Transfer-Encoding: chunked` on the \
493                         outbound headers if you intend to emit trailers over HTTP/1.1."
494                    );
495                    Ok(())
496                }
497            }
498        }
499    }
500}
501
502impl<Transport> Debug for Upgrade<Transport> {
503    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
504        f.debug_struct(&format!("Upgrade<{}>", std::any::type_name::<Transport>()))
505            .field("received_headers", &self.received_headers)
506            .field("sent_headers", &self.sent_headers)
507            .field("path", &self.path)
508            .field("method", &self.method)
509            .field("buffer", &self.buffer)
510            .field("context", &self.context)
511            .field("state", &self.state)
512            .field("transport", &format_args!(".."))
513            .field("peer_ip", &self.peer_ip)
514            .field("start_time", &self.start_time)
515            .field("authority", &self.authority)
516            .field("scheme", &self.scheme)
517            .field("protocol_session", &self.protocol_session)
518            .field("protocol", &self.protocol)
519            .field("version", &self.version)
520            .field("status", &self.status)
521            .field("secure", &self.secure)
522            .field("received_body_state", &self.received_body_state)
523            .field("received_trailers", &self.received_trailers)
524            .field("content_length_in", &self.content_length_in)
525            .field("write_state", &self.write_state)
526            .field("inbound_encoding", &self.inbound_encoding.name())
527            .field(
528                "h3_trailer_decode_in",
529                &self
530                    .h3_trailer_decode_in
531                    .as_ref()
532                    .map(|_| format_args!("..")),
533            )
534            .field(
535                "h3_trailer_payload_in_len",
536                &self.h3_trailer_payload_in.len(),
537            )
538            .finish()
539    }
540}
541
542impl<Transport> From<Conn<Transport>> for Upgrade<Transport> {
543    fn from(conn: Conn<Transport>) -> Self {
544        // Exhaustive destructure so new fields on `Conn` force a deliberate carry-vs-drop
545        // decision. Shared drift hazard with `Conn::map_transport` and `Upgrade::map_transport`.
546        let Conn {
547            request_headers,
548            response_headers,
549            path,
550            method,
551            state,
552            transport,
553            buffer,
554            context,
555            peer_ip,
556            start_time,
557            authority,
558            scheme,
559            protocol_session,
560            protocol,
561            version,
562            status,
563            secure,
564            request_body_state,
565            request_trailers,
566            response_body,
567            // post-send hooks no longer apply; `upgrade` is the marker that brought us here
568            after_send: _,
569            upgrade: _,
570        } = conn;
571
572        if let Some(body) = &response_body
573            && !body.is_empty()
574        {
575            log::warn!(
576                "Conn::upgrade() and a non-empty response body are both set; body is being \
577                 discarded. The upgrade path is mutually exclusive with serving a response body."
578            );
579        }
580
581        // Server-side roles: outbound = response_headers, inbound = request_headers.
582        let write_state = compute_write_state(version, &response_headers);
583        let content_length_in = parse_content_length(&request_headers);
584        let inbound_encoding = encoding(&request_headers);
585        // An h1 request with no framing headers parses to `End` — correct for the request
586        // body, but inherited across the upgrade it would EOF the first read of a live raw
587        // stream (a browser websocket handshake is exactly this shape). Declared framing
588        // does carry over: a chunked request keeps chunked inbound framing.
589        let received_body_state = if matches!(version, Version::Http1_0 | Version::Http1_1)
590            && !request_headers.has_header(KnownHeaderName::TransferEncoding)
591            && !request_headers.has_header(KnownHeaderName::ContentLength)
592        {
593            ReceivedBodyState::Raw { total: 0 }
594        } else {
595            request_body_state
596        };
597        let received_trailers = request_trailers.filter(|t| !t.is_empty());
598
599        Self {
600            received_headers: request_headers,
601            sent_headers: response_headers,
602            path,
603            method,
604            state,
605            transport,
606            buffer,
607            context,
608            peer_ip,
609            start_time,
610            authority,
611            scheme,
612            protocol_session,
613            protocol,
614            version,
615            status,
616            secure,
617            received_body_state,
618            received_trailers,
619            content_length_in,
620            write_state,
621            inbound_encoding,
622            h3_trailer_decode_in: None,
623            h3_trailer_payload_in: Vec::new(),
624        }
625    }
626}
627
628#[cfg(test)]
629mod tests;
630
631impl<Transport> AsyncRead for Upgrade<Transport>
632where
633    Transport: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static,
634{
635    fn poll_read(
636        mut self: Pin<&mut Self>,
637        cx: &mut Context<'_>,
638        buf: &mut [u8],
639    ) -> Poll<io::Result<usize>> {
640        let Self {
641            transport,
642            buffer,
643            received_body_state,
644            content_length_in,
645            context,
646            protocol_session,
647            received_trailers,
648            h3_trailer_decode_in,
649            h3_trailer_payload_in,
650            inbound_encoding,
651            ..
652        } = &mut *self;
653
654        let protocol_session = protocol_session.clone();
655        let mut body: ReceivedBody<'_, Transport> = ReceivedBody::new_with_config(
656            *content_length_in,
657            buffer,
658            transport,
659            received_body_state,
660            None,
661            inbound_encoding,
662            &context.config,
663        )
664        .with_trailers(received_trailers)
665        .with_protocol_session(protocol_session)
666        .with_h3_trailer_future(h3_trailer_decode_in)
667        .with_h3_trailer_payload_buffer(h3_trailer_payload_in);
668
669        Pin::new(&mut body).poll_read(cx, buf)
670    }
671}
672
673impl<Transport: AsyncWrite + Unpin> AsyncWrite for Upgrade<Transport> {
674    fn poll_write(
675        mut self: Pin<&mut Self>,
676        cx: &mut Context<'_>,
677        buf: &[u8],
678    ) -> Poll<io::Result<usize>> {
679        let Self {
680            transport,
681            write_state,
682            ..
683        } = &mut *self;
684        match write_state {
685            WriteState::Raw => Pin::new(transport).poll_write(cx, buf),
686            WriteState::H1Chunked(state) => {
687                ready!(poll_drain_pending(&mut state.pending, cx, transport))?;
688
689                // Empty buf must not become a chunk: `0\r\n` IS the last-chunk marker.
690                if buf.is_empty() {
691                    return Poll::Ready(Ok(0));
692                }
693
694                if state.terminator_written {
695                    return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
696                }
697
698                write_chunk(&mut state.pending, buf);
699                best_effort_drain(&mut state.pending, cx, transport)?;
700                Poll::Ready(Ok(buf.len()))
701            }
702            WriteState::H3Framed(state) => {
703                ready!(poll_drain_pending(&mut state.pending, cx, transport))?;
704
705                if buf.is_empty() {
706                    return Poll::Ready(Ok(0));
707                }
708
709                if state.terminator_written {
710                    return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
711                }
712
713                encode_h3_data_header(&mut state.pending, buf.len() as u64);
714                state.pending.extend_from_slice(buf);
715                best_effort_drain(&mut state.pending, cx, transport)?;
716                Poll::Ready(Ok(buf.len()))
717            }
718        }
719    }
720
721    fn poll_write_vectored(
722        mut self: Pin<&mut Self>,
723        cx: &mut Context<'_>,
724        bufs: &[IoSlice<'_>],
725    ) -> Poll<io::Result<usize>> {
726        let Self {
727            transport,
728            write_state,
729            ..
730        } = &mut *self;
731        match write_state {
732            WriteState::Raw => Pin::new(transport).poll_write_vectored(cx, bufs),
733            WriteState::H1Chunked(state) => {
734                ready!(poll_drain_pending(&mut state.pending, cx, transport))?;
735                let total: usize = bufs.iter().map(|b| b.len()).sum();
736                if total == 0 {
737                    return Poll::Ready(Ok(0));
738                }
739                if state.terminator_written {
740                    return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
741                }
742                // One chunk per vectored batch — the default impl would emit one chunk per
743                // iobuf, which is wasteful when the caller meant them as one logical write.
744                let _ = write!(state.pending, "{total:X}\r\n");
745                for b in bufs {
746                    state.pending.extend_from_slice(b);
747                }
748                state.pending.extend_from_slice(b"\r\n");
749                best_effort_drain(&mut state.pending, cx, transport)?;
750                Poll::Ready(Ok(total))
751            }
752            WriteState::H3Framed(state) => {
753                ready!(poll_drain_pending(&mut state.pending, cx, transport))?;
754                let total: usize = bufs.iter().map(|b| b.len()).sum();
755                if total == 0 {
756                    return Poll::Ready(Ok(0));
757                }
758                if state.terminator_written {
759                    return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
760                }
761                // One DATA frame per vectored batch — collapses `[length_prefix, payload]`
762                // pairs into a single frame.
763                encode_h3_data_header(&mut state.pending, total as u64);
764                for b in bufs {
765                    state.pending.extend_from_slice(b);
766                }
767                best_effort_drain(&mut state.pending, cx, transport)?;
768                Poll::Ready(Ok(total))
769            }
770        }
771    }
772
773    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
774        let Self {
775            transport,
776            write_state,
777            ..
778        } = &mut *self;
779        match write_state {
780            WriteState::Raw => Pin::new(transport).poll_flush(cx),
781            WriteState::H1Chunked(state) => {
782                ready!(poll_drain_pending(&mut state.pending, cx, transport))?;
783                Pin::new(transport).poll_flush(cx)
784            }
785            WriteState::H3Framed(state) => {
786                ready!(poll_drain_pending(&mut state.pending, cx, transport))?;
787                Pin::new(transport).poll_flush(cx)
788            }
789        }
790    }
791
792    fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
793        let Self {
794            transport,
795            write_state,
796            ..
797        } = &mut *self;
798        match write_state {
799            WriteState::Raw => Pin::new(transport).poll_close(cx),
800            WriteState::H1Chunked(state) => {
801                ready!(poll_drain_pending(&mut state.pending, cx, transport))?;
802                if !state.terminator_written {
803                    state.pending.extend_from_slice(b"0\r\n\r\n");
804                    // Flag set before the drain so a re-poll after Pending doesn't re-append.
805                    state.terminator_written = true;
806                }
807                ready!(poll_drain_pending(&mut state.pending, cx, transport))?;
808                Pin::new(transport).poll_close(cx)
809            }
810            WriteState::H3Framed(state) => {
811                // h3 stream-end is the QUIC FIN — no separate terminator frame.
812                ready!(poll_drain_pending(&mut state.pending, cx, transport))?;
813                state.terminator_written = true;
814                Pin::new(transport).poll_close(cx)
815            }
816        }
817    }
818}