Skip to main content

moq_rtc/
session.rs

1//! str0m session driver shared by every HTTP role / media direction.
2//!
3//! str0m is sans-IO, so we drive the [`str0m::Rtc`] instance from a tokio
4//! task that owns a UDP socket. [`Session::run`] alternates between
5//! [`Rtc::poll_output`] (drain pending transmits / events) and
6//! [`Rtc::handle_input`] (feed UDP packets or timeouts).
7//!
8//! The session itself doesn't care whether the [`Rtc`] was populated by
9//! accepting an SDP offer (server side) or by minting one and posting it
10//! to a remote URL (client side), or whether the media flow is RTP-in
11//! ([`MediaSink`]) or RTP-out ([`crate::egress::EgressSource`]).
12
13use std::collections::HashMap;
14use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
15use std::sync::Arc;
16use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
17
18use str0m::{Candidate, Event, IceConnectionState, Input, Output, Rtc, net::Receive};
19use tokio::net::UdpSocket;
20use tokio::sync::mpsc;
21
22use crate::egress::{EgressClock, EgressSource, WriteRequest};
23use crate::{Error, Result, codec};
24
25/// One inbound UDP datagram plus its source address, the unit fed to a session.
26/// The [`server`](crate::server) paths get these from the shared-socket demux
27/// (`crate::server::mux`); the client paths get them from a 1:1 reader
28/// ([`spawn_socket_reader`]).
29pub(crate) type Packet = (Vec<u8>, SocketAddr);
30
31/// Bound on a session's inbound datagram queue, sized like a socket buffer:
32/// past this, datagrams are dropped rather than buffered (WebRTC tolerates loss
33/// and a stalled session must not grow memory without limit).
34pub(crate) const SESSION_INBOX: usize = 256;
35
36/// str0m's outbound video buffer depth (packets), which also backs NACK resends.
37/// Raised above the str0m default (1000) so a late-joining peer can recover a
38/// large keyframe and the rest of the current group via NACK; see
39/// [`rtc_config_with_codecs`].
40const EGRESS_SEND_BUFFER_VIDEO: usize = 3000;
41
42/// Backstop deadline for a session to reach a connected ICE state, covering the one
43/// case str0m's ICE agent deliberately never times out: a peer that answers the SDP
44/// but provides NO remote candidates and sends nothing (an abandoned WHIP/WHEP
45/// offer, or a probe that only exercises signalling). str0m DOES end a connection
46/// whose candidate pairs were tried and exhausted -- the agent goes to
47/// `IceConnectionState::Disconnected` (handled in `handle_event`) after
48/// ~`StunTiming::timeout()` (~21s at the defaults). But when `remote_candidates`
49/// stays empty the agent treats the session as "still possible" forever (trickle
50/// ICE: more candidates could arrive), so it sits in `Checking` indefinitely,
51/// pinning this task, its broadcast announcement, and its mux registration. Nothing
52/// upstream ends it, so we do. Set ABOVE str0m's ~21s pair-exhaustion so a
53/// connection that actually started checks is ended by str0m's native path (and a
54/// slow-but-real TURN/lossy peer isn't clipped); this only fires for the
55/// never-any-candidate case.
56const ICE_ESTABLISH_TIMEOUT: Duration = Duration::from_secs(30);
57
58/// Receives `MediaData` events from str0m and dispatches to the right codec
59/// [`Bridge`](codec::Bridge). Used as the per-session sink in [`Session::run`]
60/// for any flow where RTP arrives from the peer (`server publish` / WHIP
61/// server, `client subscribe` / WHEP client).
62pub trait MediaSink: Send {
63	/// Called once str0m has confirmed which codec is on which `mid`.
64	fn on_track(
65		&mut self,
66		mid: str0m::media::Mid,
67		kind: str0m::media::MediaKind,
68		codec: str0m::format::Codec,
69		audio_params: Option<(u32, u32)>,
70	) -> Result<()>;
71
72	/// Called on each [`MediaData`](str0m::media::MediaData) event. The session
73	/// loop has already converted the timestamp to microseconds.
74	fn on_frame(&mut self, mid: str0m::media::Mid, frame: codec::Frame) -> Result<()>;
75
76	/// Called once when the session ends with a genuine failure, so the sink can
77	/// abort its tracks with the real cause instead of a bare `Error::Dropped`.
78	fn abort(&mut self, err: moq_net::Error);
79}
80
81/// What the session does with the negotiated media stream.
82#[non_exhaustive]
83pub enum MediaRole {
84	/// RTP-in: dispatch peer frames into a [`MediaSink`].
85	Ingest(Box<dyn MediaSink>),
86	/// RTP-out: pull frames from a [`crate::egress::EgressSource`] and forward to the peer.
87	Egress(Box<EgressSource>),
88}
89
90/// Drives a [`Rtc`] instance until it ends.
91///
92/// The caller pre-populates the `Rtc` with whatever SDP exchange they need.
93/// Sends go out the (possibly shared) `socket`; inbound datagrams arrive on
94/// `inbound` rather than being read off the socket directly, so several
95/// sessions can share one socket behind the `crate::server::mux`.
96pub struct Session {
97	rtc: Rtc,
98	/// Send side. Shared across sessions on the server (the mux socket); owned
99	/// 1:1 on the client. Receiving happens via `inbound`, not this socket.
100	socket: Arc<UdpSocket>,
101	/// The local ICE candidates we advertised. Each inbound datagram is tagged
102	/// (for str0m) with the candidate whose address family matches the packet's
103	/// source, so a dual-stack peer reaching us over IPv6 isn't told the packet
104	/// arrived on an IPv4 host candidate. MUST be the advertised candidates, not
105	/// the socket's bind address: str0m drops a STUN binding request whose
106	/// destination doesn't match a host candidate ("unknown interface"), and the
107	/// shared mux socket binds a wildcard (`0.0.0.0`) while advertising concrete
108	/// IPs. Never empty (falls back to the bound address).
109	locals: Vec<SocketAddr>,
110	/// Inbound datagrams routed to this session (demux on the server, a 1:1
111	/// reader on the client). `None` from `recv` means every sender dropped, so
112	/// the session is done.
113	inbound: mpsc::Receiver<Packet>,
114	role: MediaRole,
115	/// Egress write requests. `Some` only for [`MediaRole::Egress`]
116	/// sessions; pumps send frames here, the main loop forwards them into
117	/// str0m's [`Writer`](str0m::media::Writer).
118	writes_rx: Option<mpsc::Receiver<WriteRequest>>,
119	/// Rebases each ingested track's raw RTP timestamps onto one session
120	/// timeline so audio and video stay in sync. Unused by egress sessions.
121	ingest_clock: IngestClock,
122	/// Maps the shared MoQ presentation timeline to str0m's sender-report
123	/// wallclock. Unused by ingest sessions.
124	egress_clock: EgressClock,
125}
126
127impl Session {
128	/// Convenience for the ingest case (WHIP server, WHEP client). `locals` are the
129	/// advertised ICE candidates (see the field docs), not the socket bind.
130	pub fn ingest(
131		rtc: Rtc,
132		socket: Arc<UdpSocket>,
133		locals: Vec<SocketAddr>,
134		inbound: mpsc::Receiver<Packet>,
135		sink: Box<dyn MediaSink>,
136	) -> Self {
137		Self {
138			rtc,
139			socket,
140			locals,
141			inbound,
142			role: MediaRole::Ingest(sink),
143			writes_rx: None,
144			ingest_clock: IngestClock::default(),
145			egress_clock: EgressClock::default(),
146		}
147	}
148
149	/// Convenience for the egress case (WHEP server, WHIP client). `locals` are the
150	/// advertised ICE candidates (see the field docs), not the socket bind.
151	pub fn egress(
152		rtc: Rtc,
153		socket: Arc<UdpSocket>,
154		locals: Vec<SocketAddr>,
155		inbound: mpsc::Receiver<Packet>,
156		mut source: EgressSource,
157	) -> Self {
158		let writes_rx = source.take_writes();
159		Self {
160			rtc,
161			socket,
162			locals,
163			inbound,
164			role: MediaRole::Egress(Box::new(source)),
165			writes_rx: Some(writes_rx),
166			ingest_clock: IngestClock::default(),
167			egress_clock: EgressClock::default(),
168		}
169	}
170
171	pub async fn run(mut self) -> Result<()> {
172		let result = self.run_loop().await;
173		// A genuine failure (not a normal peer disconnect or an unconnected offer)
174		// propagates to subscribers as the real cause; the normal ends just let the
175		// sink's tracks close as Dropped.
176		if let Err(err) = &result
177			&& !matches!(err, Error::SessionClosed | Error::IceTimeout)
178			&& let MediaRole::Ingest(sink) = &mut self.role
179		{
180			sink.abort(moq_net::Error::Transport(err.to_string()));
181		}
182		result
183	}
184
185	async fn run_loop(&mut self) -> Result<()> {
186		let started = Instant::now();
187		let mut connected = false;
188		// str0m hands back the canonical destination we fed it, so a dual-stack
189		// socket needs IPv4 re-mapped before each send (see crate::net).
190		let socket_v6 = self.socket.local_addr().map_err(Error::Io)?.is_ipv6();
191		loop {
192			// A dead Rtc (DTLS/SDP failure, explicit disconnect) makes poll_output
193			// return a never-firing timeout instead of erroring, which would hang
194			// this task forever holding the broadcast announcement + mux
195			// registration. Bail so those release.
196			if !self.rtc.is_alive() {
197				return Err(Error::SessionClosed);
198			}
199
200			// Abort a session that never finishes connecting (see
201			// ICE_ESTABLISH_TIMEOUT); once connected, str0m's own timeouts take over.
202			if !connected && started.elapsed() >= ICE_ESTABLISH_TIMEOUT {
203				return Err(Error::IceTimeout);
204			}
205
206			let timeout = match self.rtc.poll_output().map_err(Error::Rtc)? {
207				Output::Timeout(t) => t,
208				Output::Transmit(t) => {
209					let dst = crate::net::to_family(t.destination, socket_v6);
210					if let Err(err) = self.socket.send_to(&t.contents, dst).await {
211						tracing::warn!(%err, %dst, "send failed");
212					}
213					continue;
214				}
215				Output::Event(event) => {
216					if let Event::IceConnectionStateChange(state) = &event {
217						connected |= state.is_connected();
218					}
219					self.handle_event(event)?;
220					continue;
221				}
222			};
223
224			let now = Instant::now();
225			let mut duration = timeout.saturating_duration_since(now);
226			// While still connecting, never sleep past the establishment deadline, so
227			// the check above fires on time even if str0m scheduled a far-off timeout.
228			if !connected {
229				duration = duration.min(ICE_ESTABLISH_TIMEOUT.saturating_sub(started.elapsed()));
230			}
231			if duration.is_zero() {
232				self.rtc.handle_input(Input::Timeout(now)).map_err(Error::Rtc)?;
233				continue;
234			}
235
236			// Wait for the earliest of: an inbound UDP packet, an egress
237			// write request (if egress), or the str0m-requested timeout.
238			tokio::select! {
239				biased;
240
241				// Egress writes get drained promptly. Without `biased` an
242				// idle socket select could starve them.
243				Some(req) = async {
244					match self.writes_rx.as_mut() {
245						Some(rx) => rx.recv().await,
246						None => std::future::pending::<Option<WriteRequest>>().await,
247					}
248				} => {
249					let now = Instant::now();
250					let wallclock = self.egress_clock.wallclock(req.time, now);
251					crate::egress::dispatch(&mut self.rtc, req, wallclock);
252				}
253
254				packet = self.inbound.recv() => {
255					match packet {
256						Some((data, src)) => {
257							let now = Instant::now();
258							// Tag the packet with the advertised candidate matching its
259							// address family, not the socket bind (see the `locals` docs).
260							let local = pick_local(&self.locals, src);
261							let recv = Receive::new(str0m::net::Protocol::Udp, src, local, &data)
262								.map_err(Error::RtcInput)?;
263							self.rtc.handle_input(Input::Receive(now, recv)).map_err(Error::Rtc)?;
264						}
265						// Every sender dropped: the demux unregistered us (or the
266						// 1:1 reader stopped). Nothing more will arrive, so end.
267						None => return Err(Error::SessionClosed),
268					}
269				}
270
271				_ = tokio::time::sleep(duration) => {
272					self.rtc
273						.handle_input(Input::Timeout(Instant::now()))
274						.map_err(Error::Rtc)?;
275				}
276			}
277		}
278	}
279
280	fn handle_event(&mut self, event: Event) -> Result<()> {
281		match event {
282			Event::IceConnectionStateChange(state) => {
283				tracing::debug!(?state, "ice state");
284				if state == IceConnectionState::Disconnected {
285					return Err(Error::SessionClosed);
286				}
287			}
288			Event::MediaAdded(added) => self.handle_media_added(added)?,
289			Event::MediaData(data) => {
290				// `ingest_clock` and `role` are disjoint fields, so the borrow checker lets
291				// us rebase the (random, per-track) RTP base and feed the sink in one
292				// block; egress sessions never get here so the clock stays untouched.
293				if let MediaRole::Ingest(sink) = &mut self.role {
294					let media_us = media_time_to_micros(&data.time);
295					let timestamp_us = self.ingest_clock.normalize(data.mid, data.network_time, media_us);
296					sink.on_frame(
297						data.mid,
298						codec::Frame {
299							timestamp_us,
300							payload: bytes::Bytes::from_owner(data.data),
301						},
302					)?;
303				}
304			}
305			Event::SenderFeedback(feedback) => {
306				if matches!(&self.role, MediaRole::Ingest(_)) {
307					self.ingest_clock.observe(feedback.mid, feedback.sender_info);
308				}
309			}
310			Event::KeyframeRequest(req) => {
311				// PLI / FIR from the egress peer. For v1 we just log and
312				// rely on the next natural keyframe from the MoQ source.
313				tracing::debug!(?req, "keyframe request from peer");
314			}
315			_ => {}
316		}
317		Ok(())
318	}
319
320	fn handle_media_added(&mut self, added: str0m::media::MediaAdded) -> Result<()> {
321		// str0m's CodecConfig is the negotiated set; pick the first
322		// codec advertised for this `mid`.
323		let pt = self.rtc.media(added.mid).and_then(|m| m.remote_pts().first().copied());
324		let params = pt.and_then(|pt| self.rtc.codec_config().params().iter().find(|p| p.pt() == pt).copied());
325		let params = match params {
326			Some(p) => p,
327			None => {
328				tracing::warn!(?added.mid, "no codec params for media; ignoring");
329				return Ok(());
330			}
331		};
332		let spec = params.spec();
333		let codec = spec.codec;
334
335		match &mut self.role {
336			MediaRole::Ingest(sink) => {
337				let audio_params = if codec.is_audio() {
338					Some((spec.clock_rate.get(), spec.channels.unwrap_or(1) as u32))
339				} else {
340					None
341				};
342				sink.on_track(added.mid, added.kind, codec, audio_params)?;
343			}
344			MediaRole::Egress(source) => {
345				source.on_track(added.mid, codec, params.pt(), spec.clock_rate)?;
346			}
347		}
348		Ok(())
349	}
350}
351
352/// Per-session clock that rebases each ingested track's raw RTP timestamps onto
353/// one timeline so audio and video stay in sync.
354///
355/// str0m hands us the RTP header timestamp verbatim
356/// ([`MediaData::time`](str0m::media::MediaData::time)). Per RFC 3550 that base
357/// is random and independent for each track, and str0m applies no RTCP
358/// sender-report correlation, so publishing the values as-is would desync audio
359/// from video (their bases differ by hours) and start the broadcast at an
360/// arbitrary offset. Until every track has an RTCP sender report, we anchor each
361/// track on its first frame's arrival. Once reports are available, their common
362/// NTP clock replaces arrival time as the cross-track reference. The NTP epoch
363/// is chosen so the transition can only move timestamps forward, never rewind a
364/// track that has already been published.
365#[derive(Default)]
366pub(crate) struct IngestClock {
367	/// Arrival time of the first frame seen in the session; the timeline origin.
368	arrival_epoch: Option<Instant>,
369	/// Remote NTP time corresponding to timestamp zero on the published timeline.
370	ntp_epoch_us: Option<i128>,
371	tracks: HashMap<str0m::media::Mid, IngestTrackClock>,
372}
373
374impl IngestClock {
375	/// Record the newest RTP-to-NTP correlation for a track.
376	fn observe(&mut self, mid: str0m::media::Mid, sender: str0m::rtp::rtcp::SenderInfo) {
377		self.tracks.entry(mid).or_default().sender = Some(SenderAnchor::new(sender));
378		self.establish_ntp_epoch();
379	}
380
381	/// Map a raw RTP-derived microsecond timestamp onto the session timeline.
382	/// `arrival` is the packet's network time
383	/// ([`MediaData::network_time`](str0m::media::MediaData::network_time)).
384	fn normalize(&mut self, mid: str0m::media::Mid, arrival: Instant, media_us: u64) -> u64 {
385		let epoch = *self.arrival_epoch.get_or_insert(arrival);
386		let track = self.tracks.entry(mid).or_default();
387		let offset = *track.arrival_offset_us.get_or_insert_with(|| {
388			// Signed wall delta from the epoch: a track whose first frame we dequeue
389			// after the epoch frame may have actually arrived *before* it, and that
390			// lead must pull its timeline earlier (not clamp to the epoch via an
391			// unsigned subtraction) so it stays in sync.
392			let wall_us = if arrival >= epoch {
393				arrival.duration_since(epoch).as_micros() as i64
394			} else {
395				-(epoch.duration_since(arrival).as_micros() as i64)
396			};
397			wall_us as i128 - media_us as i128
398		});
399		let fallback = to_u64(media_us as i128 + offset);
400		let previous = track.last_output_us;
401		track.last_media_us = Some(media_us);
402		track.last_output_us = Some(fallback);
403
404		self.establish_ntp_epoch();
405		let mapped = self
406			.ntp_epoch_us
407			.zip(self.tracks.get(&mid).and_then(|track| track.sender))
408			.map(|(epoch, sender)| to_u64(sender.capture_time_us(media_us) - epoch));
409		let output = match mapped {
410			// The epoch chosen during the transition makes `mapped >= fallback`.
411			// Later sender reports can make tiny clock corrections, so retain strict
412			// monotonicity if one would otherwise move this track backwards.
413			Some(mapped) => mapped.max(previous.map_or(fallback, |last| last.saturating_add(1))),
414			None => fallback,
415		};
416		self.tracks.get_mut(&mid).expect("track was inserted").last_output_us = Some(output);
417		output
418	}
419
420	/// Switch to the sender's common clock once every negotiated track has both
421	/// a media sample and an RTCP sender report.
422	fn establish_ntp_epoch(&mut self) {
423		// A single RTP clock needs rebasing but no cross-track synchronization.
424		// Waiting for two observed tracks also avoids a dormant negotiated m-line
425		// preventing active audio and video from ever switching to sender reports.
426		if self.ntp_epoch_us.is_some() || self.tracks.len() < 2 {
427			return;
428		}
429
430		let mut epoch = i128::MAX;
431		for track in self.tracks.values() {
432			let (Some(sender), Some(media_us), Some(output_us)) =
433				(track.sender, track.last_media_us, track.last_output_us)
434			else {
435				return;
436			};
437			// Choosing the minimum candidate makes every track's NTP-derived
438			// timestamp at least its last published timestamp. The common timeline
439			// may jump forward, but no individual track can rewind.
440			epoch = epoch.min(sender.capture_time_us(media_us) - output_us as i128);
441		}
442		self.ntp_epoch_us = Some(epoch);
443	}
444}
445
446#[derive(Default)]
447struct IngestTrackClock {
448	arrival_offset_us: Option<i128>,
449	sender: Option<SenderAnchor>,
450	last_media_us: Option<u64>,
451	last_output_us: Option<u64>,
452}
453
454#[derive(Clone, Copy)]
455struct SenderAnchor {
456	ntp_us: i128,
457	rtp_us: i128,
458}
459
460impl SenderAnchor {
461	fn new(sender: str0m::rtp::rtcp::SenderInfo) -> Self {
462		Self {
463			ntp_us: system_time_to_micros(sender.ntp_time),
464			rtp_us: media_time_to_micros(&sender.rtp_time) as i128,
465		}
466	}
467
468	fn capture_time_us(self, media_us: u64) -> i128 {
469		self.ntp_us + media_us as i128 - self.rtp_us
470	}
471}
472
473fn system_time_to_micros(time: SystemTime) -> i128 {
474	match time.duration_since(UNIX_EPOCH) {
475		Ok(duration) => duration.as_micros() as i128,
476		Err(err) => -(err.duration().as_micros() as i128),
477	}
478}
479
480fn to_u64(value: i128) -> u64 {
481	value.clamp(0, u64::MAX as i128) as u64
482}
483
484/// Log a finished session at the right level: an ordinary peer disconnect
485/// ([`Error::SessionClosed`]) is debug, a genuine failure is a warning. Keeps
486/// normal WebRTC churn out of the warning stream. `role` labels the path
487/// (e.g. `"whip server"`).
488pub(crate) fn log_session_end(role: &str, result: &Result<()>) {
489	match result {
490		Ok(()) | Err(Error::SessionClosed) => tracing::debug!(role, "session ended"),
491		// An abandoned offer (peer answered but never connected) is normal churn, not
492		// a failure: keep it out of the warning stream.
493		Err(Error::IceTimeout) => tracing::debug!(role, "session ended: ICE never connected"),
494		Err(err) => tracing::warn!(%err, role, "session ended"),
495	}
496}
497
498/// Pick the advertised local candidate to tag an inbound packet with: the first
499/// one whose address family matches `src`, falling back to the first candidate
500/// (the list is never empty). Keeps a dual-stack peer's packets tagged with a
501/// same-family host candidate so str0m's ICE pairing stays consistent.
502fn pick_local(locals: &[SocketAddr], src: SocketAddr) -> SocketAddr {
503	locals
504		.iter()
505		.find(|l| l.is_ipv4() == src.is_ipv4())
506		.copied()
507		.unwrap_or(locals[0])
508}
509
510/// Convert a str0m [`MediaTime`](str0m::media::MediaTime) to microseconds.
511fn media_time_to_micros(time: &str0m::media::MediaTime) -> u64 {
512	// MediaTime stores `numer / denom` seconds; cast through i128 so the
513	// product doesn't overflow at 90 kHz video timestamps.
514	let numer = time.numer() as i128;
515	let denom = time.denom() as i128;
516	if denom == 0 {
517		return 0;
518	}
519	let micros = (numer.saturating_mul(1_000_000)) / denom;
520	micros.max(0) as u64
521}
522
523/// Type-erased map of `Mid` -> codec bridge, populated as `MediaAdded`
524/// events arrive on the ingest side.
525pub(crate) struct Bridges {
526	inner: HashMap<str0m::media::Mid, Box<dyn codec::Bridge>>,
527}
528
529impl Bridges {
530	pub fn new() -> Self {
531		Self { inner: HashMap::new() }
532	}
533
534	pub fn insert(&mut self, mid: str0m::media::Mid, bridge: Box<dyn codec::Bridge>) {
535		self.inner.insert(mid, bridge);
536	}
537
538	pub fn push(&mut self, mid: str0m::media::Mid, frame: codec::Frame) -> Result<()> {
539		if let Some(bridge) = self.inner.get_mut(&mid) {
540			bridge.push(frame)?;
541		}
542		Ok(())
543	}
544
545	/// Abort every bridge's track with `err` so subscribers see the real cause
546	/// rather than a bare `Error::Dropped`.
547	///
548	/// Aborting consumes each bridge, so the map is emptied: the session is over.
549	pub fn abort(&mut self, err: moq_net::Error) {
550		for bridge in std::mem::take(&mut self.inner).into_values() {
551			bridge.abort(err.clone());
552		}
553	}
554}
555
556/// Build a [`Rtc`] with `CodecConfig` restricted to the supplied codecs.
557///
558/// Used by the two egress paths so we don't advertise codecs we have no
559/// source for in the catalog (WHIP client) or accept incoming codecs we
560/// can't fulfil (WHEP server). For both, the negotiated SDP intersects with
561/// what we can actually deliver, so `MediaAdded` only fires for codecs that
562/// [`crate::egress::EgressSource`] can match to a rendition.
563pub fn rtc_config_with_codecs(codecs: &[str0m::format::Codec]) -> str0m::RtcConfig {
564	use str0m::format::Codec;
565	// str0m fulfils NACK resends from the video send buffer (default 1000
566	// packets). MoQ has no PLI path back to the publisher, so a late joiner's
567	// recovery is whatever the peer can NACK out of this buffer while the current
568	// group is still in flight. Widen it so a large keyframe plus the rest of the
569	// group stays recoverable instead of aging out after ~1000 packets.
570	let mut config = str0m::RtcConfig::new()
571		.clear_codecs()
572		.set_send_buffer_video(EGRESS_SEND_BUFFER_VIDEO);
573	for c in codecs {
574		config = match c {
575			Codec::Opus => config.enable_opus(true),
576			Codec::H264 => config.enable_h264(true),
577			Codec::H265 => config.enable_h265(true),
578			Codec::Vp8 => config.enable_vp8(true),
579			Codec::Vp9 => config.enable_vp9(true),
580			Codec::Av1 => config.enable_av1(true),
581			// Any other codec str0m grows is one we have no egress source for.
582			_ => config,
583		};
584	}
585	config
586}
587
588/// Build a codec-restricted [`Rtc`] for the client egress path (which lets
589/// str0m mint its own ICE credentials). The server egress path uses
590/// [`rtc_config_with_codecs`] directly so it can inject the mux's known
591/// credentials before building.
592pub fn rtc_with_codecs(codecs: &[str0m::format::Codec]) -> Rtc {
593	rtc_config_with_codecs(codecs).build(std::time::Instant::now())
594}
595
596/// Bind an ephemeral UDP socket for a single client session and return it
597/// (shared with its [reader task](spawn_socket_reader)) plus the ICE candidates
598/// to advertise.
599///
600/// The client paths are 1:1 (one socket per dialed session, no demux); the
601/// server paths share one socket via `crate::server::mux` instead. `advertise`
602/// IPs are used verbatim (reusing the bound port); empty falls back to the
603/// bound address, substituting loopback when that address is unspecified.
604pub async fn bind_udp(advertise: &[SocketAddr]) -> Result<(Arc<UdpSocket>, Vec<SocketAddr>)> {
605	let socket = UdpSocket::bind(("0.0.0.0", 0)).await?;
606	let local = socket.local_addr()?;
607	let candidates = advertised_candidates(advertise, local)?;
608	Ok((Arc::new(socket), candidates))
609}
610
611/// Pair configured ICE candidates with the bound UDP port and validate them.
612pub(crate) fn advertised_candidates(advertise: &[SocketAddr], local: SocketAddr) -> Result<Vec<SocketAddr>> {
613	let port = local.port();
614	let candidates = if advertise.is_empty() {
615		let ip = match local.ip() {
616			IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
617			IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
618			ip => ip,
619		};
620
621		let candidate = SocketAddr::new(ip, port);
622		if candidate != local {
623			tracing::info!(bound = %local, advertised = %candidate, "webrtc udp bind is unspecified, advertising loopback ICE candidate");
624		}
625		vec![candidate]
626	} else {
627		// Reuse the bound port across each advertised IP, since str0m's ICE agent
628		// picks the destination port from the candidate it's pairing against.
629		advertise.iter().map(|addr| SocketAddr::new(addr.ip(), port)).collect()
630	};
631
632	for addr in &candidates {
633		Candidate::host(*addr, "udp").map_err(str0m::RtcError::from)?;
634	}
635	Ok(candidates)
636}
637
638/// Spawn a 1:1 reader pumping every datagram from `socket` into a channel, for
639/// the client paths (one socket per session, so no demux is needed). Mirrors the
640/// inbound side of `crate::server::mux` for a single session.
641pub fn spawn_socket_reader(socket: Arc<UdpSocket>) -> mpsc::Receiver<Packet> {
642	let (tx, rx) = mpsc::channel(SESSION_INBOX);
643	tokio::spawn(async move {
644		let mut buf = vec![0u8; 65_535];
645		loop {
646			match socket.recv_from(&mut buf).await {
647				// Bounded like a socket buffer: drop on full, stop once the
648				// session's receiver is gone.
649				Ok((len, src)) => {
650					let src = crate::net::canonical(src);
651					if let Err(mpsc::error::TrySendError::Closed(_)) = tx.try_send((buf[..len].to_vec(), src)) {
652						break;
653					}
654				}
655				Err(err) => {
656					tracing::warn!(%err, "webrtc client socket recv failed");
657					break;
658				}
659			}
660		}
661	});
662	rx
663}
664
665#[cfg(test)]
666mod tests {
667	use std::time::{Duration, UNIX_EPOCH};
668
669	use str0m::media::Mid;
670	use str0m::rtp::Ssrc;
671	use str0m::rtp::rtcp::SenderInfo;
672
673	use super::*;
674
675	#[test]
676	fn advertised_candidates_use_loopback_for_unspecified_ipv4() {
677		let local: SocketAddr = "0.0.0.0:4444".parse().unwrap();
678		let candidates = advertised_candidates(&[], local).unwrap();
679		assert_eq!(candidates, vec!["127.0.0.1:4444".parse().unwrap()]);
680	}
681
682	#[test]
683	fn advertised_candidates_use_loopback_for_unspecified_ipv6() {
684		let local: SocketAddr = "[::]:4444".parse().unwrap();
685		let candidates = advertised_candidates(&[], local).unwrap();
686		assert_eq!(candidates, vec!["[::1]:4444".parse().unwrap()]);
687	}
688
689	#[test]
690	fn advertised_candidates_keep_bound_address_when_specific() {
691		let local: SocketAddr = "127.0.0.1:4444".parse().unwrap();
692		assert_eq!(advertised_candidates(&[], local).unwrap(), vec![local]);
693	}
694
695	#[test]
696	fn advertised_candidates_reuse_bound_port_for_configured_addresses() {
697		let local: SocketAddr = "0.0.0.0:4444".parse().unwrap();
698		let advertised = vec!["127.0.0.1:1000".parse().unwrap(), "[::1]:2000".parse().unwrap()];
699
700		assert_eq!(
701			advertised_candidates(&advertised, local).unwrap(),
702			vec!["127.0.0.1:4444".parse().unwrap(), "[::1]:4444".parse().unwrap()]
703		);
704	}
705
706	#[test]
707	fn advertised_candidates_reject_configured_unspecified_addresses() {
708		let local: SocketAddr = "127.0.0.1:4444".parse().unwrap();
709		let advertised = vec!["0.0.0.0:1000".parse().unwrap()];
710		assert!(advertised_candidates(&advertised, local).is_err());
711	}
712
713	#[test]
714	fn pick_local_matches_address_family() {
715		let v4: SocketAddr = "1.2.3.4:5000".parse().unwrap();
716		let v6: SocketAddr = "[2001:db8::1]:5000".parse().unwrap();
717		let locals = vec![v4, v6];
718		let src_v4: SocketAddr = "9.9.9.9:1".parse().unwrap();
719		let src_v6: SocketAddr = "[2001:db8::2]:1".parse().unwrap();
720		assert_eq!(pick_local(&locals, src_v4), v4);
721		assert_eq!(pick_local(&locals, src_v6), v6);
722		// No same-family candidate falls back to the first.
723		assert_eq!(pick_local(&[v4], src_v6), v4);
724	}
725
726	#[test]
727	fn ingest_clock_rebases_first_frame_to_zero() {
728		let mut clock = IngestClock::default();
729		let mid = Mid::from("0");
730		let t0 = Instant::now();
731		// Raw RTP base is a large random value; the first frame must map to 0.
732		assert_eq!(clock.normalize(mid, t0, 5_000_000_000), 0);
733	}
734
735	#[test]
736	fn ingest_clock_tracks_rtp_delta_within_track() {
737		let mut clock = IngestClock::default();
738		let mid = Mid::from("0");
739		let t0 = Instant::now();
740		assert_eq!(clock.normalize(mid, t0, 5_000_000_000), 0);
741		// A later frame advances by the RTP delta, not by arrival jitter.
742		let arrival = t0 + Duration::from_millis(17); // jittered arrival, ignored after anchor
743		assert_eq!(clock.normalize(mid, arrival, 5_000_020_000), 20_000);
744	}
745
746	#[test]
747	fn ingest_clock_keeps_tracks_in_sync_via_arrival() {
748		let mut clock = IngestClock::default();
749		let audio = Mid::from("0");
750		let video = Mid::from("1");
751		let t0 = Instant::now();
752		// Audio anchors the session at 0 with its own random RTP base.
753		assert_eq!(clock.normalize(audio, t0, 1_000_000_000), 0);
754		// Video's first frame arrives 5 ms later with an unrelated RTP base; it
755		// must land at +5 ms on the shared timeline, not at video's raw base.
756		let video_arrival = t0 + Duration::from_millis(5);
757		assert_eq!(clock.normalize(video, video_arrival, 8_000_000_000), 5_000);
758		// And then track its own RTP delta.
759		assert_eq!(
760			clock.normalize(video, video_arrival + Duration::from_millis(33), 8_000_033_000),
761			38_000
762		);
763	}
764
765	#[test]
766	fn ingest_clock_handles_track_arriving_before_epoch() {
767		let mut clock = IngestClock::default();
768		let audio = Mid::from("0");
769		let video = Mid::from("1");
770		let t0 = Instant::now();
771		// Audio's MediaData is dequeued first and sets the epoch at t0.
772		assert_eq!(clock.normalize(audio, t0, 1_000_000), 0);
773		// Video's first frame actually arrived 5 ms *before* the epoch. Its lead
774		// pulls the start below zero (clamped to 0), and a frame 33 ms into video
775		// lands 28 ms onto the shared timeline (33 ms - the 5 ms head start).
776		let video_arrival = t0 - Duration::from_millis(5);
777		assert_eq!(clock.normalize(video, video_arrival, 8_000_000), 0);
778		assert_eq!(
779			clock.normalize(video, video_arrival + Duration::from_millis(33), 8_033_000),
780			28_000
781		);
782	}
783
784	#[test]
785	fn ingest_clock_replaces_arrival_jitter_with_sender_report_sync() {
786		let mut clock = IngestClock::default();
787		let audio = Mid::from("0");
788		let video = Mid::from("1");
789		let t0 = Instant::now();
790		let audio_base = 1_000_000_000;
791		let video_base = 8_000_000_000;
792
793		assert_eq!(clock.normalize(audio, t0, audio_base), 0);
794		assert_eq!(
795			clock.normalize(video, t0 + Duration::from_millis(50), video_base),
796			50_000
797		);
798		assert_eq!(
799			clock.normalize(audio, t0 + Duration::from_secs(1), audio_base + 1_000_000),
800			1_000_000
801		);
802		assert_eq!(
803			clock.normalize(video, t0 + Duration::from_millis(1_050), video_base + 1_000_000,),
804			1_050_000
805		);
806
807		// Both reports identify the same capture instant despite unrelated RTP
808		// bases. The 50 ms first-packet arrival skew must disappear permanently.
809		let report_time = UNIX_EPOCH + Duration::from_secs(1_700_000_001);
810		clock.observe(audio, sender_info(1, report_time, audio_base + 1_000_000));
811		clock.observe(video, sender_info(2, report_time, video_base + 1_000_000));
812
813		let audio_time = clock.normalize(audio, t0 + Duration::from_millis(1_020), audio_base + 1_020_000);
814		let video_time = clock.normalize(video, t0 + Duration::from_millis(1_070), video_base + 1_020_000);
815		assert_eq!(audio_time, video_time);
816		assert_eq!(audio_time, 1_070_000);
817	}
818
819	fn sender_info(ssrc: u32, ntp_time: SystemTime, rtp_us: u64) -> SenderInfo {
820		SenderInfo {
821			ssrc: Ssrc::from(ssrc),
822			ntp_time,
823			rtp_time: str0m::media::MediaTime::from_micros(rtp_us),
824			sender_packet_count: 0,
825			sender_octet_count: 0,
826		}
827	}
828}