Skip to main content

moq_native/
reconnect.rs

1use std::task::{Poll, ready};
2use std::time::Duration;
3
4use moq_net::Version;
5use moq_net::bandwidth::{Consumer as BandwidthConsumer, Producer as BandwidthProducer};
6use moq_net::kio;
7use url::Url;
8
9use crate::{Client, Error};
10
11/// Exponential backoff configuration for reconnection attempts.
12#[derive(Clone, Debug, clap::Args, serde::Serialize, serde::Deserialize)]
13#[serde(default, deny_unknown_fields)]
14#[non_exhaustive]
15pub struct Backoff {
16	/// Initial delay before first reconnect attempt.
17	#[arg(
18		id = "backoff-initial",
19		long,
20		default_value = "1s",
21		env = "MOQ_BACKOFF_INITIAL",
22		value_parser = humantime::parse_duration,
23	)]
24	#[serde(with = "humantime_serde")]
25	pub initial: Duration,
26
27	/// Multiplier applied to delay after each failure.
28	#[arg(id = "backoff-multiplier", long, default_value_t = 2, env = "MOQ_BACKOFF_MULTIPLIER")]
29	pub multiplier: u32,
30
31	/// Maximum delay between reconnect attempts.
32	#[arg(
33		id = "backoff-max",
34		long,
35		default_value = "30s",
36		env = "MOQ_BACKOFF_MAX",
37		value_parser = humantime::parse_duration,
38	)]
39	#[serde(with = "humantime_serde")]
40	pub max: Duration,
41
42	/// Maximum time to spend retrying before giving up.
43	/// Resets after a stable connection (one that outlives the initial backoff), so a flapping
44	/// session that reconnects then immediately drops still counts toward the timeout. Set to 0 for
45	/// unlimited retries.
46	#[arg(
47		id = "backoff-timeout",
48		long,
49		default_value = "5m",
50		env = "MOQ_BACKOFF_TIMEOUT",
51		value_parser = humantime::parse_duration,
52	)]
53	#[serde(with = "humantime_serde")]
54	pub timeout: Duration,
55}
56
57impl Default for Backoff {
58	fn default() -> Self {
59		Self {
60			initial: Duration::from_secs(1),
61			multiplier: 2,
62			max: Duration::from_secs(30),
63			timeout: Duration::from_secs(300),
64		}
65	}
66}
67
68impl Backoff {
69	/// How long broadcasts fed by a reconnecting session should outlive a session
70	/// drop (see [`moq_net::origin::Info::linger`]): slightly past the give-up
71	/// [`timeout`](Self::timeout), so when the loop does give up its error surfaces
72	/// before the broadcasts tear down. A zero timeout retries forever, so the
73	/// broadcasts linger forever too.
74	pub fn linger(&self) -> Duration {
75		match self.timeout.is_zero() {
76			true => Duration::MAX,
77			false => self.timeout.saturating_add(Duration::from_secs(1)),
78		}
79	}
80}
81
82/// A connection lifecycle transition reported by [`Reconnect::status`].
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84#[non_exhaustive]
85pub enum Status {
86	/// A session connected (the first connect, or a reconnect after a drop).
87	Connected,
88	/// An established session dropped; a reconnect attempt follows.
89	Disconnected,
90}
91
92/// Shared reconnect state, observed by consumers through a [`kio`] channel.
93///
94/// The channel closing (all producers dropped) is the terminal signal; `error`
95/// distinguishes a permanent give-up from a graceful close.
96#[derive(Default)]
97struct State {
98	/// Current connection status, or `None` before the first connect.
99	status: Option<Status>,
100	/// The negotiated MoQ version of the live session, or `None` when disconnected.
101	version: Option<Version>,
102	/// Set when the reconnect loop permanently gives up (reconnect timeout exceeded).
103	error: Option<Error>,
104	/// The currently-connected session, or `None` while reconnecting. Read by
105	/// [`ConnectionStatsReader`] to snapshot live connection stats.
106	session: Option<moq_net::Session>,
107}
108
109/// A cloneable read handle for the live connection stats of a [`Reconnect`] loop.
110///
111/// Obtained via [`Reconnect::stats`]. [`stats`](Self::stats) returns `None` while the loop is
112/// between connections (reconnecting), and `Some` snapshot while a session is established.
113#[derive(Clone)]
114pub struct ConnectionStatsReader {
115	state: kio::Consumer<State>,
116}
117
118impl ConnectionStatsReader {
119	/// Snapshot the current connection's stats, or `None` if not currently connected.
120	pub fn stats(&self) -> Option<moq_net::ConnectionStats> {
121		self.state.read().session.as_ref().map(moq_net::Session::stats)
122	}
123}
124
125/// Handle to a background reconnect loop.
126///
127/// Spawns a tokio task that connects, waits for session close, then reconnects with exponential
128/// backoff. The read surface mirrors [`moq_net::Session`] so a caller can treat it like a session
129/// that transparently reconnects: [`version`](Self::version), [`send_bandwidth`](Self::send_bandwidth),
130/// and [`recv_bandwidth`](Self::recv_bandwidth) track the live session and reset while disconnected.
131/// The extra toggle a plain session doesn't have is the connection lifecycle: [`connected`](Self::connected)
132/// reads it synchronously and [`status`](Self::status) waits for the next change. [`closed`](Self::closed)
133/// waits for the loop to stop. Dropping the handle aborts the background task.
134pub struct Reconnect {
135	abort: tokio::task::AbortHandle,
136	state: kio::Consumer<State>,
137	/// Persistent send-bitrate estimate, fed by the loop from each live session.
138	send_bandwidth: BandwidthConsumer,
139	/// Persistent recv-bitrate estimate, fed by the loop from each live session.
140	recv_bandwidth: BandwidthConsumer,
141	/// The last status returned by [`status`](Self::status), for change detection.
142	last_reported: Option<Status>,
143}
144
145impl Reconnect {
146	pub(crate) fn new(client: Client, url: Url, backoff: Backoff) -> Self {
147		let producer = kio::Producer::<State>::default();
148		let state = producer.consume();
149
150		// The loop feeds these across every reconnect, so a consumer's handle survives session churn
151		// (unlike a session's own bandwidth consumer, which dies with the session).
152		let send_bw = BandwidthProducer::new();
153		let recv_bw = BandwidthProducer::new();
154		let send_bandwidth = send_bw.consume();
155		let recv_bandwidth = recv_bw.consume();
156
157		let task = tokio::spawn(async move {
158			if let Err(err) = Self::run(&producer, &send_bw, &recv_bw, client, url, backoff).await {
159				tracing::error!(%err, "reconnect loop exited");
160				if let Ok(mut state) = producer.write() {
161					state.error = Some(err);
162				}
163			}
164			// Dropping the producers here closes the channels, signaling consumers.
165		});
166		Self {
167			abort: task.abort_handle(),
168			state,
169			send_bandwidth,
170			recv_bandwidth,
171			last_reported: None,
172		}
173	}
174
175	async fn run(
176		state: &kio::Producer<State>,
177		send_bw: &BandwidthProducer,
178		recv_bw: &BandwidthProducer,
179		client: Client,
180		url: Url,
181		backoff: Backoff,
182	) -> crate::Result<()> {
183		let mut delay = backoff.initial;
184		let mut retry_start = tokio::time::Instant::now();
185		let mut last_error: Option<Error> = None;
186
187		loop {
188			if !backoff.timeout.is_zero() && retry_start.elapsed() > backoff.timeout {
189				let timeout = backoff.timeout;
190				let msg = match last_error {
191					Some(err) => format!("reconnect timed out after {timeout:?}: {err}"),
192					None => format!("reconnect timed out after {timeout:?}"),
193				};
194				return Err(Error::Reconnect(msg));
195			}
196
197			tracing::info!(%url, "connecting");
198
199			match client.connect(url.clone()).await {
200				Ok(session) => {
201					tracing::info!(%url, "connected");
202					if let Ok(mut state) = state.write() {
203						state.status = Some(Status::Connected);
204						state.version = Some(session.version());
205						state.session = Some(session.clone());
206					}
207
208					let connected = tokio::time::Instant::now();
209					// Wait for the session to close, forwarding its bandwidth estimates into the
210					// persistent producers meanwhile so consumers track the live stats across the connection.
211					let closed = run_session(send_bw, recv_bw, &session).await;
212					if let Ok(mut state) = state.write() {
213						state.status = Some(Status::Disconnected);
214						state.version = None;
215						state.session = None;
216					}
217					// The estimates belonged to the now-closed session; reset until the next connect.
218					let _ = send_bw.set(None);
219					let _ = recv_bw.set(None);
220
221					if connected.elapsed() >= backoff.initial {
222						// Stayed up past the initial backoff: a healthy session. Reset the backoff
223						// window so a one-off drop reconnects promptly.
224						tracing::warn!(%url, "session closed, reconnecting");
225						delay = backoff.initial;
226						retry_start = tokio::time::Instant::now();
227						last_error = None;
228					} else {
229						// Connected then dropped almost immediately (e.g. the server accepts then
230						// resets). Treat it as a failed connection: keep the close reason so the
231						// give-up timeout reports a real cause, and fall through to the shared backoff
232						// sleep below so repeated flaps escalate instead of spinning the CPU.
233						if let Err(err) = closed {
234							let err = Error::from(err);
235							tracing::warn!(%url, %err, "session severed immediately, retrying");
236							last_error = Some(err);
237						} else {
238							tracing::warn!(%url, "session severed immediately, retrying");
239						}
240					}
241				}
242				Err(err) => {
243					if err.is_auth() {
244						return Err(err);
245					}
246					last_error = Some(err);
247				}
248			}
249
250			tracing::warn!(%url, ?delay, "reconnecting after backoff");
251			tokio::time::sleep(delay).await;
252			delay = std::cmp::min(delay * backoff.multiplier, backoff.max);
253		}
254	}
255
256	/// Poll for the next connection status change since this handle last reported one.
257	///
258	/// `Ready(Ok(status))` on a change, `Ready(Err)` once the loop has stopped (the give-up error,
259	/// or a generic one when the handle is dropped), `Pending` otherwise.
260	pub fn poll_status(&mut self, waiter: &kio::Waiter) -> Poll<crate::Result<Status>> {
261		let last = self.last_reported;
262		let status = match ready!(self.state.poll(waiter, |state| match state.status {
263			Some(status) if Some(status) != last => Poll::Ready(status),
264			_ => Poll::Pending,
265		})) {
266			Ok(status) => status,
267			Err(state) => return Poll::Ready(Err(terminal(&state))),
268		};
269
270		self.last_reported = Some(status);
271		Poll::Ready(Ok(status))
272	}
273
274	/// Wait until the connection status changes from what this handle last reported.
275	///
276	/// Returns the current [`Status`]. The loop alternates `Connected`/`Disconnected`, so successive
277	/// calls alternate too; but a status that flips and flips back before the caller polls is
278	/// reported once. This tracks the *current* state, not every edge.
279	pub async fn status(&mut self) -> crate::Result<Status> {
280		kio::wait(|waiter| self.poll_status(waiter)).await
281	}
282
283	/// Whether a session is currently connected.
284	///
285	/// The synchronous read behind [`status`](Self::status), for callers that just want the current
286	/// state rather than the next change.
287	pub fn connected(&self) -> bool {
288		self.state.read().status == Some(Status::Connected)
289	}
290
291	/// The negotiated MoQ version of the live session, or `None` while disconnected.
292	///
293	/// The [`moq_net::Session::version`] counterpart; `Option` because a reconnecting handle can be
294	/// between sessions.
295	pub fn version(&self) -> Option<Version> {
296		self.state.read().version
297	}
298
299	/// A consumer for the live session's estimated send bitrate, mirroring
300	/// [`moq_net::Session::send_bandwidth`].
301	///
302	/// Unlike the session's, this handle is persistent: the reconnect loop forwards each session's
303	/// estimate into it, so it survives reconnects. Its value is `None` while disconnected or when the
304	/// backend has no estimate.
305	pub fn send_bandwidth(&self) -> BandwidthConsumer {
306		self.send_bandwidth.clone()
307	}
308
309	/// A consumer for the live session's estimated receive bitrate, mirroring
310	/// [`moq_net::Session::recv_bandwidth`]. Persistent across reconnects like
311	/// [`send_bandwidth`](Self::send_bandwidth); `None` while disconnected or unavailable.
312	pub fn recv_bandwidth(&self) -> BandwidthConsumer {
313		self.recv_bandwidth.clone()
314	}
315
316	/// Poll whether the reconnect loop has stopped.
317	///
318	/// `Ready(Err)` if it permanently gave up (reconnect timeout exceeded), `Ready(Ok(()))` if
319	/// stopped by dropping the handle, `Pending` while it's still running.
320	pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll<crate::Result<()>> {
321		ready!(self.state.poll_closed(waiter));
322		Poll::Ready(match &self.state.read().error {
323			Some(err) => Err(err.clone()),
324			None => Ok(()),
325		})
326	}
327
328	/// Wait until the reconnect loop stops.
329	pub async fn closed(&self) -> crate::Result<()> {
330		kio::wait(|waiter| self.poll_closed(waiter)).await
331	}
332
333	/// A cloneable handle for reading the current connection's stats.
334	///
335	/// The handle keeps working across reconnects, reporting `None` between connections.
336	pub fn stats(&self) -> ConnectionStatsReader {
337		ConnectionStatsReader {
338			state: self.state.clone(),
339		}
340	}
341}
342
343/// Wait for `session` to close, forwarding its send/recv bandwidth estimates into the persistent
344/// producers meanwhile so [`Reconnect`] consumers track the live estimates across the connection.
345/// Returns the session's close result (the loop uses it to distinguish a healthy drop from an
346/// immediate sever).
347///
348/// One `poll_*` step drives it all: [`poll_forward`] mirrors each kio bandwidth estimate, and the
349/// transport's close future (the one non-kio source) is polled through the waiter's own waker.
350async fn run_session(
351	send_bw: &BandwidthProducer,
352	recv_bw: &BandwidthProducer,
353	session: &moq_net::Session,
354) -> Result<(), moq_net::Error> {
355	let mut send = session.send_bandwidth();
356	let mut recv = session.recv_bandwidth();
357	let closed = session.closed();
358	tokio::pin!(closed);
359
360	let err = kio::wait(|waiter| {
361		poll_forward(&mut send, send_bw, waiter);
362		poll_forward(&mut recv, recv_bw, waiter);
363		waiter.poll_future(closed.as_mut())
364	})
365	.await;
366
367	Err(err)
368}
369
370/// Mirror `bw`'s live estimate into `out` for as long as it changes, dropping the source handle once
371/// the session's producer is gone so we don't keep polling a dead arm. A `poll_*` step: on return,
372/// `waiter` is registered for the next change (unless the source is gone). Seeding is implicit
373/// (the first call forwards the current value if there is one).
374///
375/// A `None` estimate is forwarded but keeps the arm alive: the backend reporting nothing right now
376/// isn't the same as the session ending, and the caller resets `out` to `None` on disconnect anyway.
377fn poll_forward(bw: &mut Option<BandwidthConsumer>, out: &BandwidthProducer, waiter: &kio::Waiter) {
378	loop {
379		let Some(consumer) = bw.as_mut() else { return };
380		let Poll::Ready(res) = consumer.poll_changed(waiter) else {
381			return;
382		};
383		match res {
384			Ok(rate) => {
385				let _ = out.set(rate);
386			}
387			Err(_) => {
388				*bw = None;
389				return;
390			}
391		}
392	}
393}
394
395impl Drop for Reconnect {
396	fn drop(&mut self) {
397		self.abort.abort();
398	}
399}
400
401/// The terminal error read from a closed channel's final state.
402fn terminal(state: &State) -> Error {
403	match &state.error {
404		Some(err) => err.clone(),
405		None => Error::Reconnect("reconnect stopped".to_string()),
406	}
407}
408
409#[cfg(test)]
410mod tests {
411	use super::*;
412
413	#[test]
414	fn test_backoff_default() {
415		let backoff = Backoff::default();
416		assert_eq!(backoff.initial, Duration::from_secs(1));
417		assert_eq!(backoff.multiplier, 2);
418		assert_eq!(backoff.max, Duration::from_secs(30));
419		assert_eq!(backoff.timeout, Duration::from_secs(300));
420	}
421
422	/// The linger outlives the give-up timeout (so the reconnect error surfaces
423	/// first), and an unlimited-retry timeout lingers forever.
424	#[test]
425	fn test_backoff_linger() {
426		let backoff = Backoff::default();
427		assert_eq!(backoff.linger(), backoff.timeout + Duration::from_secs(1));
428
429		let unlimited = Backoff {
430			timeout: Duration::ZERO,
431			..Backoff::default()
432		};
433		assert_eq!(unlimited.linger(), Duration::MAX);
434	}
435
436	#[test]
437	fn poll_forward_mirrors_until_the_source_closes() {
438		let src = BandwidthProducer::new();
439		let out = BandwidthProducer::new();
440		let out_rx = out.consume();
441		let waiter = kio::Waiter::noop();
442
443		// No estimate yet: nothing forwarded, source retained.
444		let mut bw = Some(src.consume());
445		poll_forward(&mut bw, &out, &waiter);
446		assert_eq!(out_rx.peek(), None);
447		assert!(bw.is_some());
448
449		// A value is mirrored through.
450		src.set(Some(3_000)).unwrap();
451		poll_forward(&mut bw, &out, &waiter);
452		assert_eq!(out_rx.peek(), Some(3_000));
453
454		// The estimate becoming unavailable is mirrored, but the arm stays: the
455		// backend reporting nothing right now is not the session ending.
456		src.set(None).unwrap();
457		poll_forward(&mut bw, &out, &waiter);
458		assert_eq!(out_rx.peek(), None);
459		assert!(bw.is_some());
460
461		// So a later value on the same live session still gets through. Dropping the
462		// arm on the `None` above would have stranded the estimate at `None` for the
463		// rest of the session.
464		src.set(Some(9_000)).unwrap();
465		poll_forward(&mut bw, &out, &waiter);
466		assert_eq!(out_rx.peek(), Some(9_000));
467
468		// Closing the source is what retires the arm, so we stop polling a dead one.
469		src.abort(moq_net::Error::Cancel).unwrap();
470		poll_forward(&mut bw, &out, &waiter);
471		assert!(bw.is_none());
472	}
473}