Skip to main content

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};