Skip to main content

moq_native/
reconnect.rs

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