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