Skip to main content

moq_native/
reconnect.rs

1use std::task::{Poll, ready};
2use std::time::Duration;
3
4use moq_net::kio;
5use url::Url;
6
7use crate::{Client, Error};
8
9/// Exponential backoff configuration for reconnection attempts.
10#[derive(Clone, Debug, clap::Args, serde::Serialize, serde::Deserialize)]
11#[serde(default, deny_unknown_fields)]
12pub struct Backoff {
13	/// Initial delay before first reconnect attempt.
14	#[arg(
15		id = "backoff-initial",
16		long,
17		default_value = "1s",
18		env = "MOQ_BACKOFF_INITIAL",
19		value_parser = humantime::parse_duration,
20	)]
21	#[serde(with = "humantime_serde")]
22	pub initial: Duration,
23
24	/// Multiplier applied to delay after each failure.
25	#[arg(id = "backoff-multiplier", long, default_value_t = 2, env = "MOQ_BACKOFF_MULTIPLIER")]
26	pub multiplier: u32,
27
28	/// Maximum delay between reconnect attempts.
29	#[arg(
30		id = "backoff-max",
31		long,
32		default_value = "30s",
33		env = "MOQ_BACKOFF_MAX",
34		value_parser = humantime::parse_duration,
35	)]
36	#[serde(with = "humantime_serde")]
37	pub max: Duration,
38
39	/// Maximum time to spend retrying before giving up.
40	/// Resets after a stable connection (one that outlives the initial backoff), so a flapping
41	/// session that reconnects then immediately drops still counts toward the timeout. Set to 0 for
42	/// unlimited retries.
43	#[arg(
44		id = "backoff-timeout",
45		long,
46		default_value = "5m",
47		env = "MOQ_BACKOFF_TIMEOUT",
48		value_parser = humantime::parse_duration,
49	)]
50	#[serde(with = "humantime_serde")]
51	pub timeout: Duration,
52}
53
54impl Default for Backoff {
55	fn default() -> Self {
56		Self {
57			initial: Duration::from_secs(1),
58			multiplier: 2,
59			max: Duration::from_secs(30),
60			timeout: Duration::from_secs(300),
61		}
62	}
63}
64
65/// A connection lifecycle transition reported by [`Reconnect::status`].
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum Status {
68	/// A session connected (the first connect, or a reconnect after a drop).
69	Connected,
70	/// An established session dropped; a reconnect attempt follows.
71	Disconnected,
72}
73
74/// Shared reconnect state, observed by consumers through a [`kio`] channel.
75///
76/// The channel closing (all producers dropped) is the terminal signal; `error`
77/// distinguishes a permanent give-up from a graceful close.
78#[derive(Default)]
79struct State {
80	/// Current connection status, or `None` before the first connect.
81	status: Option<Status>,
82	/// Set when the reconnect loop permanently gives up (reconnect timeout exceeded).
83	error: Option<Error>,
84}
85
86/// Handle to a background reconnect loop.
87///
88/// Spawns a tokio task that connects, waits for session close, then reconnects with exponential
89/// backoff. [`status`](Self::status) reports connection changes; [`closed`](Self::closed) waits for
90/// the loop to stop. Dropping the handle aborts the background task.
91pub struct Reconnect {
92	abort: tokio::task::AbortHandle,
93	state: kio::Consumer<State>,
94	/// The last status returned by [`status`](Self::status), for change detection.
95	last_reported: Option<Status>,
96}
97
98impl Reconnect {
99	pub(crate) fn new(client: Client, url: Url, backoff: Backoff) -> Self {
100		let producer = kio::Producer::<State>::default();
101		let state = producer.consume();
102		let task = tokio::spawn(async move {
103			if let Err(err) = Self::run(&producer, client, url, backoff).await {
104				tracing::error!(%err, "reconnect loop exited");
105				if let Ok(mut state) = producer.write() {
106					state.error = Some(err);
107				}
108			}
109			// Dropping the producer here closes the channel, signaling consumers.
110		});
111		Self {
112			abort: task.abort_handle(),
113			state,
114			last_reported: None,
115		}
116	}
117
118	async fn run(state: &kio::Producer<State>, client: Client, url: Url, backoff: Backoff) -> crate::Result<()> {
119		let mut delay = backoff.initial;
120		let mut retry_start = tokio::time::Instant::now();
121		let mut last_error: Option<Error> = None;
122
123		loop {
124			if !backoff.timeout.is_zero() && retry_start.elapsed() > backoff.timeout {
125				let timeout = backoff.timeout;
126				let msg = match last_error {
127					Some(err) => format!("reconnect timed out after {timeout:?}: {err}"),
128					None => format!("reconnect timed out after {timeout:?}"),
129				};
130				return Err(Error::Reconnect(msg));
131			}
132
133			tracing::info!(%url, "connecting");
134
135			match client.connect(url.clone()).await {
136				Ok(session) => {
137					tracing::info!(%url, "connected");
138					if let Ok(mut state) = state.write() {
139						state.status = Some(Status::Connected);
140					}
141
142					let connected = tokio::time::Instant::now();
143					let closed = session.closed().await;
144					if let Ok(mut state) = state.write() {
145						state.status = Some(Status::Disconnected);
146					}
147
148					if connected.elapsed() >= backoff.initial {
149						// Stayed up past the initial backoff: a healthy session. Reset the backoff
150						// window so a one-off drop reconnects promptly.
151						tracing::warn!(%url, "session closed, reconnecting");
152						delay = backoff.initial;
153						retry_start = tokio::time::Instant::now();
154						last_error = None;
155					} else {
156						// Connected then dropped almost immediately (e.g. the server accepts then
157						// resets). Treat it as a failed connection: keep the close reason so the
158						// give-up timeout reports a real cause, and fall through to the shared backoff
159						// sleep below so repeated flaps escalate instead of spinning the CPU.
160						if let Err(err) = closed {
161							let err = Error::from(err);
162							tracing::warn!(%url, %err, "session severed immediately, retrying");
163							last_error = Some(err);
164						} else {
165							tracing::warn!(%url, "session severed immediately, retrying");
166						}
167					}
168				}
169				Err(err) => {
170					if err.is_auth() {
171						return Err(err);
172					}
173					last_error = Some(err);
174				}
175			}
176
177			tracing::warn!(%url, ?delay, "reconnecting after backoff");
178			tokio::time::sleep(delay).await;
179			delay = std::cmp::min(delay * backoff.multiplier, backoff.max);
180		}
181	}
182
183	/// Poll for the next connection status change since this handle last reported one.
184	///
185	/// `Ready(Ok(status))` on a change, `Ready(Err)` once the loop has stopped (the give-up error,
186	/// or a generic one when the handle is dropped), `Pending` otherwise.
187	pub fn poll_status(&mut self, waiter: &kio::Waiter) -> Poll<crate::Result<Status>> {
188		let last = self.last_reported;
189		let status = match ready!(self.state.poll(waiter, |state| match state.status {
190			Some(status) if Some(status) != last => Poll::Ready(status),
191			_ => Poll::Pending,
192		})) {
193			Ok(status) => status,
194			Err(state) => return Poll::Ready(Err(terminal(&state))),
195		};
196
197		self.last_reported = Some(status);
198		Poll::Ready(Ok(status))
199	}
200
201	/// Wait until the connection status changes from what this handle last reported.
202	///
203	/// Returns the current [`Status`]. The loop alternates `Connected`/`Disconnected`, so successive
204	/// calls alternate too; but a status that flips and flips back before the caller polls is
205	/// reported once. This tracks the *current* state, not every edge.
206	pub async fn status(&mut self) -> crate::Result<Status> {
207		kio::wait(|waiter| self.poll_status(waiter)).await
208	}
209
210	/// Poll whether the reconnect loop has stopped.
211	///
212	/// `Ready(Err)` if it permanently gave up (reconnect timeout exceeded), `Ready(Ok(()))` if
213	/// stopped by dropping the handle, `Pending` while it's still running.
214	pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll<crate::Result<()>> {
215		ready!(self.state.poll_closed(waiter));
216		Poll::Ready(match &self.state.read().error {
217			Some(err) => Err(err.clone()),
218			None => Ok(()),
219		})
220	}
221
222	/// Wait until the reconnect loop stops.
223	pub async fn closed(&self) -> crate::Result<()> {
224		kio::wait(|waiter| self.poll_closed(waiter)).await
225	}
226}
227
228impl Drop for Reconnect {
229	fn drop(&mut self) {
230		self.abort.abort();
231	}
232}
233
234/// The terminal error read from a closed channel's final state.
235fn terminal(state: &State) -> Error {
236	match &state.error {
237		Some(err) => err.clone(),
238		None => Error::Reconnect("reconnect stopped".to_string()),
239	}
240}
241
242#[cfg(test)]
243mod tests {
244	use super::*;
245
246	#[test]
247	fn test_backoff_default() {
248		let backoff = Backoff::default();
249		assert_eq!(backoff.initial, Duration::from_secs(1));
250		assert_eq!(backoff.multiplier, 2);
251		assert_eq!(backoff.max, Duration::from_secs(30));
252		assert_eq!(backoff.timeout, Duration::from_secs(300));
253	}
254}