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/// How the QUIC connection under a session was closed, as quinn recorded it.
119///
120/// The same over QUIC and WebTransport: both read the close off the
121/// `quinn::Connection`, so a transport-space code that `wtransport` keeps
122/// private is still read here. A `CLOSE_WEBTRANSPORT_SESSION` capsule is not a
123/// QUIC close; it reaches callers as [`TransportError::SessionClosed`].
124///
125/// There is none for a connection that is still open, nor for a close with no
126/// code: a timeout, a stateless reset, this endpoint's own application close.
127#[non_exhaustive]
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct TransportClose {
130    /// The close code.
131    pub code: u64,
132    /// Which space [`code`](Self::code) is a number in.
133    pub space: CodeSpace,
134    /// `true` when the peer sent the close, `false` when this endpoint's QUIC
135    /// stack detected a transport error and closed with it.
136    pub by_peer: bool,
137    /// The reason phrase, as sent.
138    pub reason: Vec<u8>,
139}
140
141impl TransportClose {
142    /// The close a quinn connection error records, if it carries a code.
143    pub(crate) fn of(e: &quinn::ConnectionError) -> Option<Self> {
144        match e {
145            quinn::ConnectionError::ConnectionClosed(close) => Some(Self {
146                code: u64::from(close.error_code),
147                space: CodeSpace::Transport,
148                by_peer: true,
149                reason: close.reason.to_vec(),
150            }),
151            quinn::ConnectionError::ApplicationClosed(close) => Some(Self {
152                code: close.error_code.into_inner(),
153                space: CodeSpace::Application,
154                by_peer: true,
155                reason: close.reason.to_vec(),
156            }),
157            quinn::ConnectionError::TransportError(error) => Some(Self {
158                code: u64::from(error.code),
159                space: CodeSpace::Transport,
160                by_peer: false,
161                reason: error.reason.clone().into_bytes(),
162            }),
163            _ => None,
164        }
165    }
166
167    /// The close `conn` has recorded so far.
168    pub(crate) fn read(conn: &quinn::Connection) -> Option<Self> {
169        conn.close_reason().as_ref().and_then(Self::of)
170    }
171}
172
173/// Errors from the transport layer.
174///
175/// `#[non_exhaustive]` so that a new failure mode is an additive change. The
176/// crate matches on these internally, where the attribute does not apply;
177/// downstream code that matches needs a `_` arm, which is the trade being made
178/// deliberately.
179#[derive(Debug, thiserror::Error)]
180#[non_exhaustive]
181pub enum TransportError {
182    /// Connection-level error (e.g., peer closed, timeout).
183    #[error("connection error: {0}")]
184    Connection(String),
185    /// Error writing to a stream.
186    #[error("write error: {0}")]
187    Write(String),
188    /// Error reading from a stream.
189    #[error("read error: {0}")]
190    Read(String),
191    /// Stream was closed.
192    #[error("stream closed")]
193    StreamClosed,
194    /// Error sending a datagram.
195    #[error("send datagram error: {0}")]
196    SendDatagram(String),
197    /// Connection was lost.
198    #[error("connection lost")]
199    ConnectionLost,
200    /// Error during connection establishment.
201    #[error("connect error: {0}")]
202    Connect(String),
203    /// A handshake reached the peer and did not complete, with its codes kept.
204    ///
205    /// Distinct from [`Connect`](Self::Connect), which is the same class of
206    /// event before there was anything numeric to keep — building the endpoint,
207    /// binding the socket, resolving a name. Both render the same way, so this
208    /// is additive to anything reading the message.
209    #[error("connect error: {0}")]
210    Handshake(HandshakeFailure),
211    /// The peer abandoned transmission by resetting the stream. Carries
212    /// the peer's application error code so a forwarder can mirror it
213    /// verbatim with [`SendStream::reset`].
214    #[error("stream reset by peer: code {0}")]
215    StreamReset(u64),
216    /// The peer is no longer accepting data on this stream
217    /// (`STOP_SENDING`). Carries the peer's application error code so a
218    /// forwarder can mirror it verbatim with [`RecvStream::stop`].
219    #[error("stream stopped by peer: code {0}")]
220    Stopped(u64),
221    /// The peer closed the whole session, with the code it named.
222    ///
223    /// Distinct from [`StreamReset`](Self::StreamReset), which ends one stream
224    /// and leaves the session alive. Over MoQT this code is a draft's own
225    /// session error — `VERSION_NEGOTIATION_FAILED`, `PROTOCOL_VIOLATION` — and
226    /// it is the sharpest thing a refusal says.
227    ///
228    /// It exists because quinn renders `ReadError::ConnectionLost` as the bare
229    /// words "connection lost" and drops the cause entirely, so without this
230    /// variant a relay that ends a session with a code *and* a reason phrase
231    /// reaches callers as neither. The code is on the value and also in the
232    /// message, spelled the way every other code this crate reports is spelled.
233    #[error("{reason} (code {code})")]
234    SessionClosed {
235        /// The peer's application close code.
236        code: u64,
237        /// quinn's own rendering of the close, which is the peer's reason
238        /// phrase where it sent one and the code where it did not.
239        reason: String,
240    },
241}
242
243/// A transport-agnostic connection (QUIC or WebTransport).
244pub enum Transport {
245    /// Raw QUIC via quinn.
246    Quic(quic::QuicTransport),
247    /// WebTransport via h3 + h3-quinn.
248    #[cfg(feature = "webtransport")]
249    WebTransport(webtransport::WebTransportTransport),
250}
251
252impl Transport {
253    /// Open a bidirectional stream.
254    pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), TransportError> {
255        match self {
256            Transport::Quic(t) => t.open_bi().await,
257            #[cfg(feature = "webtransport")]
258            Transport::WebTransport(t) => t.open_bi().await,
259        }
260    }
261
262    /// Accept an incoming bidirectional stream.
263    pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), TransportError> {
264        match self {
265            Transport::Quic(t) => t.accept_bi().await,
266            #[cfg(feature = "webtransport")]
267            Transport::WebTransport(t) => t.accept_bi().await,
268        }
269    }
270
271    /// Open a unidirectional send stream.
272    pub async fn open_uni(&self) -> Result<SendStream, TransportError> {
273        match self {
274            Transport::Quic(t) => t.open_uni().await,
275            #[cfg(feature = "webtransport")]
276            Transport::WebTransport(t) => t.open_uni().await,
277        }
278    }
279
280    /// Accept an incoming unidirectional stream.
281    pub async fn accept_uni(&self) -> Result<RecvStream, TransportError> {
282        match self {
283            Transport::Quic(t) => t.accept_uni().await,
284            #[cfg(feature = "webtransport")]
285            Transport::WebTransport(t) => t.accept_uni().await,
286        }
287    }
288
289    /// Send a datagram.
290    pub fn send_datagram(&self, data: Bytes) -> Result<(), TransportError> {
291        match self {
292            Transport::Quic(t) => t.send_datagram(data),
293            #[cfg(feature = "webtransport")]
294            Transport::WebTransport(t) => t.send_datagram(data),
295        }
296    }
297
298    /// Receive a datagram.
299    pub async fn recv_datagram(&self) -> Result<Bytes, TransportError> {
300        match self {
301            Transport::Quic(t) => t.recv_datagram().await,
302            #[cfg(feature = "webtransport")]
303            Transport::WebTransport(t) => t.recv_datagram().await,
304        }
305    }
306
307    /// Close the connection.
308    pub fn close(&self, code: u32, reason: &[u8]) {
309        match self {
310            Transport::Quic(t) => t.close(code, reason),
311            #[cfg(feature = "webtransport")]
312            Transport::WebTransport(t) => t.close(code, reason),
313        }
314    }
315
316    /// A future resolving when the session ends, carrying the peer's close.
317    ///
318    /// Take it before handing the transport to a handshake: a peer refusing a
319    /// setup often closes the session after the handshake has already seen
320    /// end-of-stream.
321    ///
322    /// `Some` on every transport this crate dials. Over WebTransport only an
323    /// application or session close carries a code; a QUIC transport close
324    /// arrives as text, because `wtransport` keeps that code private.
325    /// [`transport_closed`](Self::transport_closed) reads it typed.
326    pub fn closed(&self) -> Option<impl Future<Output = TransportError> + Send + 'static> {
327        let closed: std::pin::Pin<Box<dyn Future<Output = TransportError> + Send>> = match self {
328            Transport::Quic(t) => Box::pin(t.closed()),
329            #[cfg(feature = "webtransport")]
330            Transport::WebTransport(t) => Box::pin(t.closed()),
331        };
332        Some(closed)
333    }
334
335    /// How the QUIC connection was closed, once it has been. See
336    /// [`TransportClose`].
337    pub fn transport_close(&self) -> Option<TransportClose> {
338        TransportClose::read(&self.quinn_connection())
339    }
340
341    /// A future resolving when the QUIC connection closes, with its
342    /// [`TransportClose`].
343    ///
344    /// The typed counterpart to [`closed`](Self::closed), and taken for the
345    /// same reason: before the transport is handed to a handshake. Holding the
346    /// future keeps the connection alive.
347    pub fn transport_closed(
348        &self,
349    ) -> impl Future<Output = Option<TransportClose>> + Send + 'static {
350        let conn = self.quinn_connection();
351        async move { TransportClose::of(&conn.closed().await) }
352    }
353
354    /// A handle on the quinn connection under either transport.
355    pub(crate) fn quinn_connection(&self) -> quinn::Connection {
356        match self {
357            Transport::Quic(t) => t.quinn_connection().clone(),
358            #[cfg(feature = "webtransport")]
359            Transport::WebTransport(t) => t.quinn_connection().clone(),
360        }
361    }
362
363    /// The certificate chain the peer presented, DER-encoded, leaf first.
364    ///
365    /// Empty when there is none to report — the handshake has not completed,
366    /// the peer sent no chain — rather than an error, because none of those
367    /// are failures of this connection.
368    ///
369    /// **This is the whole of the certificate API, and that is deliberate.**
370    /// The bytes are handed over exactly as the peer sent them and nothing
371    /// here parses them, dates them, or decides whether they are trustworthy.
372    /// A certificate verdict is a judgement about what the caller is trying to
373    /// establish: a probe grading a public relay wants to know whether the
374    /// chain reaches a public root, an operator running against their own CA
375    /// wants precisely the opposite answer to count as healthy, and a library
376    /// that picked one would be silently wrong for the other. Whoever knows
377    /// the question owns the answer; this owns the evidence.
378    ///
379    /// One consequence worth naming, because it is the finding this exists to
380    /// make visible: the *length* of what comes back is itself a measurement.
381    /// A relay that serves a leaf and no intermediate hands back a chain of
382    /// one, which a client validating against a fixed root set rejects while a
383    /// browser holding a cached intermediate connects to it happily. That is a
384    /// misconfiguration and not a mystery — but only to a caller that can see
385    /// how many certificates arrived, which is why the chain is returned whole
386    /// rather than as a leaf.
387    ///
388    /// Read off the live connection each time — nothing is cached — so a
389    /// caller that intends to keep the chain must take it while it still holds
390    /// the `Transport`. Closing does not by itself erase the answer (quinn
391    /// keeps the crypto session for the handle's lifetime), but dropping the
392    /// handle does, and a caller that closes by value has done both.
393    pub fn peer_certificates(&self) -> Vec<Vec<u8>> {
394        match self {
395            Transport::Quic(t) => t.peer_certificates(),
396            #[cfg(feature = "webtransport")]
397            Transport::WebTransport(t) => t.peer_certificates(),
398        }
399    }
400
401    /// The application protocol the server selected in the CONNECT response.
402    ///
403    /// `None` over QUIC, where the equivalent answer is the ALPN and
404    /// [`dial_quic_to`] already returns it. Over WebTransport this is
405    /// `WT-Protocol`, which from draft-15 on is where MOQT settles its version
406    /// on that transport. See
407    /// [`WebTransportTransport::wt_protocol`](webtransport::WebTransportTransport::wt_protocol)
408    /// for how the value is read, and why an unquoted one is still read.
409    #[cfg(feature = "wt-protocol")]
410    pub fn wt_protocol(&self) -> Option<String> {
411        match self {
412            Transport::Quic(_) => None,
413            Transport::WebTransport(t) => t.wt_protocol(),
414        }
415    }
416}
417
418/// A transport-agnostic send stream.
419pub enum SendStream {
420    /// Raw QUIC send stream.
421    Quic(quinn::SendStream),
422    /// WebTransport send stream.
423    #[cfg(feature = "webtransport")]
424    WebTransport(webtransport::WtSendStream),
425}
426
427impl SendStream {
428    /// Get the QUIC stream ID (transport-level identifier).
429    pub fn stream_id(&self) -> u64 {
430        match self {
431            SendStream::Quic(s) => u64::from(s.id()),
432            #[cfg(feature = "webtransport")]
433            SendStream::WebTransport(s) => s.stream_id(),
434        }
435    }
436
437    /// Write all bytes to the stream.
438    ///
439    /// Fails with [`TransportError::Stopped`] carrying the peer's
440    /// application error code if the peer sent `STOP_SENDING`.
441    pub async fn write_all(&mut self, buf: &[u8]) -> Result<(), TransportError> {
442        match self {
443            SendStream::Quic(s) => s.write_all(buf).await.map_err(TransportError::from),
444            #[cfg(feature = "webtransport")]
445            SendStream::WebTransport(s) => s.write_all(buf).await,
446        }
447    }
448
449    /// Finish the stream (send FIN).
450    pub fn finish(&mut self) -> Result<(), TransportError> {
451        match self {
452            SendStream::Quic(s) => {
453                s.finish().map_err(|_| TransportError::StreamClosed)?;
454                Ok(())
455            }
456            #[cfg(feature = "webtransport")]
457            SendStream::WebTransport(s) => s.finish(),
458        }
459    }
460
461    /// Reset the stream, telling the peer transmission was abandoned and
462    /// handing it `code` as the `RESET_STREAM` application error code.
463    ///
464    /// This is the only way to abandon a send stream truthfully: simply
465    /// dropping a `SendStream` sends a FIN instead, which tells the peer
466    /// the stream ended *cleanly*. A forwarder that saw the far side
467    /// reset must call this with the code it received, so a truncated
468    /// stream is never laundered into a complete one.
469    ///
470    /// # Errors
471    /// - [`TransportError::StreamClosed`] if the stream was already finished or
472    ///   reset.
473    /// - [`TransportError::Write`] if `code` is outside the QUIC varint range
474    ///   (`0..2^62`). Nothing is sent in that case and the stream stays usable.
475    pub fn reset(&mut self, code: u64) -> Result<(), TransportError> {
476        match self {
477            SendStream::Quic(s) => {
478                let code = varint_code(code)?;
479                s.reset(code).map_err(|_| TransportError::StreamClosed)
480            }
481            #[cfg(feature = "webtransport")]
482            SendStream::WebTransport(s) => s.reset(code),
483        }
484    }
485
486    /// Set the stream's send priority.
487    ///
488    /// Streams with a higher priority have their locally buffered data
489    /// transmitted first. Every stream starts at priority 0.
490    ///
491    /// # Errors
492    /// [`TransportError::StreamClosed`] once the stream's send state has been
493    /// discarded — but only on the QUIC arm, and quinn keeps that state around
494    /// for a while after a `finish` or `reset`, so this is not a reliable *is
495    /// the stream still live?* probe. The WebTransport arm never reports it at
496    /// all: `wtransport` discards the underlying error and always succeeds. Do
497    /// not treat `Ok(())` as proof the priority took effect.
498    pub fn set_priority(&self, priority: i32) -> Result<(), TransportError> {
499        match self {
500            SendStream::Quic(s) => {
501                s.set_priority(priority).map_err(|_| TransportError::StreamClosed)
502            }
503            #[cfg(feature = "webtransport")]
504            SendStream::WebTransport(s) => s.set_priority(priority),
505        }
506    }
507
508    /// Resolve when this send half stops being useful.
509    ///
510    /// [`write_all`](Self::write_all) only reports `STOP_SENDING` when
511    /// there is something to write, so a forwarder that has gone idle —
512    /// the normal state of a stream waiting on its source — never learns
513    /// that the peer walked away. This is the watcher for that case: it
514    /// borrows nothing, so it can sit in a `select!` beside the read
515    /// branch for the stream's whole life.
516    /// The four outcomes, on quinn 0.11.9:
517    ///
518    /// - The peer sent `STOP_SENDING` → [`TransportError::Stopped`] carrying
519    ///   the peer's application error code, the same typed value a failed
520    ///   [`write_all`](Self::write_all) produces, so a forwarder can mirror it
521    ///   verbatim with no new match arm.
522    /// - The stream was finished and the peer acked every byte → `Ok(())`.
523    ///   quinn cannot tell that apart from *the send state was discarded*, so
524    ///   `Ok(())` means *this stream is over*, never *the peer is happy*. It
525    ///   cannot fire on a live stream.
526    /// - The connection was lost → [`TransportError::Connection`].
527    /// - **The local side reset the stream → this future never resolves.**
528    ///   quinn keeps no stopped-notification for a stream it has locally reset,
529    ///   so a watcher held across a [`reset`](Self::reset) stays pending until
530    ///   the connection ends and holds a `tokio::sync::Notify` alive for that
531    ///   long. Retire the future *before* resetting.
532    ///
533    /// The returned future is `'static`: it holds a handle on the
534    /// connection, not on `self`, so it may outlive this `SendStream`
535    /// and be spawned or stored on its own.
536    ///
537    /// # WebTransport arm
538    ///
539    /// Reaches the same quinn future through
540    /// `webtransport::WtSendStream::quic_stream` — spelled as code and not
541    /// as an intra-doc link because the `webtransport` module is behind its
542    /// own feature, so a link to it is broken in the default build and
543    /// `just doc-check` runs `cargo doc --workspace --no-deps` without it.
544    /// This bypasses `wtransport`'s own `stopped`, which collapses stopped /
545    /// closed / disconnected into one error. One asymmetry the QUIC arm
546    /// does not have: [`finish`](Self::finish) moves the inner
547    /// `wtransport` stream out, so calling this afterwards yields a
548    /// future that resolves immediately to
549    /// [`TransportError::StreamClosed`] — the "finished and acked" case
550    /// is unobservable there. Not yet exercised against a live
551    /// WebTransport session.
552    pub fn stopped(&self) -> impl Future<Output = Result<(), TransportError>> + Send + 'static {
553        // Resolve the arm eagerly so the returned future borrows nothing
554        // and both arms hand back the *same* quinn future type.
555        let watched: Result<_, TransportError> = match self {
556            SendStream::Quic(s) => Ok(s.stopped()),
557            #[cfg(feature = "webtransport")]
558            SendStream::WebTransport(s) => s.quic_stream().map(|q| q.stopped()),
559        };
560        async move { stopped_outcome(watched?.await) }
561    }
562}
563
564/// Map quinn's `stopped()` result onto [`TransportError`].
565///
566/// `Ok(None)` is quinn's *the send state is gone*, which it reports both for a
567/// finished-and-acked stream and for one whose state it discarded; neither is
568/// an error, so both become `Ok(())`.
569fn stopped_outcome(
570    outcome: Result<Option<quinn::VarInt>, quinn::StoppedError>,
571) -> Result<(), TransportError> {
572    match outcome {
573        Ok(Some(code)) => Err(TransportError::Stopped(code.into_inner())),
574        Ok(None) => Ok(()),
575        Err(e) => Err(TransportError::Connection(e.to_string())),
576    }
577}
578
579/// Convert an application error code to a quinn `VarInt`.
580///
581/// QUIC application error codes are varints, so values above
582/// 2^62 - 1 cannot be represented on the wire.
583fn varint_code(code: u64) -> Result<quinn::VarInt, TransportError> {
584    quinn::VarInt::from_u64(code)
585        .map_err(|_| TransportError::Write(format!("error code {code} exceeds the varint range")))
586}
587
588/// A transport-agnostic receive stream.
589pub enum RecvStream {
590    /// Raw QUIC receive stream.
591    Quic(quinn::RecvStream),
592    /// WebTransport receive stream.
593    #[cfg(feature = "webtransport")]
594    WebTransport(webtransport::WtRecvStream),
595}
596
597impl RecvStream {
598    /// Get the QUIC stream ID (transport-level identifier).
599    pub fn stream_id(&self) -> u64 {
600        match self {
601            RecvStream::Quic(s) => u64::from(s.id()),
602            #[cfg(feature = "webtransport")]
603            RecvStream::WebTransport(s) => s.stream_id(),
604        }
605    }
606
607    /// Read data into the buffer. Returns `Ok(Some(n))` with bytes read,
608    /// `Ok(None)` on stream end, or `Err` on failure.
609    ///
610    /// Fails with [`TransportError::StreamReset`] carrying the peer's
611    /// application error code if the peer reset the stream, which is how
612    /// callers distinguish an abandoned stream from a clean FIN
613    /// (`Ok(None)`).
614    pub async fn read(&mut self, buf: &mut [u8]) -> Result<Option<usize>, TransportError> {
615        match self {
616            RecvStream::Quic(s) => s.read(buf).await.map_err(TransportError::from),
617            #[cfg(feature = "webtransport")]
618            RecvStream::WebTransport(s) => s.read(buf).await,
619        }
620    }
621
622    /// Wait for the peer to reset this stream — **without reading a byte**.
623    ///
624    /// [`read`](Self::read) is the only other way to learn that a peer sent
625    /// `RESET_STREAM`, and it is unusable by a reader that has stopped
626    /// consuming on purpose: a forwarder applying backpressure holds its
627    /// source unread, so the reset surfaces on a call it is deliberately
628    /// not making, and the abandonment goes unobserved for as long as the
629    /// backpressure lasts. This observes the same event on its own.
630    ///
631    /// **It consumes nothing.** No bytes leave the receive buffer, so no
632    /// `MAX_STREAM_DATA` credit is granted and the peer stays flow-control
633    /// blocked exactly as it was. That is the whole point: it is safe to
634    /// poll *while* backpressure is being applied, which
635    /// [`read`](Self::read) is not.
636    ///
637    /// Cancel-safe: it registers interest and consumes no state, so
638    /// dropping the future loses nothing.
639    ///
640    /// # Returns
641    /// - `Ok(Some(code))` — the peer reset the stream with this application
642    ///   error code. The same code [`TransportError::StreamReset`] would have
643    ///   carried out of [`read`](Self::read).
644    /// - `Ok(None)` — **no reset is observable on this stream, now or ever**,
645    ///   and the caller must stop asking: this resolves immediately every time,
646    ///   so a caller that re-polls it in a loop spins. Either the transport
647    ///   freed the stream's state (it was finished and fully read, or stopped)
648    ///   or, on the WebTransport arm, `wtransport` exposes no reset-only
649    ///   observable at all and this answers `Ok(None)` unconditionally.
650    /// - `Err` — a connection-level failure.
651    pub async fn received_reset(&mut self) -> Result<Option<u64>, TransportError> {
652        match self {
653            RecvStream::Quic(s) => match s.received_reset().await {
654                Ok(code) => Ok(code.map(|c| c.into_inner())),
655                Err(e) => Err(TransportError::Connection(e.to_string())),
656            },
657            // `wtransport::RecvStream` has no reset-only observable, so a
658            // WebTransport forwarder keeps the pre-existing behaviour: a
659            // peer reset is seen on the next `read` and not before.
660            #[cfg(feature = "webtransport")]
661            RecvStream::WebTransport(_) => Ok(None),
662        }
663    }
664
665    /// Stop accepting data on the stream, discarding anything unread and
666    /// telling the peer to stop transmitting with `code` as the
667    /// `STOP_SENDING` application error code.
668    ///
669    /// Dropping a `RecvStream` also stops it, but with a hard-coded code
670    /// of 0 — so a forwarder mirroring a peer's `STOP_SENDING` must call
671    /// this explicitly to keep the original code intact.
672    ///
673    /// After a successful call the stream is no longer readable, and the
674    /// two arms say so differently: the QUIC arm's [`read`](Self::read)
675    /// returns [`TransportError::Read`], the WebTransport arm's returns
676    /// [`TransportError::StreamClosed`] (the inner stream is consumed,
677    /// because `wtransport::RecvStream::stop` takes `self` by value).
678    /// Stop reading once you have stopped a stream rather than matching
679    /// on which error comes back.
680    ///
681    /// # Errors
682    /// - [`TransportError::StreamClosed`] if the stream was already stopped,
683    ///   finished or reset.
684    /// - [`TransportError::Write`] if `code` is outside the QUIC varint range
685    ///   (`0..2^62`) — `Write` because what failed is the `STOP_SENDING` frame
686    ///   this endpoint would have sent. Nothing is sent in that case and the
687    ///   stream stays readable.
688    pub fn stop(&mut self, code: u64) -> Result<(), TransportError> {
689        match self {
690            RecvStream::Quic(s) => {
691                let code = varint_code(code)?;
692                s.stop(code).map_err(|_| TransportError::StreamClosed)
693            }
694            #[cfg(feature = "webtransport")]
695            RecvStream::WebTransport(s) => s.stop(code),
696        }
697    }
698}