moq_net/session.rs
1//! A MoQ session handle and a snapshot of its connection statistics.
2
3use std::{sync::Arc, task::Poll, time::Duration};
4
5use web_transport_trait::Stats as _;
6
7use crate::{Error, SessionError, Version, bandwidth, goaway, runtime::Timers as _};
8
9/// How long [`Session::close`] waits for queued data before closing anyway.
10const CLOSE_TIMEOUT: Duration = Duration::from_secs(1);
11
12/// A close requested by a session handle, executed by the driver.
13#[derive(Clone)]
14enum Close {
15 /// Close now with this code.
16 Abort { code: u32, reason: String },
17 /// Close once the protocol owes the peer nothing, or at [`CLOSE_TIMEOUT`].
18 Drain,
19}
20
21/// How the session ended, published by the driver.
22#[derive(Clone)]
23struct Ended {
24 /// The transport's terminal error.
25 err: Error,
26 /// The drain's outcome, when a drain is what closed the transport.
27 drain: Option<Result<(), Error>>,
28}
29
30/// The stats cell shared between the driver's sampler and the handles.
31struct StatsState {
32 /// The latest sample the driver took (or the construction-time snapshot).
33 sample: Stats,
34 /// A handle read the stats since the last sample: keep sampling.
35 demanded: bool,
36}
37
38/// A snapshot of connection statistics for a [`Session`].
39///
40/// Every field is optional: availability depends on the transport backend (native QUIC
41/// reports all of them, the browser WebTransport reports few or none) and on the
42/// connection state (e.g. `estimated_send_rate` is `None` until the congestion controller
43/// has a window). `None` means "not reported", not "zero".
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45#[non_exhaustive]
46pub struct Stats {
47 /// Smoothed round-trip time estimate.
48 pub rtt: Option<Duration>,
49
50 /// Estimated send bandwidth from the congestion controller.
51 pub estimated_send_rate: Option<bandwidth::Rate>,
52
53 /// Estimated receive bandwidth from MoQ PROBE.
54 ///
55 /// `None` unless the negotiated version supports PROBE (moq-lite-03+).
56 pub estimated_recv_rate: Option<bandwidth::Rate>,
57
58 /// Total bytes sent over the connection, including retransmissions and overhead.
59 pub bytes_sent: Option<u64>,
60
61 /// Total bytes received over the connection, including duplicates and overhead.
62 pub bytes_received: Option<u64>,
63
64 /// Total bytes lost (detected via retransmission or acknowledgement).
65 pub bytes_lost: Option<u64>,
66
67 /// Total datagrams sent.
68 pub packets_sent: Option<u64>,
69
70 /// Total datagrams received.
71 pub packets_received: Option<u64>,
72
73 /// Total datagrams detected as lost.
74 pub packets_lost: Option<u64>,
75}
76
77/// The congestion controller's view of the connection, as PROBE reports it.
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
79pub(crate) struct Estimate {
80 /// The congestion controller's send rate.
81 pub send_rate: Option<bandwidth::Rate>,
82 /// The smoothed round-trip time.
83 pub rtt: Option<Duration>,
84}
85
86impl Estimate {
87 /// Read the transport's current estimate.
88 pub(crate) fn read<S: crate::transport::poll::Session>(session: &S) -> Self {
89 Self::from(&snapshot(session))
90 }
91}
92
93impl From<&Stats> for Estimate {
94 fn from(stats: &Stats) -> Self {
95 Self {
96 send_rate: stats.estimated_send_rate,
97 rtt: stats.rtt,
98 }
99 }
100}
101
102/// A MoQ transport session, wrapping a WebTransport connection.
103///
104/// Returned with a [`Driver`](crate::Driver) by [`crate::Client::connect`] and
105/// [`crate::Server::accept`]. The caller must poll or spawn that driver to run
106/// the session.
107///
108/// Like every handle in this library, the lifecycle is reference counted: clones
109/// share the connection, the transport closes when the last clone drops, and
110/// [`abort`](Self::abort) closes it explicitly with an error. The handle and the
111/// driver are severed in both directions: the driver holds no `Session` clone,
112/// so running it never keeps the session alive, and the `Session`
113/// holds no transport, so the handle is `Send + Sync` whatever transport the
114/// driver uses. Everything transport-shaped (the close, the close reason,
115/// the stats sample) is relayed through the driver.
116#[derive(Clone)]
117pub struct Session {
118 /// Handle side to driver: `Some` once [`abort`](Self::abort) or
119 /// [`close`](Self::close) ran; the channel closing (the last handle
120 /// dropping) is the implicit Cancel, unless a drain was already requested.
121 close: kio::Producer<Option<Close>>,
122 /// Driver to handle side: how the transport ended.
123 closed: kio::Consumer<Option<Ended>>,
124 stats: kio::Shared<StatsState>,
125 version: Version,
126 // Weak, so the sampler runs only while a caller holds a consumer.
127 send_bandwidth: Option<bandwidth::Weak>,
128 recv_bandwidth: Option<bandwidth::Consumer>,
129 goaway: Arc<goaway::Handle>,
130}
131
132impl Session {
133 /// Returns the negotiated protocol version.
134 pub fn version(&self) -> Version {
135 self.version
136 }
137
138 /// Returns a consumer for the estimated send bitrate (from the congestion controller).
139 ///
140 /// Returns `None` if the QUIC backend doesn't support bandwidth estimation.
141 pub fn send_bandwidth(&self) -> Option<bandwidth::Consumer> {
142 self.send_bandwidth.as_ref().map(bandwidth::Weak::consume)
143 }
144
145 /// Returns a consumer for the estimated receive bitrate (from PROBE).
146 ///
147 /// Returns `None` if the MoQ version doesn't support PROBE (requires moq-lite-03+).
148 pub fn recv_bandwidth(&self) -> Option<bandwidth::Consumer> {
149 self.recv_bandwidth.clone()
150 }
151
152 /// Returns a snapshot of the current connection statistics.
153 ///
154 /// Cheap and non-blocking: this reads the latest sample the session's
155 /// driver took, and schedules a refresh, so periodic polling observes
156 /// fresh counters (100ms cadence). See [`Stats`] for which
157 /// metrics each backend reports.
158 pub fn stats(&self) -> Stats {
159 let mut stats = {
160 let mut state = self.stats.lock();
161 // A read is demand: wake the sampler, but only mutate (and so wake)
162 // when the flag actually flips.
163 if !state.demanded {
164 state.demanded = true;
165 }
166 state.sample
167 };
168 stats.estimated_recv_rate = self.recv_bandwidth.as_ref().and_then(bandwidth::Consumer::peek);
169 stats
170 }
171
172 /// Close the transport with an explicit error, instead of waiting for the last
173 /// clone to drop. Idempotent: the first abort wins, and it cuts short a
174 /// [`close`](Self::close) still draining.
175 ///
176 /// The close is executed by the session's driver, so it reaches the wire
177 /// once the runtime polls it (immediately on a live runtime).
178 pub fn abort(&self, err: Error) {
179 if let Ok(mut close) = self.close.write()
180 && !matches!(*close, Some(Close::Abort { .. }))
181 {
182 *close = Some(Close::Abort {
183 code: SessionError::from(&err).to_code(),
184 reason: err.to_string(),
185 });
186 }
187 }
188
189 /// Close the session once the data it queued has been delivered.
190 ///
191 /// Waits until every stream still serving the peer (a subscription whose track
192 /// finished, a fetch, a track info reply) has written its data and FIN and the
193 /// peer acknowledged them, then closes the transport. Returns
194 /// [`Error::Timeout`] if that takes longer than one second, closing anyway, or
195 /// the session's terminal error if it ended some other way first. A track that
196 /// is still live never finishes, so finish or abort tracks before closing.
197 ///
198 /// moq-transport (IETF) sessions close without waiting.
199 pub async fn close(self) -> Result<(), Error> {
200 if let Ok(mut close) = self.close.write()
201 && close.is_none()
202 {
203 *close = Some(Close::Drain);
204 }
205 // The drain outlives this handle, so dropping it cannot cut the drain short.
206 let closed = self.closed.clone();
207 drop(self);
208
209 match closed
210 .wait(|state| match &**state {
211 Some(ended) => Poll::Ready(ended.clone()),
212 None => Poll::Pending,
213 })
214 .await
215 {
216 Ok(ended) => ended.drain.unwrap_or(Err(ended.err)),
217 Err(kio::Closed) => Err(Error::Cancel),
218 }
219 }
220
221 /// Block until the transport session is closed, returning the reason.
222 ///
223 /// A close code the peer sent is decoded through the session registry (so an auth
224 /// rejection arrives as `Error::Session(SessionError::Unauthorized)`); every peer code is
225 /// preserved as [`Error::Session`], and a close carrying no application code surfaces as
226 /// [`Error::Transport`]. See [`Error::from_transport`]. If the runtime drops
227 /// the driver instead of running it to completion, this resolves with
228 /// [`Error::Cancel`].
229 pub async fn closed(&self) -> Error {
230 match self
231 .closed
232 .wait(|state| match &**state {
233 Some(ended) => Poll::Ready(ended.err.clone()),
234 None => Poll::Pending,
235 })
236 .await
237 {
238 Ok(err) => err,
239 // The driver was dropped before it could observe the close.
240 Err(kio::Closed) => Error::Cancel,
241 }
242 }
243
244 /// Drain the peer gracefully: the handle for sending this session's single
245 /// GOAWAY.
246 ///
247 /// The graceful counterpart to [`abort`](Self::abort). Send the message with
248 /// [`goaway::Producer::send`], then await [`closed`](Self::closed) to observe
249 /// the peer leaving.
250 ///
251 /// Only a [`Goaway`](goaway::Goaway) carrying a [`timeout`](goaway::Goaway::timeout)
252 /// schedules a close of our own, so without one this waits for a peer that may
253 /// never leave. Set a deadline when the drain has to finish.
254 ///
255 /// Available on every version. A version with no GOAWAY message (moq-lite-03
256 /// and earlier) simply carries no explanation to the peer; the deadline is the
257 /// sender's own timer either way, so the session still closes on schedule and
258 /// the caller does not branch on the negotiated version.
259 pub fn drain(&self) -> goaway::Producer {
260 self.goaway.producer()
261 }
262
263 /// Observe a GOAWAY from the peer, telling us to migrate elsewhere.
264 ///
265 /// [`peek`](goaway::Consumer::peek) is the cheap synchronous check;
266 /// [`recv`](goaway::Consumer::recv) waits for one. Once a GOAWAY arrives, new
267 /// subscribe and announce-interest requests on this session are refused (both
268 /// drafts forbid opening new streams afterward); existing subscriptions keep
269 /// flowing until the session closes.
270 pub fn draining(&self) -> goaway::Consumer {
271 self.goaway.consumer()
272 }
273}
274
275impl Session {
276 pub(super) fn new<S>(
277 runtime: crate::time::Clock,
278 session: S,
279 version: Version,
280 recv_bandwidth: Option<bandwidth::Consumer>,
281 protocol: crate::driver::Protocol<S>,
282 goaway: goaway::Handle,
283 estimate: Option<kio::Producer<Estimate>>,
284 ) -> (Self, crate::Driver<S>)
285 where
286 S: crate::transport::poll::Session,
287 {
288 let sample = snapshot(&session);
289 if let Some(estimate) = &estimate
290 && let Ok(mut current) = estimate.write()
291 {
292 *current = Estimate::from(&sample);
293 }
294
295 // Send bandwidth is version-agnostic: it depends on QUIC backend support.
296 let (send_bandwidth, send_producer) = if sample.estimated_send_rate.is_some() {
297 let producer = bandwidth::Producer::new();
298 (Some(producer.weak()), Some(producer))
299 } else {
300 (None, None)
301 };
302
303 let close = kio::Producer::new(None);
304 let closed = kio::Producer::new(None);
305 let closed_consumer = closed.consume();
306 let stats = kio::Shared::new(StatsState {
307 sample,
308 demanded: false,
309 });
310
311 let supervisor = Supervisor {
312 local_close: protocol.local_close(),
313 runtime: runtime.clone(),
314 closed_watch: session.clone(),
315 session,
316 close: Some(close.consume()),
317 closed,
318 stats: stats.clone(),
319 send_bandwidth: send_producer,
320 estimate,
321 sampled: None,
322 mode: SamplerMode::Idle,
323 drain: Drain::Idle,
324 };
325
326 let session = Self {
327 close,
328 closed: closed_consumer,
329 stats,
330 version,
331 send_bandwidth,
332 recv_bandwidth,
333 goaway: Arc::new(goaway),
334 };
335 let driver = crate::Driver::new(
336 runtime.clone(),
337 crate::driver::State {
338 protocol,
339 supervisor: Some(supervisor),
340 result: None,
341 },
342 );
343
344 (session, driver)
345 }
346}
347
348/// The driver's transport-facing half of a [`Session`]: it executes the
349/// handles' close requests, publishes the transport's terminal error, and
350/// samples the connection stats (including the send-bandwidth estimate) while
351/// anyone is consuming them.
352///
353/// Finishes once the transport reports closed; everything else is moot then.
354pub(crate) struct Supervisor<S> {
355 local_close: Arc<std::sync::atomic::AtomicBool>,
356 runtime: crate::time::Clock,
357 session: S,
358 // A dedicated clone for the close watch, since each pending poll operation
359 // needs its own handle.
360 closed_watch: S,
361 /// Handle-side close requests; `None` once one was executed (only the
362 /// first close matters, and the channel closing is the last handle
363 /// dropping).
364 close: Option<kio::Consumer<Option<Close>>>,
365 /// Where the transport's end is published for [`Session::closed`].
366 closed: kio::Producer<Option<Ended>>,
367 stats: kio::Shared<StatsState>,
368 /// The send-rate estimate channel, when the backend reports one. `None`
369 /// also once every consumer is gone for good.
370 send_bandwidth: Option<bandwidth::Producer>,
371 /// Published on change while a consumer exists; see [`Self::poll_sampler`].
372 estimate: Option<kio::Producer<Estimate>>,
373 /// When the last sample was taken.
374 sampled: Option<crate::time::Instant>,
375 mode: SamplerMode,
376 drain: Drain,
377}
378
379/// A [`Session::close`] waiting for the protocol to deliver what it queued.
380enum Drain {
381 /// Nobody asked for one.
382 Idle,
383 /// Requested: close once drained, or at the deadline.
384 Waiting(crate::runtime::Deadline<crate::time::Clock>),
385 /// The drain closed the transport, with this outcome.
386 Done(Result<(), Error>),
387}
388
389enum SamplerMode {
390 /// Nobody wants stats; sampling is paused.
391 Idle,
392 /// Someone does; sample when the deadline elapses.
393 Polling {
394 deadline: crate::runtime::Deadline<crate::time::Clock>,
395 },
396}
397
398impl<S: crate::transport::poll::Session> Supervisor<S> {
399 const POLL_INTERVAL: Duration = Duration::from_millis(100);
400
401 pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> {
402 let mut cx = std::task::Context::from_waker(waiter.waker());
403
404 // The transport's terminal error ends the supervisor.
405 if let Poll::Ready(err) = self.closed_watch.poll_closed(&mut cx) {
406 // Nothing samples once this returns, but `stats()` keeps serving
407 // this cell, so leave it holding the session's final counters
408 // rather than whichever sample the last demand happened to catch.
409 self.stats.lock().sample = snapshot(&self.session);
410 let drain = match std::mem::replace(&mut self.drain, Drain::Idle) {
411 Drain::Done(res) => Some(res),
412 _ => None,
413 };
414 if let Ok(mut closed) = self.closed.write() {
415 *closed = Some(Ended {
416 err: Error::from_transport(err),
417 drain,
418 });
419 }
420 return Poll::Ready(());
421 }
422
423 // Execute the handle-side close requests. The channel closing is the
424 // last handle dropping, with a request written just before winning over
425 // the implicit cancel. A drain loops back to watch for an abort, which
426 // cuts it short.
427 while let Some(close) = &self.close {
428 let draining = matches!(self.drain, Drain::Waiting(_));
429 let (request, last) = match close.poll(waiter, |state| match &**state {
430 Some(Close::Drain) if draining => Poll::Pending,
431 Some(request) => Poll::Ready(request.clone()),
432 None => Poll::Pending,
433 }) {
434 Poll::Ready(Ok(request)) => (request, false),
435 Poll::Ready(Err(last)) => (
436 last.clone().unwrap_or_else(|| {
437 self.local_close.store(true, std::sync::atomic::Ordering::Relaxed);
438 Close::Abort {
439 code: SessionError::Cancel.to_code(),
440 reason: "dropped".to_string(),
441 }
442 }),
443 true,
444 ),
445 Poll::Pending => break,
446 };
447 match request {
448 Close::Abort { code, reason } => {
449 self.session.close(code, &reason);
450 self.drain = Drain::Idle;
451 self.close = None;
452 }
453 Close::Drain => {
454 if !draining {
455 self.drain = Drain::Waiting(crate::runtime::Deadline::after(&self.runtime, CLOSE_TIMEOUT));
456 }
457 // No handle is left to abort.
458 if last {
459 self.close = None;
460 }
461 }
462 }
463 }
464
465 self.poll_sampler(waiter);
466 Poll::Pending
467 }
468
469 /// Finish a requested drain once the protocol owes the peer nothing, or at
470 /// the deadline. Returns whether this closed the transport.
471 ///
472 /// Called after the protocol ran this turn, since only then is `drained`
473 /// current.
474 pub(crate) fn poll_drain(&mut self, drained: bool, waiter: &kio::Waiter) -> bool {
475 let Drain::Waiting(deadline) = &mut self.drain else {
476 return false;
477 };
478 let res = match drained {
479 true => Ok(()),
480 false if deadline.poll(waiter).is_ready() => Err(Error::Timeout),
481 false => return false,
482 };
483 self.local_close.store(true, std::sync::atomic::Ordering::Relaxed);
484 self.session.close(SessionError::Cancel.to_code(), "");
485 self.drain = Drain::Done(res);
486 // The transport is closed, so no later request can change anything.
487 self.close = None;
488 true
489 }
490
491 /// Take one sample, publishing it to the bandwidth and estimate channels.
492 fn sample(&mut self) -> Stats {
493 let sample = snapshot(&self.session);
494 self.sampled = Some(self.runtime.now());
495 if let Some(producer) = &self.send_bandwidth {
496 // An error means every consumer is gone for good; the stats cell
497 // still wants the sample.
498 if producer.set(sample.estimated_send_rate).is_err() {
499 self.send_bandwidth = None;
500 }
501 }
502 if let Some(estimate) = &self.estimate
503 && let Ok(mut current) = estimate.write()
504 {
505 let next = Estimate::from(&sample);
506 // Only a change wakes the consumers.
507 if *current != next {
508 *current = next;
509 }
510 }
511 sample
512 }
513
514 /// Take one sample for the readers that asked, and arm the next deadline.
515 fn sample_demanded(&mut self) {
516 let sample = self.sample();
517 let mut stats = self.stats.lock();
518 stats.sample = sample;
519 stats.demanded = false;
520 drop(stats);
521 self.mode = SamplerMode::Polling {
522 deadline: crate::runtime::Deadline::after(&self.runtime, Self::POLL_INTERVAL),
523 };
524 }
525
526 fn poll_sampler(&mut self, waiter: &kio::Waiter) {
527 self.poll_demanded(waiter);
528
529 // An estimate consumer (a PROBE stream) samples only when the driver runs
530 // anyway, never on a timer of its own: a session nothing wakes is sending
531 // nothing, so its estimate has nothing new to say. The driver polls this
532 // before the protocol, so the estimate a consumer reads is at most one
533 // interval old.
534 let Some(estimate) = &self.estimate else { return };
535 if estimate.poll_used(waiter).is_pending() {
536 return;
537 }
538 let now = self.runtime.now();
539 if self
540 .sampled
541 .is_none_or(|at| now.duration_since(at) >= Self::POLL_INTERVAL)
542 {
543 // Not into the stats cell: writing it would wake this sampler's own
544 // registration there, and stats readers demand their own samples.
545 self.sample();
546 }
547 }
548
549 /// Sample on an interval while a bandwidth consumer or a stats reader wants it.
550 fn poll_demanded(&mut self, waiter: &kio::Waiter) {
551 loop {
552 match &mut self.mode {
553 SamplerMode::Idle => {
554 // Demand is a bandwidth consumer appearing or a stats read.
555 let mut demanded = match &self.send_bandwidth {
556 Some(producer) => match producer.poll_used(waiter) {
557 Poll::Ready(Ok(())) => true,
558 Poll::Ready(Err(_)) => {
559 self.send_bandwidth = None;
560 false
561 }
562 Poll::Pending => false,
563 },
564 None => false,
565 };
566 demanded |= self
567 .stats
568 .poll(waiter, |state| match state.demanded {
569 true => Poll::Ready(()),
570 false => Poll::Pending,
571 })
572 .is_ready();
573 if !demanded {
574 return;
575 }
576 self.sample_demanded();
577 }
578 SamplerMode::Polling { deadline } => {
579 if deadline.poll(waiter).is_pending() {
580 return;
581 }
582 // The interval elapsed: pause unless someone still cares.
583 let used = self.send_bandwidth.as_ref().is_some_and(bandwidth::Producer::is_used);
584 if !used && !self.stats.read().demanded {
585 self.mode = SamplerMode::Idle;
586 continue;
587 }
588 self.sample_demanded();
589 // Loop so the fresh deadline registers the waiter.
590 }
591 }
592 }
593 }
594}
595
596/// A [`Stats`] snapshot of the transport's counters.
597///
598/// `estimated_recv_rate` is filled in at the [`Session`] level (it comes from
599/// MoQ PROBE, not the transport), so it stays `None` here.
600fn snapshot<S: crate::transport::poll::Session>(session: &S) -> Stats {
601 let stats = session.stats();
602 Stats {
603 rtt: stats.rtt(),
604 estimated_send_rate: stats.estimated_send_rate().map(bandwidth::Rate::from_bps),
605 bytes_sent: stats.bytes_sent(),
606 bytes_received: stats.bytes_received(),
607 bytes_lost: stats.bytes_lost(),
608 packets_sent: stats.packets_sent(),
609 packets_received: stats.packets_received(),
610 packets_lost: stats.packets_lost(),
611 ..Default::default()
612 }
613}
614
615// The point of the sever: the handle's auto-traits no longer depend on which
616// transport the runtime drives, so every consumer (moq-ffi needs Send + Sync)
617// works over every transport, pinned `!Send` ones included.
618const _: () = {
619 const fn assert_send_sync<T: Send + Sync>() {}
620 assert_send_sync::<Session>();
621};