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