dig_ip/connect.rs
1//! [`connect`] — the RFC-8305 (Happy Eyeballs v2) dial racer.
2//!
3//! `connect` builds its attempt list ONLY from [`dial_order`], so it structurally cannot SYN a
4//! family the local host or the peer lacks. Over that intersection it races the caller's transport
5//! dial closure IPv6-first: it starts the most-preferred (IPv6) candidate, and after a short
6//! [`DialConfig::attempt_delay`] ALSO starts the next candidate if the first has not completed — so a
7//! viable IPv6 candidate is preferred and IPv4 is used only as a fallback when IPv6 fails or stalls.
8//! IPv6 is the PREFERENCE, not merely first to start: a lower-priority success is returned only once
9//! every higher-priority attempt has concluded, so a viable IPv6 wins even when a hedged IPv4 attempt
10//! connects sooner.
11//!
12//! The transport dial stays a caller-supplied `async` closure (`dial_fn`), so this crate needs no
13//! TLS/socket dependency — dig-nat supplies the real mTLS `TcpStream::connect` + rustls closure; a
14//! test supplies a canned one.
15
16use std::collections::BTreeSet;
17use std::fmt;
18use std::future::Future;
19use std::net::SocketAddr;
20use std::time::Duration;
21
22use crate::candidate::PeerCandidates;
23use crate::dial::{dial_order, NoCommonFamily};
24use crate::family::Family;
25use crate::local::LocalStack;
26
27/// Tuning for the happy-eyeballs candidate race.
28#[derive(Debug, Clone, Copy)]
29pub struct DialConfig {
30 /// Hard timeout for a single candidate's connect attempt.
31 pub per_attempt_timeout: Duration,
32 /// Delay before ALSO starting the next (lower-priority) candidate while the current is still in
33 /// flight — RFC 8305's "Connection Attempt Delay". A small value (~250ms) hedges a stalled IPv6
34 /// without racing so hard that IPv4 routinely beats a viable IPv6.
35 pub attempt_delay: Duration,
36}
37
38impl Default for DialConfig {
39 fn default() -> Self {
40 // RFC 8305 recommends a ~250ms connection-attempt delay; the per-attempt timeout is kept
41 // generous so a caller's outer per-dial bound is the real ceiling.
42 DialConfig {
43 per_attempt_timeout: Duration::from_secs(10),
44 attempt_delay: Duration::from_millis(250),
45 }
46 }
47}
48
49/// The winning connection plus which candidate/family established it.
50#[derive(Debug)]
51pub struct DialWinner<C> {
52 /// The connection returned by the caller's `dial_fn`.
53 pub conn: C,
54 /// The address that won.
55 pub addr: SocketAddr,
56 /// The family of the winning address (so the caller can record the preference that held).
57 pub family: Family,
58}
59
60/// Why a [`connect`] attempt produced no connection.
61#[derive(Debug)]
62pub enum ConnectError<E> {
63 /// The local host and the peer share no reachable family — no attempt was even started.
64 NoCommonFamily(NoCommonFamily),
65 /// Every candidate in the intersection was attempted and failed.
66 AllFailed(Vec<(SocketAddr, E)>),
67}
68
69impl<E: fmt::Display> fmt::Display for ConnectError<E> {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 ConnectError::NoCommonFamily(e) => write!(f, "{e}"),
73 ConnectError::AllFailed(errs) => {
74 let joined = errs
75 .iter()
76 .map(|(a, e)| format!("{a}: {e}"))
77 .collect::<Vec<_>>()
78 .join("; ");
79 write!(f, "all candidates failed: [{joined}]")
80 }
81 }
82 }
83}
84
85impl<E: fmt::Display + fmt::Debug> std::error::Error for ConnectError<E> {}
86
87/// Establish a connection to `peer`, IPv6-first with graceful IPv4 fallback, over the local∩peer
88/// family intersection ([`dial_order`]).
89///
90/// `dial_fn` performs one candidate's transport connect (async, family-aware via the [`SocketAddr`]
91/// it is handed). On success the returned [`DialWinner`] reports the winning address + family; on
92/// failure [`ConnectError::NoCommonFamily`] (nothing dialable) or [`ConnectError::AllFailed`] (every
93/// candidate tried and failed).
94pub async fn connect<C, E, F, Fut>(
95 local: &LocalStack,
96 peer: &PeerCandidates,
97 config: DialConfig,
98 dial_fn: F,
99) -> Result<DialWinner<C>, ConnectError<E>>
100where
101 E: fmt::Display + FromTimeout,
102 F: Fn(SocketAddr) -> Fut + Sync,
103 Fut: Future<Output = Result<C, E>> + Send,
104 C: Send,
105{
106 // The intersection filter: the attempt list is derived ONLY from dial_order, so a family the
107 // local host or the peer lacks can never be attempted. A disjoint pair fails immediately.
108 let ordered = dial_order(local, peer).map_err(ConnectError::NoCommonFamily)?;
109
110 let total = ordered.len();
111 // Each attempt yields (priority, addr, result); FuturesUnordered runs them concurrently.
112 type Attempt<'f, U, Er> =
113 std::pin::Pin<Box<dyn Future<Output = (usize, SocketAddr, Result<U, Er>)> + Send + 'f>>;
114 let mut attempts: futures::stream::FuturesUnordered<Attempt<'_, C, E>> =
115 futures::stream::FuturesUnordered::new();
116 // Priority indices still in flight; a held fallback success is only returned once no live
117 // attempt is more preferred than it.
118 let mut live: BTreeSet<usize> = BTreeSet::new();
119 let mut errors: Vec<(SocketAddr, E)> = Vec::with_capacity(total);
120 let mut next_prio = 0usize;
121 // The most-preferred success seen so far, held until no more-preferred candidate can beat it.
122 let mut best_success: Option<(usize, SocketAddr, C)> = None;
123
124 // Launch candidate `next_prio` as a bounded attempt.
125 macro_rules! launch {
126 () => {
127 if next_prio < total {
128 let prio = next_prio;
129 let addr = ordered[prio];
130 next_prio += 1;
131 live.insert(prio);
132 let fut = &dial_fn;
133 attempts.push(Box::pin(async move {
134 let res =
135 match tokio::time::timeout(config.per_attempt_timeout, fut(addr)).await {
136 Ok(Ok(conn)) => Ok(conn),
137 Ok(Err(e)) => Err(e),
138 Err(_) => Err(TimedOut.into_e()),
139 };
140 (prio, addr, res)
141 }));
142 }
143 };
144 }
145
146 // Prime the most-preferred (IPv6) candidate.
147 launch!();
148
149 loop {
150 // Settle a held success once no still-live AND no unlaunched candidate is more preferred.
151 if let Some((p, _, _)) = &best_success {
152 let more_preferred_live = live.iter().next().map(|lo| *lo < *p).unwrap_or(false);
153 let more_preferred_unlaunched = next_prio <= *p;
154 if !more_preferred_live && !more_preferred_unlaunched {
155 let (_, addr, conn) = best_success.take().unwrap();
156 return Ok(DialWinner {
157 conn,
158 addr,
159 family: Family::of(&addr),
160 });
161 }
162 }
163
164 // Nothing running and nothing left to launch → done.
165 if live.is_empty() && next_prio >= total {
166 break;
167 }
168
169 let stagger = tokio::time::sleep(config.attempt_delay);
170 tokio::select! {
171 biased;
172 finished = futures::StreamExt::next(&mut attempts), if !live.is_empty() => {
173 match finished {
174 Some((prio, addr, Ok(conn))) => {
175 live.remove(&prio);
176 // Top-priority (index 0, most-preferred IPv6) success wins outright.
177 if prio == 0 {
178 return Ok(DialWinner { conn, addr, family: Family::of(&addr) });
179 }
180 // Otherwise hold the most-preferred success; keep racing more-preferred ones.
181 let keep = best_success.as_ref().map(|(bp, _, _)| prio < *bp).unwrap_or(true);
182 if keep {
183 best_success = Some((prio, addr, conn));
184 }
185 launch!();
186 }
187 Some((prio, addr, Err(e))) => {
188 live.remove(&prio);
189 errors.push((addr, e));
190 launch!();
191 }
192 None => break,
193 }
194 }
195 _ = stagger, if !live.is_empty() && next_prio < total => {
196 // The preferred candidate is stalling — hedge by ALSO starting the next candidate.
197 launch!();
198 }
199 }
200 }
201
202 // Nothing left in flight: return the most-preferred held success, else the collected errors.
203 if let Some((_, addr, conn)) = best_success {
204 return Ok(DialWinner {
205 conn,
206 addr,
207 family: Family::of(&addr),
208 });
209 }
210 Err(ConnectError::AllFailed(errors))
211}
212
213/// A private timeout marker so the racer can synthesize a per-attempt-timeout error of the caller's
214/// error type `E` without requiring `E: From<std::io::Error>`. The caller's `E` must be constructible
215/// from a `&str`-ish description; we route through the [`FromTimeout`] shim implemented for the common
216/// case (`String`) and any `E: From<std::io::Error>`.
217struct TimedOut;
218
219impl TimedOut {
220 fn into_e<E: FromTimeout>(self) -> E {
221 E::timed_out()
222 }
223}
224
225/// How to construct the caller's error type for a per-attempt timeout. Implemented for `String` (the
226/// common test/closure error) and any type convertible from a `std::io::Error`.
227pub trait FromTimeout {
228 /// Build the "connect attempt timed out" value of this error type.
229 fn timed_out() -> Self;
230}
231
232impl FromTimeout for String {
233 fn timed_out() -> Self {
234 "connect attempt timed out".to_string()
235 }
236}
237
238impl FromTimeout for std::io::Error {
239 fn timed_out() -> Self {
240 std::io::Error::new(std::io::ErrorKind::TimedOut, "connect attempt timed out")
241 }
242}