Skip to main content

moqtap_client/transport/
mod.rs

1//! Transport abstraction for QUIC and WebTransport.
2//!
3//! Uses enum dispatch (not trait objects) since the transport set is closed.
4//! WebTransport support is behind the `webtransport` feature flag. Nothing
5//! here is per-draft; every draft's connection is carried over the same two.
6
7pub mod quic;
8#[cfg(feature = "webtransport")]
9pub mod webtransport;
10
11pub use quic::{
12    dial_quic, dial_quic_to, show_cipher_suite, CertificateHook, CertificateLog, DialError,
13    DialPhase, QuicDialOptions, QuicTarget, TLS13_CIPHER_SUITES,
14};
15#[cfg(feature = "webtransport")]
16pub use webtransport::{dial_webtransport, dial_webtransport_to};
17
18use std::future::Future;
19
20use bytes::Bytes;
21
22/// A handshake that failed, with the codes the failure carried still intact.
23///
24/// The reason it is a struct and not another `String` variant: a peer's refusal
25/// arrives as a *number*, and flattening it into prose is lossy in a way that
26/// only shows up downstream. A TLS alert reaches QUIC as `0x0100 | alert`, so
27/// `no_application_protocol` (120) is `0x178`, and a caller that wants to
28/// distinguish "this relay does not speak our protocol" from "this relay's
29/// certificate expired" (alert 45, `0x12D`) was left parsing error messages for
30/// digits.
31///
32/// Every field is optional except [`reason`](Self::reason), because not every
33/// failure has a code — a timeout and a DNS miss are real outcomes with nothing
34/// numeric in them.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct HandshakeFailure {
37    /// The QUIC error code, when the failure carried one.
38    ///
39    /// Meaningless without [`code_space`](Self::code_space) — the same integer
40    /// says different things in the two spaces.
41    pub code: Option<u64>,
42    /// Which space [`code`](Self::code) is a number in.
43    pub code_space: Option<CodeSpace>,
44    /// The TLS alert, when the code was a *transport* code in QUIC's crypto
45    /// range.
46    ///
47    /// Derived and not independently sourced: QUIC has no separate field for
48    /// it, and `0x0100..=0x01ff` *is* how TLS alerts are carried.
49    pub tls_alert: Option<u8>,
50    /// What the stack said, for the detail no code carries — which certificate
51    /// field mismatched, when it expired, which name was expected.
52    ///
53    /// Alert 42 (`bad_certificate`) is rustls's catch-all, so telling an expired
54    /// certificate from a name mismatch still means reading this.
55    pub reason: String,
56}
57
58/// Which number space a QUIC error code belongs to.
59///
60/// Kept because the same integer means different things in each, and a report
61/// that loses the space is one that cannot be read. Transport code 271 is
62/// `0x10F` — inside the range TLS alerts are carried in. Application code 271
63/// is a peer's own close code and has nothing to do with TLS. Reading an alert
64/// out of the second produces a finding that never happened, which is worse
65/// than producing none.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum CodeSpace {
68    /// QUIC's own transport errors, where TLS alerts arrive as `0x0100 | alert`.
69    Transport,
70    /// The application's close codes — over MoQT, a draft's own error codes.
71    Application,
72}
73
74impl HandshakeFailure {
75    /// A failure carrying a transport-space code, with any TLS alert recovered.
76    ///
77    /// Prefer this over a struct literal: it is what keeps `tls_alert`
78    /// consistent with `code`, and a literal can set the two independently.
79    pub fn transport(code: u64, reason: String) -> Self {
80        Self {
81            code: Some(code),
82            code_space: Some(CodeSpace::Transport),
83            tls_alert: Self::alert_of(code),
84            reason,
85        }
86    }
87
88    /// A failure carrying an application-space close code.
89    ///
90    /// Never carries a TLS alert: application codes are a separate space, and
91    /// the ones that happen to land in `0x0100..=0x01ff` are the trap this
92    /// constructor exists to close.
93    pub fn application(code: u64, reason: String) -> Self {
94        Self { code: Some(code), code_space: Some(CodeSpace::Application), tls_alert: None, reason }
95    }
96
97    /// A failure with nothing numeric in it — a timeout, a DNS miss, a reset.
98    pub fn bare(reason: String) -> Self {
99        Self { code: None, code_space: None, tls_alert: None, reason }
100    }
101
102    /// Recover the alert from a **transport-space** code, if it carries one.
103    ///
104    /// `0x0100 | alert` is the mapping TLS-over-QUIC defines, so the range is
105    /// exactly one byte wide and the low byte is the alert. Applying this to an
106    /// application-space code is a bug — see [`CodeSpace`].
107    pub fn alert_of(code: u64) -> Option<u8> {
108        (0x0100..=0x01ff).contains(&code).then_some((code & 0xff) as u8)
109    }
110}
111
112impl std::fmt::Display for HandshakeFailure {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.write_str(&self.reason)
115    }
116}
117
118/// Errors from the transport layer.
119///
120/// `#[non_exhaustive]` so that a new failure mode is an additive change. The
121/// crate matches on these internally, where the attribute does not apply;
122/// downstream code that matches needs a `_` arm, which is the trade being made
123/// deliberately.
124#[derive(Debug, thiserror::Error)]
125#[non_exhaustive]
126pub enum TransportError {
127    /// Connection-level error (e.g., peer closed, timeout).
128    #[error("connection error: {0}")]
129    Connection(String),
130    /// Error writing to a stream.
131    #[error("write error: {0}")]
132    Write(String),
133    /// Error reading from a stream.
134    #[error("read error: {0}")]
135    Read(String),
136    /// Stream was closed.
137    #[error("stream closed")]
138    StreamClosed,
139    /// Error sending a datagram.
140    #[error("send datagram error: {0}")]
141    SendDatagram(String),
142    /// Connection was lost.
143    #[error("connection lost")]
144    ConnectionLost,
145    /// Error during connection establishment.
146    #[error("connect error: {0}")]
147    Connect(String),
148    /// A handshake reached the peer and did not complete, with its codes kept.
149    ///
150    /// Distinct from [`Connect`](Self::Connect), which is the same class of
151    /// event before there was anything numeric to keep — building the endpoint,
152    /// binding the socket, resolving a name. Both render the same way, so this
153    /// is additive to anything reading the message.
154    #[error("connect error: {0}")]
155    Handshake(HandshakeFailure),
156    /// The peer abandoned transmission by resetting the stream. Carries
157    /// the peer's application error code so a forwarder can mirror it
158    /// verbatim with [`SendStream::reset`].
159    #[error("stream reset by peer: code {0}")]
160    StreamReset(u64),
161    /// The peer is no longer accepting data on this stream
162    /// (`STOP_SENDING`). Carries the peer's application error code so a
163    /// forwarder can mirror it verbatim with [`RecvStream::stop`].
164    #[error("stream stopped by peer: code {0}")]
165    Stopped(u64),
166    /// The peer closed the whole session, with the code it named.
167    ///
168    /// Distinct from [`StreamReset`](Self::StreamReset), which ends one stream
169    /// and leaves the session alive. Over MoQT this code is a draft's own
170    /// session error — `VERSION_NEGOTIATION_FAILED`, `PROTOCOL_VIOLATION` — and
171    /// it is the sharpest thing a refusal says.
172    ///
173    /// It exists because quinn renders `ReadError::ConnectionLost` as the bare
174    /// words "connection lost" and drops the cause entirely, so without this
175    /// variant a relay that ends a session with a code *and* a reason phrase
176    /// reaches callers as neither. The code is on the value and also in the
177    /// message, spelled the way every other code this crate reports is spelled.
178    #[error("{reason} (code {code})")]
179    SessionClosed {
180        /// The peer's application close code.
181        code: u64,
182        /// quinn's own rendering of the close, which is the peer's reason
183        /// phrase where it sent one and the code where it did not.
184        reason: String,
185    },
186}
187
188/// A transport-agnostic connection (QUIC or WebTransport).
189pub enum Transport {
190    /// Raw QUIC via quinn.
191    Quic(quic::QuicTransport),
192    /// WebTransport via h3 + h3-quinn.
193    #[cfg(feature = "webtransport")]
194    WebTransport(webtransport::WebTransportTransport),
195}
196
197impl Transport {
198    /// Open a bidirectional stream.
199    pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), TransportError> {
200        match self {
201            Transport::Quic(t) => t.open_bi().await,
202            #[cfg(feature = "webtransport")]
203            Transport::WebTransport(t) => t.open_bi().await,
204        }
205    }
206
207    /// Accept an incoming bidirectional stream.
208    pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), TransportError> {
209        match self {
210            Transport::Quic(t) => t.accept_bi().await,
211            #[cfg(feature = "webtransport")]
212            Transport::WebTransport(t) => t.accept_bi().await,
213        }
214    }
215
216    /// Open a unidirectional send stream.
217    pub async fn open_uni(&self) -> Result<SendStream, TransportError> {
218        match self {
219            Transport::Quic(t) => t.open_uni().await,
220            #[cfg(feature = "webtransport")]
221            Transport::WebTransport(t) => t.open_uni().await,
222        }
223    }
224
225    /// Accept an incoming unidirectional stream.
226    pub async fn accept_uni(&self) -> Result<RecvStream, TransportError> {
227        match self {
228            Transport::Quic(t) => t.accept_uni().await,
229            #[cfg(feature = "webtransport")]
230            Transport::WebTransport(t) => t.accept_uni().await,
231        }
232    }
233
234    /// Send a datagram.
235    pub fn send_datagram(&self, data: Bytes) -> Result<(), TransportError> {
236        match self {
237            Transport::Quic(t) => t.send_datagram(data),
238            #[cfg(feature = "webtransport")]
239            Transport::WebTransport(t) => t.send_datagram(data),
240        }
241    }
242
243    /// Receive a datagram.
244    pub async fn recv_datagram(&self) -> Result<Bytes, TransportError> {
245        match self {
246            Transport::Quic(t) => t.recv_datagram().await,
247            #[cfg(feature = "webtransport")]
248            Transport::WebTransport(t) => t.recv_datagram().await,
249        }
250    }
251
252    /// Close the connection.
253    pub fn close(&self, code: u32, reason: &[u8]) {
254        match self {
255            Transport::Quic(t) => t.close(code, reason),
256            #[cfg(feature = "webtransport")]
257            Transport::WebTransport(t) => t.close(code, reason),
258        }
259    }
260
261    /// A future resolving when the session ends, carrying the peer's close.
262    ///
263    /// Borrows nothing and outlives this transport, so a caller can take it
264    /// *before* handing the transport to a handshake and still be holding it
265    /// when the peer's answer lands. That ordering is the whole point: a peer
266    /// refusing a setup often finishes the control stream first and closes the
267    /// session a round trip later, by which time the handshake has already
268    /// returned end-of-stream and dropped everything it had.
269    ///
270    /// `None` over WebTransport. The session close there is `wtransport`'s and
271    /// its code is in a private field with no accessor — the first of the
272    /// README's known gaps, not a decision made here.
273    pub fn closed(&self) -> Option<impl Future<Output = TransportError> + Send + 'static> {
274        match self {
275            Transport::Quic(t) => Some(t.closed()),
276            #[cfg(feature = "webtransport")]
277            Transport::WebTransport(_) => None,
278        }
279    }
280
281    /// The certificate chain the peer presented, DER-encoded, leaf first.
282    ///
283    /// Empty when there is none to report — the handshake has not completed,
284    /// the peer sent no chain — rather than an error, because none of those
285    /// are failures of this connection.
286    ///
287    /// **This is the whole of the certificate API, and that is deliberate.**
288    /// The bytes are handed over exactly as the peer sent them and nothing
289    /// here parses them, dates them, or decides whether they are trustworthy.
290    /// A certificate verdict is a judgement about what the caller is trying to
291    /// establish: a probe grading a public relay wants to know whether the
292    /// chain reaches a public root, an operator running against their own CA
293    /// wants precisely the opposite answer to count as healthy, and a library
294    /// that picked one would be silently wrong for the other. Whoever knows
295    /// the question owns the answer; this owns the evidence.
296    ///
297    /// One consequence worth naming, because it is the finding this exists to
298    /// make visible: the *length* of what comes back is itself a measurement.
299    /// A relay that serves a leaf and no intermediate hands back a chain of
300    /// one, which a client validating against a fixed root set rejects while a
301    /// browser holding a cached intermediate connects to it happily. That is a
302    /// misconfiguration and not a mystery — but only to a caller that can see
303    /// how many certificates arrived, which is why the chain is returned whole
304    /// rather than as a leaf.
305    ///
306    /// Read off the live connection each time — nothing is cached — so a
307    /// caller that intends to keep the chain must take it while it still holds
308    /// the `Transport`. Closing does not by itself erase the answer (quinn
309    /// keeps the crypto session for the handle's lifetime), but dropping the
310    /// handle does, and a caller that closes by value has done both.
311    pub fn peer_certificates(&self) -> Vec<Vec<u8>> {
312        match self {
313            Transport::Quic(t) => t.peer_certificates(),
314            #[cfg(feature = "webtransport")]
315            Transport::WebTransport(t) => t.peer_certificates(),
316        }
317    }
318
319    /// The application protocol the server selected in the CONNECT response.
320    ///
321    /// `None` over QUIC, where the equivalent answer is the ALPN and
322    /// [`dial_quic_to`] already returns it. Over WebTransport this is
323    /// `WT-Protocol`, which from draft-15 on is where MOQT settles its version
324    /// on that transport. See
325    /// [`WebTransportTransport::wt_protocol`](webtransport::WebTransportTransport::wt_protocol)
326    /// for how the value is read, and why an unquoted one is still read.
327    #[cfg(feature = "wt-protocol")]
328    pub fn wt_protocol(&self) -> Option<String> {
329        match self {
330            Transport::Quic(_) => None,
331            Transport::WebTransport(t) => t.wt_protocol(),
332        }
333    }
334}
335
336/// A transport-agnostic send stream.
337pub enum SendStream {
338    /// Raw QUIC send stream.
339    Quic(quinn::SendStream),
340    /// WebTransport send stream.
341    #[cfg(feature = "webtransport")]
342    WebTransport(webtransport::WtSendStream),
343}
344
345impl SendStream {
346    /// Get the QUIC stream ID (transport-level identifier).
347    pub fn stream_id(&self) -> u64 {
348        match self {
349            SendStream::Quic(s) => s.id().index(),
350            #[cfg(feature = "webtransport")]
351            SendStream::WebTransport(_) => 0, // WebTransport doesn't expose stream IDs
352        }
353    }
354
355    /// Write all bytes to the stream.
356    ///
357    /// Fails with [`TransportError::Stopped`] carrying the peer's
358    /// application error code if the peer sent `STOP_SENDING`.
359    pub async fn write_all(&mut self, buf: &[u8]) -> Result<(), TransportError> {
360        match self {
361            SendStream::Quic(s) => s.write_all(buf).await.map_err(TransportError::from),
362            #[cfg(feature = "webtransport")]
363            SendStream::WebTransport(s) => s.write_all(buf).await,
364        }
365    }
366
367    /// Finish the stream (send FIN).
368    pub fn finish(&mut self) -> Result<(), TransportError> {
369        match self {
370            SendStream::Quic(s) => {
371                s.finish().map_err(|_| TransportError::StreamClosed)?;
372                Ok(())
373            }
374            #[cfg(feature = "webtransport")]
375            SendStream::WebTransport(s) => s.finish(),
376        }
377    }
378
379    /// Reset the stream, telling the peer transmission was abandoned and
380    /// handing it `code` as the `RESET_STREAM` application error code.
381    ///
382    /// This is the only way to abandon a send stream truthfully: simply
383    /// dropping a `SendStream` sends a FIN instead, which tells the peer
384    /// the stream ended *cleanly*. A forwarder that saw the far side
385    /// reset must call this with the code it received, so a truncated
386    /// stream is never laundered into a complete one.
387    ///
388    /// # Errors
389    /// - [`TransportError::StreamClosed`] if the stream was already finished or
390    ///   reset.
391    /// - [`TransportError::Write`] if `code` is outside the QUIC varint range
392    ///   (`0..2^62`). Nothing is sent in that case and the stream stays usable.
393    pub fn reset(&mut self, code: u64) -> Result<(), TransportError> {
394        match self {
395            SendStream::Quic(s) => {
396                let code = varint_code(code)?;
397                s.reset(code).map_err(|_| TransportError::StreamClosed)
398            }
399            #[cfg(feature = "webtransport")]
400            SendStream::WebTransport(s) => s.reset(code),
401        }
402    }
403
404    /// Set the stream's send priority.
405    ///
406    /// Streams with a higher priority have their locally buffered data
407    /// transmitted first. Every stream starts at priority 0.
408    ///
409    /// # Errors
410    /// [`TransportError::StreamClosed`] once the stream's send state has been
411    /// discarded — but only on the QUIC arm, and quinn keeps that state around
412    /// for a while after a `finish` or `reset`, so this is not a reliable *is
413    /// the stream still live?* probe. The WebTransport arm never reports it at
414    /// all: `wtransport` discards the underlying error and always succeeds. Do
415    /// not treat `Ok(())` as proof the priority took effect.
416    pub fn set_priority(&self, priority: i32) -> Result<(), TransportError> {
417        match self {
418            SendStream::Quic(s) => {
419                s.set_priority(priority).map_err(|_| TransportError::StreamClosed)
420            }
421            #[cfg(feature = "webtransport")]
422            SendStream::WebTransport(s) => s.set_priority(priority),
423        }
424    }
425
426    /// Resolve when this send half stops being useful.
427    ///
428    /// [`write_all`](Self::write_all) only reports `STOP_SENDING` when
429    /// there is something to write, so a forwarder that has gone idle —
430    /// the normal state of a stream waiting on its source — never learns
431    /// that the peer walked away. This is the watcher for that case: it
432    /// borrows nothing, so it can sit in a `select!` beside the read
433    /// branch for the stream's whole life.
434    /// The four outcomes, all measured against quinn 0.11.9:
435    ///
436    /// - The peer sent `STOP_SENDING` → [`TransportError::Stopped`] carrying
437    ///   the peer's application error code, the same typed value a failed
438    ///   [`write_all`](Self::write_all) produces, so a forwarder can mirror it
439    ///   verbatim with no new match arm.
440    /// - The stream was finished and the peer acked every byte → `Ok(())`.
441    ///   quinn cannot tell that apart from *the send state was discarded*, so
442    ///   `Ok(())` means *this stream is over*, never *the peer is happy*. It
443    ///   cannot fire on a live stream.
444    /// - The connection was lost → [`TransportError::Connection`].
445    /// - **The local side reset the stream → this future never resolves.**
446    ///   quinn keeps no stopped-notification for a stream it has locally reset,
447    ///   so a watcher held across a [`reset`](Self::reset) stays pending until
448    ///   the connection ends and holds a `tokio::sync::Notify` alive for that
449    ///   long. Retire the future *before* resetting.
450    ///
451    /// The returned future is `'static`: it holds a handle on the
452    /// connection, not on `self`, so it may outlive this `SendStream`
453    /// and be spawned or stored on its own.
454    ///
455    /// # WebTransport arm
456    ///
457    /// Reaches the same quinn future through
458    /// `webtransport::WtSendStream::quic_stream` — spelled as code and not
459    /// as an intra-doc link because the `webtransport` module is behind its
460    /// own feature, so a link to it is broken in the default build and
461    /// `just doc-check` runs `cargo doc --workspace --no-deps` without it.
462    /// This bypasses `wtransport`'s own `stopped`, which collapses stopped /
463    /// closed / disconnected into one error. One asymmetry the QUIC arm
464    /// does not have: [`finish`](Self::finish) moves the inner
465    /// `wtransport` stream out, so calling this afterwards yields a
466    /// future that resolves immediately to
467    /// [`TransportError::StreamClosed`] — the "finished and acked" case
468    /// is unobservable there. Not yet exercised against a live
469    /// WebTransport session.
470    pub fn stopped(&self) -> impl Future<Output = Result<(), TransportError>> + Send + 'static {
471        // Resolve the arm eagerly so the returned future borrows nothing
472        // and both arms hand back the *same* quinn future type.
473        let watched: Result<_, TransportError> = match self {
474            SendStream::Quic(s) => Ok(s.stopped()),
475            #[cfg(feature = "webtransport")]
476            SendStream::WebTransport(s) => s.quic_stream().map(|q| q.stopped()),
477        };
478        async move { stopped_outcome(watched?.await) }
479    }
480}
481
482/// Map quinn's `stopped()` result onto [`TransportError`].
483///
484/// `Ok(None)` is quinn's *the send state is gone*, which it reports both for a
485/// finished-and-acked stream and for one whose state it discarded; neither is
486/// an error, so both become `Ok(())`.
487fn stopped_outcome(
488    outcome: Result<Option<quinn::VarInt>, quinn::StoppedError>,
489) -> Result<(), TransportError> {
490    match outcome {
491        Ok(Some(code)) => Err(TransportError::Stopped(code.into_inner())),
492        Ok(None) => Ok(()),
493        Err(e) => Err(TransportError::Connection(e.to_string())),
494    }
495}
496
497/// Convert an application error code to a quinn `VarInt`.
498///
499/// QUIC application error codes are varints, so values above
500/// 2^62 - 1 cannot be represented on the wire.
501fn varint_code(code: u64) -> Result<quinn::VarInt, TransportError> {
502    quinn::VarInt::from_u64(code)
503        .map_err(|_| TransportError::Write(format!("error code {code} exceeds the varint range")))
504}
505
506/// A transport-agnostic receive stream.
507pub enum RecvStream {
508    /// Raw QUIC receive stream.
509    Quic(quinn::RecvStream),
510    /// WebTransport receive stream.
511    #[cfg(feature = "webtransport")]
512    WebTransport(webtransport::WtRecvStream),
513}
514
515impl RecvStream {
516    /// Get the QUIC stream ID (transport-level identifier).
517    pub fn stream_id(&self) -> u64 {
518        match self {
519            RecvStream::Quic(s) => s.id().index(),
520            #[cfg(feature = "webtransport")]
521            RecvStream::WebTransport(_) => 0,
522        }
523    }
524
525    /// Read data into the buffer. Returns `Ok(Some(n))` with bytes read,
526    /// `Ok(None)` on stream end, or `Err` on failure.
527    ///
528    /// Fails with [`TransportError::StreamReset`] carrying the peer's
529    /// application error code if the peer reset the stream, which is how
530    /// callers distinguish an abandoned stream from a clean FIN
531    /// (`Ok(None)`).
532    pub async fn read(&mut self, buf: &mut [u8]) -> Result<Option<usize>, TransportError> {
533        match self {
534            RecvStream::Quic(s) => s.read(buf).await.map_err(TransportError::from),
535            #[cfg(feature = "webtransport")]
536            RecvStream::WebTransport(s) => s.read(buf).await,
537        }
538    }
539
540    /// Wait for the peer to reset this stream — **without reading a byte**.
541    ///
542    /// [`read`](Self::read) is the only other way to learn that a peer sent
543    /// `RESET_STREAM`, and it is unusable by a reader that has stopped
544    /// consuming on purpose: a forwarder applying backpressure holds its
545    /// source unread, so the reset surfaces on a call it is deliberately
546    /// not making, and the abandonment goes unobserved for as long as the
547    /// backpressure lasts. This observes the same event on its own.
548    ///
549    /// **It consumes nothing.** No bytes leave the receive buffer, so no
550    /// `MAX_STREAM_DATA` credit is granted and the peer stays flow-control
551    /// blocked exactly as it was. That is the whole point: it is safe to
552    /// poll *while* backpressure is being applied, which
553    /// [`read`](Self::read) is not.
554    ///
555    /// Cancel-safe: it registers interest and consumes no state, so
556    /// dropping the future loses nothing.
557    ///
558    /// # Returns
559    /// - `Ok(Some(code))` — the peer reset the stream with this application
560    ///   error code. The same code [`TransportError::StreamReset`] would have
561    ///   carried out of [`read`](Self::read).
562    /// - `Ok(None)` — **no reset is observable on this stream, now or ever**,
563    ///   and the caller must stop asking: this resolves immediately every time,
564    ///   so a caller that re-polls it in a loop spins. Either the transport
565    ///   freed the stream's state (it was finished and fully read, or stopped)
566    ///   or, on the WebTransport arm, `wtransport` exposes no reset-only
567    ///   observable at all and this answers `Ok(None)` unconditionally.
568    /// - `Err` — a connection-level failure.
569    pub async fn received_reset(&mut self) -> Result<Option<u64>, TransportError> {
570        match self {
571            RecvStream::Quic(s) => match s.received_reset().await {
572                Ok(code) => Ok(code.map(|c| c.into_inner())),
573                Err(e) => Err(TransportError::Connection(e.to_string())),
574            },
575            // `wtransport::RecvStream` has no reset-only observable, so a
576            // WebTransport forwarder keeps the pre-existing behaviour: a
577            // peer reset is seen on the next `read` and not before.
578            #[cfg(feature = "webtransport")]
579            RecvStream::WebTransport(_) => Ok(None),
580        }
581    }
582
583    /// Stop accepting data on the stream, discarding anything unread and
584    /// telling the peer to stop transmitting with `code` as the
585    /// `STOP_SENDING` application error code.
586    ///
587    /// Dropping a `RecvStream` also stops it, but with a hard-coded code
588    /// of 0 — so a forwarder mirroring a peer's `STOP_SENDING` must call
589    /// this explicitly to keep the original code intact.
590    ///
591    /// After a successful call the stream is no longer readable, and the
592    /// two arms say so differently: the QUIC arm's [`read`](Self::read)
593    /// returns [`TransportError::Read`], the WebTransport arm's returns
594    /// [`TransportError::StreamClosed`] (the inner stream is consumed,
595    /// because `wtransport::RecvStream::stop` takes `self` by value).
596    /// Stop reading once you have stopped a stream rather than matching
597    /// on which error comes back.
598    ///
599    /// # Errors
600    /// - [`TransportError::StreamClosed`] if the stream was already stopped,
601    ///   finished or reset.
602    /// - [`TransportError::Write`] if `code` is outside the QUIC varint range
603    ///   (`0..2^62`) — `Write` because what failed is the `STOP_SENDING` frame
604    ///   this endpoint would have sent. Nothing is sent in that case and the
605    ///   stream stays readable.
606    pub fn stop(&mut self, code: u64) -> Result<(), TransportError> {
607        match self {
608            RecvStream::Quic(s) => {
609                let code = varint_code(code)?;
610                s.stop(code).map_err(|_| TransportError::StreamClosed)
611            }
612            #[cfg(feature = "webtransport")]
613            RecvStream::WebTransport(s) => s.stop(code),
614        }
615    }
616}