use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use dig_ip::{CandidateSource, DialConfig, LocalStack, PeerCandidates};
use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{client_async_tls_with_config, MaybeTlsStream, WebSocketStream};
use crate::wire::{RelayMessage, RelayPeerInfo};
pub const DEFAULT_NETWORK_ID: &str = "DIG_MAINNET";
pub const RELAY_PROTOCOL_VERSION: u32 = 1;
const BASE_BACKOFF_SECS: u64 = 5;
const MAX_BACKOFF_SECS: u64 = 300;
const PING_INTERVAL_SECS: u64 = 30;
const DISCOVERY_INTERVAL_SECS: u64 = 60;
pub const MAX_KNOWN_PEERS: usize = 1024;
pub const MAX_RELAY_PAYLOAD: usize = 1 << 20;
const RELAY_TUNNEL_INBOUND_CAP: usize = 256;
pub fn backoff_secs(consecutive_failures: u32) -> u64 {
backoff_secs_with(consecutive_failures, BASE_BACKOFF_SECS, MAX_BACKOFF_SECS)
}
fn backoff_secs_with(consecutive_failures: u32, base: u64, cap: u64) -> u64 {
let shifted = base.checked_shl(consecutive_failures).unwrap_or(cap);
shifted.clamp(base, cap)
}
#[derive(Debug, Clone, Copy)]
pub struct Backoff {
pub base_secs: u64,
pub cap_secs: u64,
}
impl Default for Backoff {
fn default() -> Self {
Backoff {
base_secs: BASE_BACKOFF_SECS,
cap_secs: MAX_BACKOFF_SECS,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayState {
Disabled,
Connecting,
Connected,
Disconnected,
}
impl RelayState {
pub fn as_str(self) -> &'static str {
match self {
RelayState::Disabled => "disabled",
RelayState::Connecting => "connecting",
RelayState::Connected => "connected",
RelayState::Disconnected => "disconnected",
}
}
fn to_u8(self) -> u8 {
match self {
RelayState::Disabled => 0,
RelayState::Connecting => 1,
RelayState::Connected => 2,
RelayState::Disconnected => 3,
}
}
fn from_u8(v: u8) -> Self {
match v {
0 => RelayState::Disabled,
1 => RelayState::Connecting,
2 => RelayState::Connected,
_ => RelayState::Disconnected,
}
}
}
#[derive(Debug, Default)]
struct DiscoveredPeers {
order: Vec<RelayPeerInfo>,
ids: HashSet<String>,
}
impl DiscoveredPeers {
fn insert(&mut self, peer: RelayPeerInfo) {
if self.order.len() >= MAX_KNOWN_PEERS {
return;
}
if self.ids.insert(peer.peer_id.clone()) {
self.order.push(peer);
}
}
fn remove(&mut self, peer_id: &str) {
if self.ids.remove(peer_id) {
self.order.retain(|p| p.peer_id != peer_id);
}
}
fn replace(&mut self, peers: Vec<RelayPeerInfo>) {
self.order.clear();
self.ids.clear();
for peer in peers {
self.insert(peer);
}
}
fn clear(&mut self) {
self.order.clear();
self.ids.clear();
}
}
#[derive(Debug)]
pub struct RelayStatus {
state: AtomicU8,
reconnect_attempts: AtomicU32,
connected_peers: AtomicU64,
last_error: Mutex<Option<String>>,
known_peers: Mutex<DiscoveredPeers>,
outbound: Mutex<Option<mpsc::UnboundedSender<RelayMessage>>>,
local_peer_id: Mutex<Option<String>>,
tunnels: Mutex<HashMap<String, mpsc::Sender<Vec<u8>>>>,
relay_seq: AtomicU64,
}
impl Default for RelayStatus {
fn default() -> Self {
RelayStatus {
state: AtomicU8::new(RelayState::Disconnected.to_u8()),
reconnect_attempts: AtomicU32::new(0),
connected_peers: AtomicU64::new(0),
last_error: Mutex::new(None),
known_peers: Mutex::new(DiscoveredPeers::default()),
outbound: Mutex::new(None),
local_peer_id: Mutex::new(None),
tunnels: Mutex::new(HashMap::new()),
relay_seq: AtomicU64::new(0),
}
}
}
impl RelayStatus {
pub fn new() -> Arc<Self> {
Arc::new(RelayStatus::default())
}
pub fn state(&self) -> RelayState {
RelayState::from_u8(self.state.load(Ordering::Relaxed))
}
fn transition_to(&self, next: RelayState) -> bool {
let prev = self.state.swap(next.to_u8(), Ordering::Relaxed);
prev != next.to_u8()
}
pub fn set_disabled(&self) {
if self.transition_to(RelayState::Disabled) {
tracing::info!("relay reservation disabled (DIG_RELAY_URL=off)");
}
}
pub fn set_connecting(&self) {
if self.transition_to(RelayState::Connecting) {
tracing::debug!("relay connecting");
}
}
pub fn set_connected(&self, connected_peers: u64) {
self.connected_peers
.store(connected_peers, Ordering::Relaxed);
self.reconnect_attempts.store(0, Ordering::Relaxed);
*self.last_error.lock().unwrap() = None;
if self.transition_to(RelayState::Connected) {
tracing::info!(connected_peers, "relay reservation established");
}
}
pub fn set_disconnected(&self, error: Option<String>) {
self.reconnect_attempts.fetch_add(1, Ordering::Relaxed);
if let Some(e) = &error {
*self.last_error.lock().unwrap() = Some(e.clone());
}
let changed = self.transition_to(RelayState::Disconnected);
if changed {
match &error {
Some(e) => tracing::warn!(
error = %e,
"relay reservation lost — node still serving; retrying in background"
),
None => tracing::info!("relay reservation closed — retrying in background"),
}
}
}
pub fn is_connected(&self) -> bool {
self.state() == RelayState::Connected
}
pub fn reconnect_attempts(&self) -> u32 {
self.reconnect_attempts.load(Ordering::Relaxed)
}
pub fn known_peers(&self) -> Vec<RelayPeerInfo> {
self.known_peers.lock().unwrap().order.clone()
}
pub fn known_peer_count(&self) -> usize {
self.known_peers.lock().unwrap().order.len()
}
fn replace_known_peers(&self, peers: Vec<RelayPeerInfo>) {
self.known_peers.lock().unwrap().replace(peers);
}
fn add_known_peer(&self, peer: RelayPeerInfo) {
self.known_peers.lock().unwrap().insert(peer);
}
fn remove_known_peer(&self, peer_id: &str) {
self.known_peers.lock().unwrap().remove(peer_id);
}
fn clear_known_peers(&self) {
self.known_peers.lock().unwrap().clear();
}
fn set_transport(&self, peer_id: &str, outbound: mpsc::UnboundedSender<RelayMessage>) {
*self.local_peer_id.lock().unwrap() = Some(peer_id.to_string());
*self.outbound.lock().unwrap() = Some(outbound);
}
fn clear_transport(&self) {
*self.outbound.lock().unwrap() = None;
self.tunnels.lock().unwrap().clear();
}
pub fn relay_transport_ready(&self) -> bool {
self.is_connected() && self.outbound.lock().unwrap().is_some()
}
pub fn open_tunnel(
self: &Arc<Self>,
target_peer: &str,
network_id: &str,
) -> Result<RelayTunnel, String> {
if !self.relay_transport_ready() {
return Err("relay reservation not connected — cannot open relayed tunnel".into());
}
let (tx, rx) = mpsc::channel(RELAY_TUNNEL_INBOUND_CAP);
self.tunnels
.lock()
.unwrap()
.insert(target_peer.to_string(), tx);
Ok(RelayTunnel {
target: target_peer.to_string(),
network_id: network_id.to_string(),
status: Arc::clone(self),
inbound: rx,
})
}
fn route_relayed(&self, from: &str, payload: Vec<u8>) {
if payload.len() > MAX_RELAY_PAYLOAD {
tracing::debug!(
from,
len = payload.len(),
"dropping oversized relayed frame"
);
return;
}
let sink = self.tunnels.lock().unwrap().get(from).cloned();
if let Some(sink) = sink {
if sink.try_send(payload).is_err() {
tracing::debug!(from, "relayed tunnel inbound full/closed — frame dropped");
}
}
}
fn close_tunnel(&self, target_peer: &str) {
self.tunnels.lock().unwrap().remove(target_peer);
}
#[cfg(test)]
pub(crate) fn open_tunnel_exists(&self, target_peer: &str) -> bool {
self.tunnels.lock().unwrap().contains_key(target_peer)
}
pub fn snapshot_json(&self, endpoint: &str, peer_id: &str) -> serde_json::Value {
let state = self.state();
serde_json::json!({
"state": state.as_str(),
"connected": state == RelayState::Connected,
"endpoint": endpoint,
"peer_id": peer_id,
"reconnect_attempts": self.reconnect_attempts.load(Ordering::Relaxed),
"connected_peers": self.connected_peers.load(Ordering::Relaxed),
"last_error": *self.last_error.lock().unwrap(),
})
}
}
pub struct RelayTunnel {
target: String,
network_id: String,
status: Arc<RelayStatus>,
inbound: mpsc::Receiver<Vec<u8>>,
}
impl RelayTunnel {
pub fn target(&self) -> &str {
&self.target
}
pub fn network_id(&self) -> &str {
&self.network_id
}
pub fn send(&self, payload: Vec<u8>) -> Result<(), String> {
if payload.len() > MAX_RELAY_PAYLOAD {
return Err(format!(
"relayed payload {} exceeds cap {MAX_RELAY_PAYLOAD}",
payload.len()
));
}
let from = self
.status
.local_peer_id
.lock()
.unwrap()
.clone()
.ok_or("relay reservation not connected — no local peer_id")?;
let seq = self.status.relay_seq.fetch_add(1, Ordering::Relaxed);
let frame = RelayMessage::RelayGossipMessage {
from,
to: self.target.clone(),
payload,
seq,
};
let guard = self.status.outbound.lock().unwrap();
let sink = guard
.as_ref()
.ok_or("relay reservation not connected — cannot send relayed frame")?;
sink.send(frame)
.map_err(|_| "relay reservation write half closed".to_string())
}
pub async fn recv(&mut self) -> Option<Vec<u8>> {
self.inbound.recv().await
}
pub(crate) fn poll_recv(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Vec<u8>>> {
self.inbound.poll_recv(cx)
}
}
impl Drop for RelayTunnel {
fn drop(&mut self) {
self.status.close_tunnel(&self.target);
}
}
pub fn relay_url_from_env() -> String {
std::env::var("DIG_RELAY_URL")
.ok()
.filter(|s| !s.trim().is_empty())
.filter(|s| !is_off_token(s))
.unwrap_or_else(|| dig_constants::DIG_RELAY_URL.to_string())
}
pub fn relay_enabled() -> bool {
match std::env::var("DIG_RELAY_URL") {
Ok(v) => !is_off_token(&v),
Err(_) => true,
}
}
fn is_off_token(v: &str) -> bool {
let v = v.trim();
v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("disabled")
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub async fn run_relay_connection(
endpoint: String,
peer_id: String,
network_id: String,
listen_addrs: Vec<SocketAddr>,
status: Arc<RelayStatus>,
) {
run_relay_connection_with(
endpoint,
peer_id,
network_id,
listen_addrs,
status,
Backoff::default(),
)
.await
}
pub async fn run_relay_connection_with(
endpoint: String,
peer_id: String,
network_id: String,
listen_addrs: Vec<SocketAddr>,
status: Arc<RelayStatus>,
backoff: Backoff,
) {
let mut consecutive_failures: u32 = 0;
loop {
status.set_connecting();
match connect_once(&endpoint, &peer_id, &network_id, &listen_addrs, &status).await {
Ok(()) => {
consecutive_failures = 0;
status.set_disconnected(None);
}
Err(e) => {
consecutive_failures = consecutive_failures.saturating_add(1);
status.set_disconnected(Some(e));
}
}
let delay = backoff_secs_with(consecutive_failures, backoff.base_secs, backoff.cap_secs);
tokio::time::sleep(Duration::from_secs(delay)).await;
}
}
#[derive(Debug, PartialEq, Eq)]
struct RelayEndpoint {
host: String,
port: u16,
}
fn parse_relay_endpoint(endpoint: &str) -> Result<RelayEndpoint, String> {
let (scheme, rest) = endpoint
.split_once("://")
.ok_or_else(|| format!("relay endpoint missing scheme: {endpoint}"))?;
let default_port = match scheme.to_ascii_lowercase().as_str() {
"ws" => 80,
"wss" => 443,
other => return Err(format!("unsupported relay scheme: {other}")),
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let authority = authority
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(authority);
let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
let (h, after) = stripped
.split_once(']')
.ok_or_else(|| format!("malformed IPv6 authority: {authority}"))?;
let port = match after.strip_prefix(':') {
Some(p) => p.parse().map_err(|_| format!("bad relay port: {after}"))?,
None => default_port,
};
(h.to_string(), port)
} else if let Some((h, p)) = authority.rsplit_once(':') {
(
h.to_string(),
p.parse().map_err(|_| format!("bad relay port: {p}"))?,
)
} else {
(authority.to_string(), default_port)
};
if host.is_empty() {
return Err(format!("relay endpoint missing host: {endpoint}"));
}
Ok(RelayEndpoint { host, port })
}
async fn resolve_relay_candidates(host: &str, port: u16) -> Result<PeerCandidates, String> {
let mut candidates = PeerCandidates::new();
let source_for = |ip: &IpAddr| {
if ip.is_ipv6() {
CandidateSource::DnsAAAA
} else {
CandidateSource::DnsA
}
};
if let Ok(ip) = host.parse::<IpAddr>() {
candidates.add(SocketAddr::new(ip, port), source_for(&ip));
} else {
let resolved = tokio::net::lookup_host((host, port))
.await
.map_err(|e| format!("resolve {host}:{port}: {e}"))?;
for addr in resolved {
candidates.add(addr, source_for(&addr.ip()));
}
}
if candidates.is_empty() {
return Err(format!("no addresses resolved for {host}:{port}"));
}
Ok(candidates)
}
async fn race_relay_candidates<C, F, Fut>(
local: &LocalStack,
candidates: &PeerCandidates,
config: DialConfig,
dial_fn: F,
) -> Result<dig_ip::DialWinner<C>, String>
where
F: Fn(SocketAddr) -> Fut + Sync,
Fut: std::future::Future<Output = Result<C, String>> + Send,
C: Send,
{
dig_ip::connect(local, candidates, config, dial_fn)
.await
.map_err(|e| format!("relay happy-eyeballs dial: {e}"))
}
async fn open_relay_ws(
endpoint: &str,
) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, String> {
let parsed = parse_relay_endpoint(endpoint)?;
let candidates = resolve_relay_candidates(&parsed.host, parsed.port).await?;
let local = LocalStack::cached();
let winner = race_relay_candidates(
&local,
&candidates,
DialConfig::default(),
|addr| async move {
TcpStream::connect(addr)
.await
.map_err(|e| format!("tcp connect {addr}: {e}"))
},
)
.await?;
let (ws, _resp) = client_async_tls_with_config(endpoint, winner.conn, None, None)
.await
.map_err(|e| format!("ws handshake: {e}"))?;
Ok(ws)
}
async fn connect_once(
endpoint: &str,
peer_id: &str,
network_id: &str,
listen_addrs: &[SocketAddr],
status: &Arc<RelayStatus>,
) -> Result<(), String> {
status.clear_known_peers();
status.clear_transport();
let ws = open_relay_ws(endpoint).await?;
let (mut write, mut read) = ws.split();
let register = RelayMessage::Register {
peer_id: peer_id.to_string(),
network_id: network_id.to_string(),
protocol_version: RELAY_PROTOCOL_VERSION,
listen_addrs: listen_addrs.to_vec(),
};
send(&mut write, ®ister).await?;
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<RelayMessage>();
status.set_transport(peer_id, out_tx);
let get_peers = RelayMessage::GetPeers {
network_id: Some(network_id.to_string()),
};
send(&mut write, &get_peers).await?;
let mut ping = tokio::time::interval(Duration::from_secs(PING_INTERVAL_SECS));
ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ping.tick().await;
let mut discovery = tokio::time::interval(Duration::from_secs(DISCOVERY_INTERVAL_SECS));
discovery.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
discovery.tick().await;
let result = serve_session(
&mut write,
&mut read,
&mut ping,
&mut discovery,
&mut out_rx,
network_id,
status,
)
.await;
status.clear_transport();
result
}
#[allow(clippy::too_many_arguments)]
async fn serve_session<W, R>(
write: &mut W,
read: &mut R,
ping: &mut tokio::time::Interval,
discovery: &mut tokio::time::Interval,
out_rx: &mut mpsc::UnboundedReceiver<RelayMessage>,
network_id: &str,
status: &Arc<RelayStatus>,
) -> Result<(), String>
where
W: SinkExt<Message> + Unpin,
<W as futures_util::Sink<Message>>::Error: std::fmt::Display,
R: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
{
loop {
tokio::select! {
_ = ping.tick() => {
send(write, &RelayMessage::Ping { timestamp: now_secs() }).await?;
}
_ = discovery.tick() => {
send(write, &RelayMessage::GetPeers {
network_id: Some(network_id.to_string()),
}).await?;
}
Some(frame) = out_rx.recv() => {
send(write, &frame).await?;
}
frame = read.next() => {
match frame {
None => return Ok(()),
Some(Err(e)) => return Err(format!("read: {e}")),
Some(Ok(Message::Close(_))) => return Ok(()),
Some(Ok(Message::Ping(p))) => {
write.send(Message::Pong(p)).await.map_err(|e| format!("pong: {e}"))?;
}
Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {}
Some(Ok(Message::Text(t))) => {
handle_incoming(t.into_bytes(), write, status).await?;
}
Some(Ok(Message::Binary(b))) => {
handle_incoming(b, write, status).await?;
}
}
}
}
}
}
async fn handle_incoming<W>(
bytes: Vec<u8>,
write: &mut W,
status: &Arc<RelayStatus>,
) -> Result<(), String>
where
W: SinkExt<Message> + Unpin,
<W as futures_util::Sink<Message>>::Error: std::fmt::Display,
{
let Ok(msg) = serde_json::from_slice::<RelayMessage>(&bytes) else {
return Ok(()); };
match msg {
RelayMessage::RegisterAck {
success,
message,
connected_peers,
} => {
if success {
status.set_connected(connected_peers as u64);
} else {
return Err(format!("register rejected: {message}"));
}
}
RelayMessage::Ping { timestamp } => {
send(write, &RelayMessage::Pong { timestamp }).await?;
}
RelayMessage::Peers { peers } => status.replace_known_peers(peers),
RelayMessage::PeerConnected { peer } => status.add_known_peer(peer),
RelayMessage::PeerDisconnected { peer_id } => status.remove_known_peer(&peer_id),
RelayMessage::RelayGossipMessage { from, payload, .. } => {
status.route_relayed(&from, payload)
}
RelayMessage::Error { code, message } => {
return Err(format!("relay error {code}: {message}"));
}
other => tracing::debug!(?other, "relay message ignored by reservation loop"),
}
Ok(())
}
#[cfg(test)]
pub(crate) fn loopback_reservation_pair(
a_id: &str,
b_id: &str,
) -> (Arc<RelayStatus>, Arc<RelayStatus>) {
let a = RelayStatus::new();
let b = RelayStatus::new();
a.set_connected(1);
b.set_connected(1);
let (a_tx, mut a_rx) = mpsc::unbounded_channel::<RelayMessage>();
let (b_tx, mut b_rx) = mpsc::unbounded_channel::<RelayMessage>();
a.set_transport(a_id, a_tx);
b.set_transport(b_id, b_tx);
let b_route = Arc::clone(&b);
tokio::spawn(async move {
while let Some(RelayMessage::RelayGossipMessage { from, payload, .. }) = a_rx.recv().await {
b_route.route_relayed(&from, payload);
}
});
let a_route = Arc::clone(&a);
tokio::spawn(async move {
while let Some(RelayMessage::RelayGossipMessage { from, payload, .. }) = b_rx.recv().await {
a_route.route_relayed(&from, payload);
}
});
(a, b)
}
async fn send<W>(write: &mut W, msg: &RelayMessage) -> Result<(), String>
where
W: SinkExt<Message> + Unpin,
<W as futures_util::Sink<Message>>::Error: std::fmt::Display,
{
let txt = serde_json::to_string(msg).map_err(|e| format!("encode: {e}"))?;
write
.send(Message::Text(txt))
.await
.map_err(|e| format!("send: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use dig_ip::Family;
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
use std::sync::Mutex as StdMutex;
#[test]
fn parses_wss_host_and_explicit_port() {
let ep = parse_relay_endpoint("wss://relay.dig.net:443").unwrap();
assert_eq!(ep.host, "relay.dig.net");
assert_eq!(ep.port, 443);
}
#[test]
fn parses_default_ports_by_scheme() {
assert_eq!(
parse_relay_endpoint("wss://relay.dig.net").unwrap().port,
443
);
assert_eq!(parse_relay_endpoint("ws://relay.dig.net").unwrap().port, 80);
}
#[test]
fn parses_bracketed_ipv6_authority_with_and_without_port() {
let with_port = parse_relay_endpoint("wss://[2001:db8::1]:8443").unwrap();
assert_eq!(with_port.host, "2001:db8::1");
assert_eq!(with_port.port, 8443);
let no_port = parse_relay_endpoint("wss://[2001:db8::1]/ws").unwrap();
assert_eq!(no_port.host, "2001:db8::1");
assert_eq!(no_port.port, 443);
}
#[test]
fn ignores_path_query_and_userinfo() {
let ep = parse_relay_endpoint("wss://user@relay.dig.net:9443/ws?x=1#f").unwrap();
assert_eq!(ep.host, "relay.dig.net");
assert_eq!(ep.port, 9443);
}
#[test]
fn rejects_malformed_endpoints() {
assert!(parse_relay_endpoint("relay.dig.net:443").is_err()); assert!(parse_relay_endpoint("http://relay.dig.net").is_err()); assert!(parse_relay_endpoint("wss://relay.dig.net:notaport").is_err());
}
#[tokio::test]
async fn resolve_relay_candidates_handles_ip_literals_without_dns() {
let v6 = resolve_relay_candidates("2001:db8::1", 443).await.unwrap();
assert_eq!(v6.all().len(), 1);
assert_eq!(v6.all()[0].family, Family::V6);
assert_eq!(v6.all()[0].source, CandidateSource::DnsAAAA);
let v4 = resolve_relay_candidates("203.0.113.7", 443).await.unwrap();
assert_eq!(v4.all()[0].family, Family::V4);
assert_eq!(v4.all()[0].source, CandidateSource::DnsA);
}
#[tokio::test]
async fn relay_dial_races_both_families_and_falls_back_to_ipv4() {
let mut candidates = PeerCandidates::new();
let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
let v4: SocketAddr = "203.0.113.7:443".parse().unwrap();
candidates.add(v6, CandidateSource::DnsAAAA);
candidates.add(v4, CandidateSource::DnsA);
let dual = LocalStack::from_flags(true, true);
let attempted: StdMutex<Vec<SocketAddr>> = StdMutex::new(Vec::new());
let cfg = DialConfig {
per_attempt_timeout: Duration::from_secs(1),
attempt_delay: Duration::from_millis(5),
};
let winner = race_relay_candidates(&dual, &candidates, cfg, |addr| {
let attempted = &attempted;
async move {
attempted.lock().unwrap().push(addr);
if addr.is_ipv6() {
Err(format!("simulated dead IPv6 {addr}"))
} else {
Ok(addr) }
}
})
.await
.expect("IPv4 fallback wins when IPv6 is dead");
assert_eq!(winner.conn, v4, "the live IPv4 candidate won");
assert_eq!(winner.family, Family::V4);
let tried = attempted.lock().unwrap();
assert!(
tried.contains(&v6),
"the IPv6 candidate was attempted first"
);
assert!(
tried.contains(&v4),
"the IPv4 candidate was attempted as fallback"
);
}
#[tokio::test]
async fn relay_dial_prefers_ipv6_when_both_live() {
let mut candidates = PeerCandidates::new();
let v6: SocketAddr = "[2001:db8::2]:443".parse().unwrap();
let v4: SocketAddr = "203.0.113.8:443".parse().unwrap();
candidates.add(v6, CandidateSource::DnsAAAA);
candidates.add(v4, CandidateSource::DnsA);
let dual = LocalStack::from_flags(true, true);
let calls = AtomicUsize::new(0);
let winner = race_relay_candidates(&dual, &candidates, DialConfig::default(), |addr| {
calls.fetch_add(1, AtomicOrdering::Relaxed);
async move { Ok::<SocketAddr, String>(addr) }
})
.await
.unwrap();
assert_eq!(winner.conn, v6, "IPv6 preferred when both are viable");
assert_eq!(winner.family, Family::V6);
}
}