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