Skip to main content

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}