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, RedactedUrl};
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	/// Cumulative connects and disconnects, bumped by the reconnect loop itself so a session that
142	/// connects and drops before a consumer polls still counts.
143	presence: moq_net::stats::Presence,
144	/// The negotiated MoQ version of the live session, or `None` when disconnected.
145	version: Option<Version>,
146	/// Set when the reconnect loop permanently gives up: the backoff timeout expiring, or a server
147	/// answer that redialing cannot change.
148	error: Option<Error>,
149	/// The currently-connected session, or `None` while reconnecting. Read by
150	/// [`ConnectionStatsReader`] to snapshot live connection stats.
151	session: Option<moq_net::Session>,
152}
153
154/// Statistics and protocol sampled from the same live connection.
155#[non_exhaustive]
156pub struct ConnectionSnapshot {
157	/// Transport statistics at the time of the snapshot.
158	pub stats: moq_net::ConnectionStats,
159	/// Protocol negotiated by the connection that supplied these statistics.
160	pub version: Version,
161}
162
163/// A cloneable read handle for the live connection stats of a [`Reconnect`] loop.
164///
165/// Obtained via [`Reconnect::stats`]. [`stats`](Self::stats) returns `None` while the loop is
166/// between connections (reconnecting), and `Some` snapshot while a session is established.
167#[derive(Clone)]
168pub struct ConnectionStatsReader {
169	state: kio::Consumer<State>,
170	last_presence: moq_net::stats::Presence,
171}
172
173impl ConnectionStatsReader {
174	/// Cumulative connects and disconnects of this reconnect loop, the same shape as a relay's
175	/// sessions track: `sessions - sessions_closed` is 1 while connected, and a rate is a delta over
176	/// any window.
177	pub fn presence(&self) -> moq_net::stats::Presence {
178		self.state.read().presence
179	}
180
181	/// Poll until either presence counter moves past what this handle last reported.
182	pub fn poll_presence(&mut self, waiter: &kio::Waiter) -> Poll<crate::Result<moq_net::stats::Presence>> {
183		let last = self.last_presence;
184		let presence = match ready!(self.state.poll(waiter, |state| match state.presence {
185			presence if presence != last => Poll::Ready(presence),
186			_ => Poll::Pending,
187		})) {
188			Ok(presence) => presence,
189			Err(state) => return Poll::Ready(Err(terminal(&state))),
190		};
191
192		self.last_presence = presence;
193		Poll::Ready(Ok(presence))
194	}
195
196	/// Wait until either presence counter moves past what this handle last reported.
197	///
198	/// Unlike [`Reconnect::status`], a connect and disconnect that both land before the caller polls
199	/// are not coalesced away: the counters still moved.
200	pub async fn presence_changed(&mut self) -> crate::Result<moq_net::stats::Presence> {
201		kio::wait(|waiter| self.poll_presence(waiter)).await
202	}
203
204	/// Snapshot the current connection's stats, or `None` if not currently connected.
205	pub fn stats(&self) -> Option<moq_net::ConnectionStats> {
206		self.state.read().session.as_ref().map(moq_net::Session::stats)
207	}
208
209	/// Snapshot statistics and protocol together, or `None` while disconnected.
210	pub fn snapshot(&self) -> Option<ConnectionSnapshot> {
211		let state = self.state.read();
212		let session = state.session.as_ref()?;
213		Some(ConnectionSnapshot {
214			stats: session.stats(),
215			version: session.version(),
216		})
217	}
218}
219
220/// Handle to a background reconnect loop.
221///
222/// Spawns a tokio task that connects, waits for session close, then reconnects with exponential
223/// backoff until [`Backoff::timeout`] runs out. This loop is the only retry owner for the connection:
224/// a caller that rebuilds it on failure restarts the backoff from its initial delay, which turns the
225/// escalation back into a tight loop. Watch [`closed`](Self::closed) instead.
226///
227/// The read surface mirrors [`moq_net::Session`] so a caller can treat it like a session
228/// that transparently reconnects: [`version`](Self::version), [`send_bandwidth`](Self::send_bandwidth),
229/// and [`recv_bandwidth`](Self::recv_bandwidth) track the live session and reset while disconnected.
230/// The extra toggle a plain session doesn't have is the connection lifecycle: [`connected`](Self::connected)
231/// reads it synchronously and [`status`](Self::status) waits for the next change. [`closed`](Self::closed)
232/// waits for the loop to stop. Dropping the handle aborts the background task.
233pub struct Reconnect {
234	abort: tokio::task::AbortHandle,
235	state: kio::Consumer<State>,
236	/// Persistent send-bitrate estimate, fed by the loop from each live session.
237	send_bandwidth: BandwidthConsumer,
238	/// Persistent recv-bitrate estimate, fed by the loop from each live session.
239	recv_bandwidth: BandwidthConsumer,
240	/// The last status returned by [`status`](Self::status), for change detection.
241	last_reported: Option<Status>,
242}
243
244impl Reconnect {
245	pub(crate) fn new(client: Client, url: Url, backoff: Backoff) -> Self {
246		let producer = kio::Producer::<State>::default();
247		let state = producer.consume();
248
249		// The loop feeds these across every reconnect, so a consumer's handle survives session churn
250		// (unlike a session's own bandwidth consumer, which dies with the session).
251		let send_bw = BandwidthProducer::new();
252		let recv_bw = BandwidthProducer::new();
253		let send_bandwidth = send_bw.consume();
254		let recv_bandwidth = recv_bw.consume();
255
256		let task = tokio::spawn(async move {
257			if let Err(err) = Self::run(&producer, &send_bw, &recv_bw, client, url, backoff).await {
258				tracing::error!(%err, "reconnect loop exited");
259				if let Ok(mut state) = producer.write() {
260					state.error = Some(err);
261				}
262			}
263			// Dropping the producers here closes the channels, signaling consumers.
264		});
265		Self {
266			abort: task.abort_handle(),
267			state,
268			send_bandwidth,
269			recv_bandwidth,
270			last_reported: None,
271		}
272	}
273
274	async fn run(
275		state: &kio::Producer<State>,
276		send_bw: &BandwidthProducer,
277		recv_bw: &BandwidthProducer,
278		client: Client,
279		url: Url,
280		backoff: Backoff,
281	) -> crate::Result<()> {
282		// The escalating wait between attempts, and the instant the give-up budget expires. Both
283		// restart after a session that stayed healthy, so a one-off drop reconnects promptly. A zero
284		// timeout means no deadline at all: retry for as long as the process lives.
285		let mut delay = backoff.initial;
286		let mut deadline = deadline_from(&backoff);
287		let mut last_error: Option<Error> = None;
288
289		// The dial target usually carries an auth token in its query, so every line
290		// below logs the redacted form.
291		let url_log = RedactedUrl::new(&url);
292
293		loop {
294			tracing::info!(url = %url_log, "connecting");
295
296			match client.connect(url.clone()).await {
297				Ok(session) => {
298					tracing::info!(url = %url_log, "connected");
299					if let Ok(mut state) = state.write() {
300						state.presence.sessions += 1;
301						state.status = Some(Status::Connected);
302						state.version = Some(session.version());
303						state.session = Some(session.clone());
304					}
305
306					let connected = tokio::time::Instant::now();
307					// Wait for the session to close, forwarding its bandwidth estimates into the
308					// persistent producers meanwhile so consumers track the live stats across the connection.
309					let closed = run_session(send_bw, recv_bw, &session).await;
310					if let Ok(mut state) = state.write() {
311						state.presence.sessions_closed += 1;
312						state.status = Some(Status::Disconnected);
313						state.version = None;
314						state.session = None;
315					}
316					// The estimates belonged to the now-closed session; reset until the next connect.
317					let _ = send_bw.set(None);
318					let _ = recv_bw.set(None);
319
320					if connected.elapsed() >= backoff.initial {
321						// Stayed up past the initial backoff: a healthy session. Reset the backoff
322						// window so a one-off drop reconnects promptly.
323						tracing::warn!(url = %url_log, "session closed, reconnecting");
324						delay = backoff.initial;
325						deadline = deadline_from(&backoff);
326						last_error = None;
327					} else {
328						// Connected then dropped almost immediately (e.g. the server accepts then
329						// resets). Treat it as a failed connection: keep the close reason so the
330						// give-up timeout reports a real cause, and fall through to the shared backoff
331						// sleep below so repeated flaps escalate instead of spinning the CPU.
332						if let Err(err) = closed {
333							let err = Error::from(err);
334							tracing::warn!(url = %url_log, %err, "session severed immediately, retrying");
335							last_error = Some(err);
336						} else {
337							tracing::warn!(url = %url_log, "session severed immediately, retrying");
338						}
339					}
340				}
341				Err(err) => {
342					// The two answers a server can give that redialing cannot change: it rejected our
343					// credentials, or it answered the CONNECT with a status that isn't an invitation
344					// to come back. Everything else falls through to the backoff, whose budget is
345					// what stops the loop.
346					if err.is_auth() {
347						return Err(err);
348					}
349					if let Some(status) = err.status()
350						&& !crate::error::status_retryable(status)
351					{
352						return Err(err);
353					}
354					last_error = Some(err);
355				}
356			}
357
358			let now = tokio::time::Instant::now();
359			if deadline.is_some_and(|deadline| now >= deadline) {
360				let timeout = backoff.timeout;
361				let msg = match last_error {
362					Some(err) => format!("reconnect timed out after {timeout:?}: {err}"),
363					None => format!("reconnect timed out after {timeout:?}"),
364				};
365				return Err(Error::Reconnect(msg));
366			}
367
368			// Jittered so a fleet knocked offline together doesn't reconnect on the same tick, and
369			// never past the deadline the budget promised.
370			let mut wait = delay.mul_f64(0.5 + rand::rng().random::<f64>() / 2.0);
371			if let Some(deadline) = deadline {
372				wait = wait.min(deadline - now);
373			}
374			delay = backoff.next_delay(delay);
375
376			tracing::warn!(url = %url_log, ?wait, "reconnecting after backoff");
377			tokio::time::sleep(wait).await;
378		}
379	}
380
381	/// Poll for the next connection status change since this handle last reported one.
382	///
383	/// `Ready(Ok(status))` on a change, `Ready(Err)` once the loop has stopped (the give-up error,
384	/// or a generic one when the handle is dropped), `Pending` otherwise.
385	pub fn poll_status(&mut self, waiter: &kio::Waiter) -> Poll<crate::Result<Status>> {
386		let last = self.last_reported;
387		let status = match ready!(self.state.poll(waiter, |state| match state.status {
388			Some(status) if Some(status) != last => Poll::Ready(status),
389			_ => Poll::Pending,
390		})) {
391			Ok(status) => status,
392			Err(state) => return Poll::Ready(Err(terminal(&state))),
393		};
394
395		self.last_reported = Some(status);
396		Poll::Ready(Ok(status))
397	}
398
399	/// Wait until the connection status changes from what this handle last reported.
400	///
401	/// Returns the current [`Status`]. The loop alternates `Connected`/`Disconnected`, so successive
402	/// calls alternate too; but a status that flips and flips back before the caller polls is
403	/// reported once. This tracks the *current* state, not every edge.
404	pub async fn status(&mut self) -> crate::Result<Status> {
405		kio::wait(|waiter| self.poll_status(waiter)).await
406	}
407
408	/// Whether a session is currently connected.
409	///
410	/// The synchronous read behind [`status`](Self::status), for callers that just want the current
411	/// state rather than the next change.
412	pub fn connected(&self) -> bool {
413		self.state.read().status == Some(Status::Connected)
414	}
415
416	/// The negotiated MoQ version of the live session, or `None` while disconnected.
417	///
418	/// The [`moq_net::Session::version`] counterpart; `Option` because a reconnecting handle can be
419	/// between sessions.
420	pub fn version(&self) -> Option<Version> {
421		self.state.read().version
422	}
423
424	/// A consumer for the live session's estimated send bitrate, mirroring
425	/// [`moq_net::Session::send_bandwidth`].
426	///
427	/// Unlike the session's, this handle is persistent: the reconnect loop forwards each session's
428	/// estimate into it, so it survives reconnects. Its value is `None` while disconnected or when the
429	/// backend has no estimate.
430	pub fn send_bandwidth(&self) -> BandwidthConsumer {
431		self.send_bandwidth.clone()
432	}
433
434	/// A consumer for the live session's estimated receive bitrate, mirroring
435	/// [`moq_net::Session::recv_bandwidth`]. Persistent across reconnects like
436	/// [`send_bandwidth`](Self::send_bandwidth); `None` while disconnected or unavailable.
437	pub fn recv_bandwidth(&self) -> BandwidthConsumer {
438		self.recv_bandwidth.clone()
439	}
440
441	/// Poll whether the reconnect loop has stopped.
442	///
443	/// `Ready(Err)` if it permanently gave up (a failure no retry can clear, or the backoff timeout
444	/// expiring), `Ready(Ok(()))` if stopped by dropping the handle, `Pending` while it's still
445	/// running.
446	pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll<crate::Result<()>> {
447		ready!(self.state.poll_closed(waiter));
448		Poll::Ready(match &self.state.read().error {
449			Some(err) => Err(err.clone()),
450			None => Ok(()),
451		})
452	}
453
454	/// Wait until the reconnect loop stops.
455	pub async fn closed(&self) -> crate::Result<()> {
456		kio::wait(|waiter| self.poll_closed(waiter)).await
457	}
458
459	/// A cloneable handle for reading the current connection's stats.
460	///
461	/// The handle keeps working across reconnects, reporting `None` between connections.
462	pub fn stats(&self) -> ConnectionStatsReader {
463		ConnectionStatsReader {
464			state: self.state.clone(),
465			last_presence: moq_net::stats::Presence::default(),
466		}
467	}
468}
469
470/// Wait for `session` to close, forwarding its send/recv bandwidth estimates into the persistent
471/// producers meanwhile so [`Reconnect`] consumers track the live estimates across the connection.
472/// Returns the session's close result (the loop uses it to distinguish a healthy drop from an
473/// immediate sever).
474///
475/// One `poll_*` step drives it all: [`poll_forward`] mirrors each kio bandwidth estimate, and the
476/// transport's close future (the one non-kio source) is polled through the waiter's own waker.
477async fn run_session(
478	send_bw: &BandwidthProducer,
479	recv_bw: &BandwidthProducer,
480	session: &moq_net::Session,
481) -> Result<(), moq_net::Error> {
482	let mut send = session.send_bandwidth();
483	let mut recv = session.recv_bandwidth();
484	let closed = session.closed();
485	tokio::pin!(closed);
486
487	let err = kio::wait(|waiter| {
488		poll_forward(&mut send, send_bw, waiter);
489		poll_forward(&mut recv, recv_bw, waiter);
490		waiter.poll_future(closed.as_mut())
491	})
492	.await;
493
494	Err(err)
495}
496
497/// Mirror `bw`'s live estimate into `out` for as long as it changes, dropping the source handle once
498/// the session's producer is gone so we don't keep polling a dead arm. A `poll_*` step: on return,
499/// `waiter` is registered for the next change (unless the source is gone). Seeding is implicit
500/// (the first call forwards the current value if there is one).
501///
502/// A `None` estimate is forwarded but keeps the arm alive: the backend reporting nothing right now
503/// isn't the same as the session ending, and the caller resets `out` to `None` on disconnect anyway.
504fn poll_forward(bw: &mut Option<BandwidthConsumer>, out: &BandwidthProducer, waiter: &kio::Waiter) {
505	loop {
506		let Some(consumer) = bw.as_mut() else { return };
507		let Poll::Ready(res) = consumer.poll_changed(waiter) else {
508			return;
509		};
510		match res {
511			Ok(rate) => {
512				let _ = out.set(rate);
513			}
514			Err(_) => {
515				*bw = None;
516				return;
517			}
518		}
519	}
520}
521
522impl Drop for Reconnect {
523	fn drop(&mut self) {
524		self.abort.abort();
525	}
526}
527
528/// The terminal error read from a closed channel's final state.
529fn terminal(state: &State) -> Error {
530	match &state.error {
531		Some(err) => err.clone(),
532		None => Error::Reconnect("reconnect stopped".to_string()),
533	}
534}
535
536#[cfg(test)]
537mod tests {
538	#[tokio::test]
539	async fn snapshot_uses_one_live_session() {
540		let mut config = crate::ServerConfig {
541			bind: Some("[::]:0".into()),
542			..Default::default()
543		};
544		config.tls.generate = vec!["localhost".into()];
545		let mut server = config.init().unwrap();
546		let url = format!("moqt://localhost:{}", server.local_addr().unwrap().port())
547			.parse()
548			.unwrap();
549		let mut config = crate::ClientConfig::default();
550		config.tls.disable_verify = Some(true);
551		let client = config.init().unwrap();
552		let (accepted, connected) = tokio::time::timeout(Duration::from_secs(10), async {
553			tokio::join!(
554				async { server.accept().await.unwrap().ok().await.unwrap() },
555				client.connect(url)
556			)
557		})
558		.await
559		.unwrap();
560		let session = connected.unwrap();
561		let version = session.version();
562		let producer = kio::Producer::<State>::default();
563		let reader = ConnectionStatsReader {
564			state: producer.consume(),
565			last_presence: Default::default(),
566		};
567		assert_eq!(reader.presence().active(), 0);
568		assert!(reader.snapshot().is_none());
569		{
570			let mut state = producer.write().ok().unwrap();
571			state.presence.sessions = 2;
572			state.presence.sessions_closed = 1;
573			state.session = Some(session);
574		}
575		assert_eq!(reader.presence().sessions, 2);
576		assert_eq!(reader.presence().active(), 1);
577		// The snapshot must read the protocol from that same session, without a second state query.
578		assert_eq!(reader.snapshot().unwrap().version, version);
579		producer.write().ok().unwrap().session = None;
580		assert!(reader.snapshot().is_none());
581		drop(accepted);
582	}
583
584	/// A connect and disconnect that both land before the consumer polls leave the status where it
585	/// was, but the counters still report the flap.
586	#[tokio::test]
587	async fn presence_change_survives_coalesced_status() {
588		let producer = kio::Producer::<State>::default();
589		let mut reader = ConnectionStatsReader {
590			state: producer.consume(),
591			last_presence: Default::default(),
592		};
593		{
594			let mut state = producer.write().ok().unwrap();
595			state.presence.sessions = 1;
596			state.status = Some(Status::Connected);
597		}
598		{
599			let mut state = producer.write().ok().unwrap();
600			state.presence.sessions_closed = 1;
601			state.status = Some(Status::Disconnected);
602		}
603		let presence = reader.presence_changed().await.unwrap();
604		assert_eq!((presence.sessions, presence.sessions_closed), (1, 1));
605		assert_eq!(presence.active(), 0);
606	}
607
608	/// The retry loop is `delay = min(delay * multiplier, max)`, so a zero anywhere
609	/// pins the delay at zero and turns an unreachable relay into a hot dial loop,
610	/// unbounded when the give-up timeout is also zero.
611	#[test]
612	fn backoff_rejects_an_unpaced_retry() {
613		assert!(Backoff::default().validate().is_ok());
614
615		for bad in [
616			Backoff {
617				initial: Duration::ZERO,
618				..Default::default()
619			},
620			Backoff {
621				multiplier: 0,
622				..Default::default()
623			},
624			Backoff {
625				max: Duration::ZERO,
626				..Default::default()
627			},
628		] {
629			assert!(
630				matches!(bad.validate(), Err(crate::Error::BackoffUnpaced)),
631				"{bad:?} should be rejected"
632			);
633		}
634
635		// A zero timeout is documented as retry-forever, which is only a hazard
636		// unpaced, and a multiplier of 1 is a constant delay rather than no delay.
637		let forever = Backoff {
638			timeout: Duration::ZERO,
639			multiplier: 1,
640			..Default::default()
641		};
642		assert!(forever.validate().is_ok());
643	}
644
645	#[test]
646	fn backoff_growth_saturates_before_applying_the_cap() {
647		let backoff = Backoff {
648			multiplier: u32::MAX,
649			..Default::default()
650		};
651		assert_eq!(backoff.next_delay(Duration::MAX), backoff.max);
652	}
653
654	use super::*;
655
656	#[test]
657	fn test_backoff_default() {
658		let backoff = Backoff::default();
659		assert_eq!(backoff.initial, Duration::from_secs(1));
660		assert_eq!(backoff.multiplier, 2);
661		assert_eq!(backoff.max, Duration::from_secs(5));
662		assert_eq!(backoff.timeout, Duration::from_secs(10));
663	}
664
665	/// The linger outlives the give-up timeout (so the reconnect error surfaces
666	/// first), and an unlimited-retry timeout lingers forever.
667	#[test]
668	fn test_backoff_linger() {
669		let backoff = Backoff::default();
670		assert_eq!(backoff.linger(), backoff.timeout + Duration::from_secs(1));
671
672		let unlimited = Backoff {
673			timeout: Duration::ZERO,
674			..Backoff::default()
675		};
676		assert_eq!(unlimited.linger(), Duration::MAX);
677	}
678
679	#[test]
680	fn poll_forward_mirrors_until_the_source_closes() {
681		let src = BandwidthProducer::new();
682		let out = BandwidthProducer::new();
683		let out_rx = out.consume();
684		let waiter = kio::Waiter::noop();
685
686		// No estimate yet: nothing forwarded, source retained.
687		let mut bw = Some(src.consume());
688		poll_forward(&mut bw, &out, &waiter);
689		assert_eq!(out_rx.peek(), None);
690		assert!(bw.is_some());
691
692		// A value is mirrored through.
693		src.set(Some(3_000)).unwrap();
694		poll_forward(&mut bw, &out, &waiter);
695		assert_eq!(out_rx.peek(), Some(3_000));
696
697		// The estimate becoming unavailable is mirrored, but the arm stays: the
698		// backend reporting nothing right now is not the session ending.
699		src.set(None).unwrap();
700		poll_forward(&mut bw, &out, &waiter);
701		assert_eq!(out_rx.peek(), None);
702		assert!(bw.is_some());
703
704		// So a later value on the same live session still gets through. Dropping the
705		// arm on the `None` above would have stranded the estimate at `None` for the
706		// rest of the session.
707		src.set(Some(9_000)).unwrap();
708		poll_forward(&mut bw, &out, &waiter);
709		assert_eq!(out_rx.peek(), Some(9_000));
710
711		// Closing the source is what retires the arm, so we stop polling a dead one.
712		src.abort(moq_net::Error::Cancel).unwrap();
713		poll_forward(&mut bw, &out, &waiter);
714		assert!(bw.is_none());
715	}
716}