Skip to main content

iroh_topic_tracker/
gossip.rs

1use std::{
2    collections::{HashMap, HashSet},
3    sync::{
4        Arc,
5        atomic::{AtomicBool, Ordering},
6    },
7    time::{Duration, Instant},
8};
9
10use dht::async_dht::AsyncDht;
11use ed25519_dalek::SigningKey;
12use futures_lite::StreamExt;
13use iroh::{Endpoint, EndpointId};
14use iroh_gossip::api::{GossipReceiver, GossipSender};
15use n0_future::time;
16use n0_watcher::Watchable;
17use sha2::Digest;
18use tokio::sync::Mutex;
19
20#[derive(Debug, Clone)]
21pub struct TopicDiscoveryConfig {
22    endpoint: Endpoint,
23
24    signing_key: SigningKey,
25    /// How often to re-announce to DHT (default: 5 minutes)
26    announce_interval: Duration,
27    /// Discovery interval when we have peers (default: 60s)
28    discovery_interval: Duration,
29    /// Discovery interval after we first started connecting for first_connected_duration (default: 10s)
30    first_connected_duration: Option<Duration>,
31    /// Discovery interval after we first connected for first_connected_duration (default: 5s)
32    discovery_interval_first_connected: Duration,
33    /// Discovery interval when we have no peers (default: 2s) - aggressive mode
34    discovery_interval_no_peers: Duration,
35    /// Timeout for individual connection attempts (default: 5s)
36    connection_timeout: Duration,
37    /// How long before we retry a failed peer (default: 5 minutes)
38    retry_interval: Duration,
39    /// Max peers to attempt per discovery round (default: 5)
40    max_peers_per_round: Option<usize>,
41    /// DHT initialization retry count if None infinite retries
42    dht_retries: Option<usize>,
43}
44
45pub struct ConfigBuilder(TopicDiscoveryConfig);
46
47impl ConfigBuilder {
48    pub fn announce_interval(mut self, interval: Duration) -> Self {
49        self.0.announce_interval = interval;
50        self
51    }
52
53    pub fn discovery_interval(mut self, interval: Duration) -> Self {
54        self.0.discovery_interval = interval;
55        self
56    }
57
58    pub fn discovery_interval_no_peers(mut self, interval: Duration) -> Self {
59        self.0.discovery_interval_no_peers = interval;
60        self
61    }
62
63    pub fn discovery_interval_first_connected(mut self, interval: Duration) -> Self {
64        self.0.discovery_interval_first_connected = interval;
65        self
66    }
67
68    pub fn first_connected_duration(mut self, duration: Option<Duration>) -> Self {
69        self.0.first_connected_duration = duration;
70        self
71    }
72
73    pub fn connection_timeout(mut self, timeout: Duration) -> Self {
74        self.0.connection_timeout = timeout;
75        self
76    }
77
78    pub fn retry_interval(mut self, interval: Duration) -> Self {
79        self.0.retry_interval = interval;
80        self
81    }
82
83    pub fn max_peers_per_round(mut self, max: Option<usize>) -> Self {
84        self.0.max_peers_per_round = max;
85        self
86    }
87
88    pub fn dht_retries(mut self, retries: Option<usize>) -> Self {
89        self.0.dht_retries = retries;
90        self
91    }
92
93    pub fn build(&self) -> TopicDiscoveryConfig {
94        self.0.clone()
95    }
96}
97
98impl TopicDiscoveryConfig {
99    pub fn builder(endpoint: Endpoint) -> ConfigBuilder {
100        ConfigBuilder(Self {
101            signing_key: endpoint.secret_key().to_bytes().into(),
102            endpoint,
103            announce_interval: Duration::from_secs(300),
104            discovery_interval: Duration::from_secs(60),
105            first_connected_duration: Some(Duration::from_secs(60)),
106            discovery_interval_first_connected: Duration::from_secs(5),
107            discovery_interval_no_peers: Duration::from_secs(2),
108            connection_timeout: Duration::from_secs(5),
109            retry_interval: Duration::from_secs(300),
110            max_peers_per_round: Some(5),
111            dht_retries: None,
112        })
113    }
114
115    pub fn announce_interval(&self) -> Duration {
116        self.announce_interval
117    }
118
119    pub fn discovery_interval(&self) -> Duration {
120        self.discovery_interval
121    }
122
123    pub fn discovery_interval_no_peers(&self) -> Duration {
124        self.discovery_interval_no_peers
125    }
126
127    pub fn connection_timeout(&self) -> Duration {
128        self.connection_timeout
129    }
130
131    pub fn retry_interval(&self) -> Duration {
132        self.retry_interval
133    }
134
135    pub fn max_peers_per_round(&self) -> Option<usize> {
136        self.max_peers_per_round
137    }
138
139    pub fn dht_retries(&self) -> Option<usize> {
140        self.dht_retries
141    }
142}
143
144#[derive(Debug, Clone)]
145struct DiscoveryState {
146    /// Number of peers we've successfully joined to gossip
147    once_connected_neighbors: Arc<Mutex<HashSet<EndpointId>>>,
148    /// Signal to stop all tasks
149    stopped: Arc<AtomicBool>,
150    /// Peers we've attempted to connect to, with timestamp for retry logic
151    attempted: Arc<Mutex<HashMap<[u8; 32], Instant>>>,
152    /// How long before we retry a failed peer
153    retry_interval: Duration,
154    /// First connection timestamp to switch discovery intervals
155    first_connected_timestamp: Watchable<Option<Instant>>,
156}
157
158impl DiscoveryState {
159    fn new(retry_interval: Duration) -> Arc<Self> {
160        Arc::new(Self {
161            once_connected_neighbors: Arc::new(Mutex::new(HashSet::new())),
162            stopped: Arc::new(AtomicBool::new(false)),
163            attempted: Arc::new(Mutex::new(HashMap::new())),
164            retry_interval,
165            first_connected_timestamp: Watchable::new(None),
166        })
167    }
168
169    fn stop(&self) {
170        self.stopped.store(true, Ordering::Relaxed);
171    }
172
173    fn is_stopped(&self) -> bool {
174        self.stopped.load(Ordering::Relaxed)
175    }
176
177    async fn has_connections(&self) -> bool {
178        let guard = self.once_connected_neighbors.lock().await;
179        !guard.is_empty()
180    }
181
182    async fn added_connection_count(&self) -> usize {
183        let guard = self.once_connected_neighbors.lock().await;
184        guard.len()
185    }
186
187    // All historically connected peers with (at least once) open connections at some point
188    fn added_neighbors(&self) -> Arc<Mutex<HashSet<EndpointId>>> {
189        self.once_connected_neighbors.clone()
190    }
191
192    /// Mark a peer as attempted. Returns true if we should try to connect
193    /// (either new peer, or retry interval has elapsed).
194    async fn should_attempt(&self, peer: [u8; 32]) -> bool {
195        let mut map = self.attempted.lock().await;
196        match map.get(&peer) {
197            None => {
198                map.insert(peer, Instant::now());
199                true
200            }
201            Some(last_attempt) => {
202                if last_attempt.elapsed() > self.retry_interval {
203                    map.insert(peer, Instant::now());
204                    true
205                } else {
206                    false
207                }
208            }
209        }
210    }
211
212    async fn reset_attempt(&self, peer: [u8; 32]) {
213        let mut map = self.attempted.lock().await;
214        map.remove(&peer);
215    }
216
217    fn first_connected_timestamp_watcher(&self) -> Watchable<Option<Instant>> {
218        self.first_connected_timestamp.clone()
219    }
220
221    fn first_connected_phase(&self, config: &TopicDiscoveryConfig) -> bool {
222        if let Some(timestamp) = self.first_connected_timestamp_watcher().get()
223            && let Some(first_connected_duration) = config.first_connected_duration
224            && timestamp.elapsed() < first_connected_duration
225        {
226            true
227        } else {
228            false
229        }
230    }
231}
232
233#[derive(Debug)]
234pub struct TopicDiscoveryHandle {
235    state: Arc<DiscoveryState>,
236    _tasks: Vec<tokio::task::JoinHandle<()>>,
237}
238
239impl TopicDiscoveryHandle {
240    pub fn stop(&self) {
241        self.state.stop();
242    }
243
244    pub fn is_running(&self) -> bool {
245        !self.state.is_stopped()
246    }
247
248    pub async fn has_connections(&self) -> bool {
249        self.state.has_connections().await
250    }
251
252    pub async fn added_connection_count(&self) -> usize {
253        self.state.added_connection_count().await
254    }
255
256    /// Adds new neighbors as soon as a path is available.
257    /// NOTE: does NOT remove neighbors that go down
258    pub async fn added_neighbors(&self) -> HashSet<EndpointId> {
259        let mtx = self.state.added_neighbors();
260        let guard = mtx.lock().await;
261        guard.clone()
262    }
263}
264
265impl Drop for TopicDiscoveryHandle {
266    fn drop(&mut self) {
267        self.stop();
268    }
269}
270
271pub trait TopicDiscoveryExt {
272    #[allow(async_fn_in_trait)]
273    async fn subscribe_with_discovery_joined(
274        &self,
275        topic_id: Vec<u8>,
276        bootstrap_nodes: Vec<EndpointId>,
277        config: TopicDiscoveryConfig,
278    ) -> anyhow::Result<(GossipSender, GossipReceiver, TopicDiscoveryHandle)>;
279
280    #[allow(async_fn_in_trait)]
281    async fn subscribe_with_discovery(
282        &self,
283        topic_id: Vec<u8>,
284        bootstrap_nodes: Vec<EndpointId>,
285        config: TopicDiscoveryConfig,
286    ) -> anyhow::Result<(GossipSender, GossipReceiver, TopicDiscoveryHandle)>;
287}
288
289impl TopicDiscoveryExt for iroh_gossip::net::Gossip {
290    async fn subscribe_with_discovery_joined(
291        &self,
292        topic_id: Vec<u8>,
293        bootstrap_nodes: Vec<EndpointId>,
294        config: TopicDiscoveryConfig,
295    ) -> anyhow::Result<(GossipSender, GossipReceiver, TopicDiscoveryHandle)> {
296        tracing::info!("subscribe_with_discovery_joined: starting subscription");
297        let (sender, mut receiver, handle) = self
298            .subscribe_with_discovery(topic_id, bootstrap_nodes, config)
299            .await?;
300        tracing::info!("subscribe_with_discovery_joined: waiting for receiver.joined()");
301        receiver.joined().await?;
302
303        while handle.added_connection_count().await < 1 {
304            tokio::time::sleep(Duration::from_millis(50)).await;
305        }
306        tracing::info!("subscribe_with_discovery_joined: joined successfully");
307        Ok((sender, receiver, handle))
308    }
309
310    async fn subscribe_with_discovery(
311        &self,
312        topic_id: Vec<u8>,
313        bootstrap_nodes: Vec<EndpointId>,
314        config: TopicDiscoveryConfig,
315    ) -> anyhow::Result<(GossipSender, GossipReceiver, TopicDiscoveryHandle)> {
316        tracing::info!("subscribe_with_discovery: computing topic hash");
317        let topic_bytes = topic_hash_32(&topic_id);
318        tracing::debug!(
319            "subscribe_with_discovery: topic_hash={}",
320            hex::encode(topic_bytes)
321        );
322
323        tracing::info!("subscribe_with_discovery: subscribing to gossip topic");
324        let (sender, receiver) = self
325            .subscribe(
326                iroh_gossip::proto::TopicId::from_bytes(topic_bytes),
327                bootstrap_nodes,
328            )
329            .await?
330            .split();
331
332        tracing::info!(
333            "subscribe_with_discovery: subscribed, spawning announce and discovery tasks"
334        );
335
336        let state = DiscoveryState::new(config.retry_interval);
337
338        tracing::info!("subscribe_with_discovery: initializing shared DHT");
339        let mut tries = 0;
340        let dht = loop {
341            if let Ok(dht) = init_dht().await {
342                break Arc::new(dht);
343            }
344            tracing::warn!("subscribe_with_discovery: DHT init failed, retrying in 2s");
345            tokio::time::sleep(Duration::from_secs(2)).await;
346            tries += 1;
347            if let Some(retries) = config.dht_retries()
348                && tries > retries
349            {
350                anyhow::bail!("DHT init failed after 5 attempts");
351            }
352        };
353
354        let tasks = vec![
355            spawn_announce_task(state.clone(), dht.clone(), topic_bytes, config.clone()),
356            spawn_discovery_task(state.clone(), dht, sender.clone(), topic_bytes, config),
357        ];
358
359        let handle = TopicDiscoveryHandle {
360            state,
361            _tasks: tasks,
362        };
363
364        Ok((sender, receiver, handle))
365    }
366}
367
368async fn init_dht() -> anyhow::Result<AsyncDht> {
369    tracing::info!("init_dht: building DHT with bootstrap nodes");
370    let dht = dht::Dht::builder()
371        .no_bootstrap()
372        .bootstrap(&["pkarr.rustonbsd.com:6881", "relay.pkarr.org:6881"])
373        .build()?
374        .as_async();
375
376    tracing::info!("init_dht: waiting for DHT bootstrap... ");
377    match tokio::time::timeout(Duration::from_secs(15), dht.bootstrapped()).await {
378        Ok(true) => {}
379        Ok(false) => {
380            tracing::error!("init_dht: DHT bootstrap failed");
381            anyhow::bail!("DHT bootstrap failed");
382        }
383        Err(_) => {
384            tracing::error!("init_dht: DHT bootstrap timed out");
385            anyhow::bail!("DHT bootstrap timed out");
386        }
387    }
388
389    Ok(dht)
390}
391
392fn spawn_connector(
393    state: Arc<DiscoveryState>,
394    gossip_sender: GossipSender,
395    peer: EndpointId,
396    timeout: Duration,
397    endpoint: Endpoint,
398) {
399    tokio::spawn(async move {
400        if state.is_stopped() {
401            return;
402        }
403
404        tracing::debug!("connector: joining peer {} via gossip", peer.fmt_short());
405
406        let _ = gossip_sender.join_peers(vec![peer]).await;
407
408        if state.is_stopped() {
409            return;
410        }
411
412        let wait_for_connection = async {
413            loop {
414                if let Some(remote_info) = endpoint.remote_info(peer).await
415                    && remote_info.addrs().any(|addr| {
416                        matches!(addr.usage(), iroh::endpoint::TransportAddrUsage::Active)
417                    })
418                {
419                    return true;
420                }
421                tokio::time::sleep(Duration::from_millis(250)).await;
422            }
423        };
424
425        match time::timeout(timeout, wait_for_connection).await {
426            Ok(true) => {
427                tracing::info!(
428                    "connector: successfully connected to peer {}",
429                    peer.fmt_short()
430                );
431                let mut guard = state.once_connected_neighbors.lock().await;
432                guard.insert(peer);
433                return;
434            }
435            Ok(false) => {
436                tracing::debug!(
437                    "connector: stopped while waiting for connection to {}",
438                    peer.fmt_short()
439                );
440            }
441            Err(_) => {
442                tracing::warn!(
443                    "connector: timeout waiting for connection to {} after {:?}",
444                    peer.fmt_short(),
445                    timeout
446                );
447            }
448        }
449
450        state.reset_attempt(*peer.as_bytes()).await;
451    });
452}
453
454fn spawn_announce_task(
455    state: Arc<DiscoveryState>,
456    dht: Arc<AsyncDht>,
457    topic_hash_32: [u8; 32],
458    config: TopicDiscoveryConfig,
459) -> tokio::task::JoinHandle<()> {
460    tracing::info!("spawn_announce_task: starting announce task");
461    tokio::spawn(async move {
462        let mut backoff = Duration::from_secs(5);
463        let mut round = 0u64;
464
465        let id = match dht::Id::from_bytes(topic_hash_20(&topic_hash_32)) {
466            Ok(id) => id,
467            Err(e) => {
468                tracing::error!("announce_task: invalid topic hash: {e}");
469                return;
470            }
471        };
472
473        while !state.is_stopped() {
474            round += 1;
475            tracing::debug!("announce_task: round {round} starting");
476
477            tracing::debug!("announce_task: announcing to DHT");
478            match tokio::time::timeout(
479                Duration::from_secs(30),
480                dht.announce_signed_peer(id, &config.signing_key),
481            )
482            .await
483            {
484                Ok(Ok(_)) => {
485                    tracing::info!("announce_task: DHT announce success");
486                    backoff = Duration::from_secs(5);
487                    tracing::debug!("announce_task: sleeping for {:?}", config.announce_interval);
488                    tokio::time::sleep(config.announce_interval).await;
489                }
490                Ok(Err(e)) => {
491                    tracing::warn!(
492                        "announce_task: DHT announce failed: {e}, retrying in {backoff:?}"
493                    );
494
495                    // Token staleness fix: Do a fresh GET to acquire new tokens before retry.
496                    // The PUT fails with NoClosestNodes when tokens expire (5min rotation).
497                    // get_signed_peers forces the DHT to issue fresh tokens for our IP I think?!
498                    tracing::debug!("announce_task: refreshing tokens via get_signed_peers");
499                    let mut stream = dht.get_signed_peers(id).await;
500                    let _ = stream.next().await;
501
502                    tokio::time::sleep(backoff).await;
503                    backoff = (backoff * 2).min(Duration::from_secs(60));
504                }
505                Err(_) => {
506                    tracing::warn!(
507                        "announce_task: DHT announce timed out, retrying in {backoff:?}"
508                    );
509                    tokio::time::sleep(backoff).await;
510                    backoff = (backoff * 2).min(Duration::from_secs(60));
511                }
512            }
513        }
514        tracing::info!("announce_task: stopped");
515    })
516}
517
518fn spawn_discovery_task(
519    state: Arc<DiscoveryState>,
520    dht: Arc<AsyncDht>,
521    gossip_sender: GossipSender,
522    topic_hash_32: [u8; 32],
523    config: TopicDiscoveryConfig,
524) -> tokio::task::JoinHandle<()> {
525    let my_key = config.signing_key.verifying_key().to_bytes();
526
527    tracing::info!("spawn_discovery_task: starting discovery task");
528    tokio::spawn(async move {
529        let mut round = 0u64;
530
531        let mut no_peer_backoff = config.discovery_interval_no_peers;
532        let backoff_increment = config.discovery_interval_no_peers;
533
534        let id = match dht::Id::from_bytes(topic_hash_20(&topic_hash_32)) {
535            Ok(id) => id,
536            Err(e) => {
537                tracing::error!("discovery_task: invalid topic hash: {e}");
538                return;
539            }
540        };
541
542        while !state.is_stopped() {
543            round = round.saturating_add(1);
544            tracing::debug!(
545                "discovery_task: round {round} starting (connected: {}, backoff: {:?})",
546                state.added_connection_count().await,
547                no_peer_backoff
548            );
549
550            tracing::debug!("discovery_task: querying DHT for peers");
551            let peers = collect_peers_with_timeout(
552                &dht,
553                id,
554                Duration::from_secs(30),
555                config.announce_interval,
556            )
557            .await;
558            tracing::debug!("discovery_task: found {} peers from DHT", peers.len());
559
560            let mut spawned: usize = 0;
561            for key_bytes in peers
562                .iter()
563                .take(config.max_peers_per_round.unwrap_or(usize::MAX))
564            {
565                if *key_bytes == my_key {
566                    continue;
567                }
568
569                if !state.should_attempt(*key_bytes).await {
570                    continue;
571                }
572
573                let Some(peer) = ed25519_dalek::VerifyingKey::from_bytes(key_bytes)
574                    .ok()
575                    .map(iroh::PublicKey::from_verifying_key)
576                else {
577                    continue;
578                };
579
580                spawn_connector(
581                    state.clone(),
582                    gossip_sender.clone(),
583                    peer,
584                    config.connection_timeout,
585                    config.endpoint.clone(),
586                );
587                spawned = spawned.saturating_add(1);
588            }
589
590            if spawned > 0 {
591                tracing::info!("discovery_task: spawned {spawned} connector tasks");
592            }
593
594            let has_connection = state.has_connections().await;
595            let interval = if has_connection {
596                no_peer_backoff = config.discovery_interval_no_peers;
597                if state.first_connected_phase(&config) {
598                    config.discovery_interval_first_connected
599                } else {
600                    config.discovery_interval
601                }
602            } else {
603                let current = no_peer_backoff;
604                no_peer_backoff =
605                    (no_peer_backoff + backoff_increment).min(config.discovery_interval);
606                current
607            };
608
609            tracing::debug!(
610                "discovery_task: sleeping for {:?} (has_connections: {}, next_backoff: {:?})",
611                interval,
612                has_connection,
613                no_peer_backoff
614            );
615            tokio::time::sleep(interval).await;
616            if !has_connection && state.has_connections().await {
617                let additional_interval = if state.first_connected_phase(&config) {
618                    config.discovery_interval_first_connected
619                } else {
620                    config.discovery_interval
621                }
622                .saturating_sub(interval);
623                no_peer_backoff = config.discovery_interval_no_peers;
624                tracing::debug!(
625                    "discovery_task: conn established during sleep, additional sleep for {:?} (has_connections: {}, next_backoff: {:?})",
626                    additional_interval,
627                    state.has_connections().await,
628                    no_peer_backoff
629                );
630                tokio::time::sleep(additional_interval).await;
631            }
632        }
633        tracing::info!("discovery_task: stopped");
634    })
635}
636
637async fn collect_peers_with_timeout(
638    dht: &AsyncDht,
639    id: dht::Id,
640    timeout: Duration,
641    announce_interval: Duration,
642) -> Vec<[u8; 32]> {
643    use futures_lite::StreamExt;
644
645    tracing::debug!("collect_peers_with_timeout: starting peer collection");
646    let mut stream = dht.get_signed_peers(id).await;
647    let deadline = tokio::time::Instant::now() + timeout;
648    let mut valid_items = Vec::new();
649    while let Ok(Some(items)) = tokio::time::timeout_at(deadline, stream.next()).await {
650        tracing::debug!(
651            "collect_peers_with_timeout: received batch of {} signed peers from DHT",
652            items.len()
653        );
654        for item in items {
655            let key_hex = hex::encode(&item.key()[..8]); // First 8 bytes for brevity
656            tracing::debug!(
657                "collect_peers_with_timeout: peer key={key_hex}... timestamp={}",
658                item.timestamp()
659            );
660
661            let now = std::time::SystemTime::now()
662                .duration_since(std::time::UNIX_EPOCH)
663                .unwrap_or_default()
664                .as_micros();
665
666            let max_age = announce_interval.as_micros() + 10_000_000; // announce + 10s buffer
667            let age = now.saturating_sub(item.timestamp() as u128);
668            if age > max_age {
669                tracing::debug!(
670                    "collect_peers_with_timeout: skipping stale peer {key_hex}... (age: {}ms, max: {}ms)",
671                    age / 1000,
672                    max_age / 1000
673                );
674                continue;
675            }
676
677            if !valid_items.contains(&item) {
678                valid_items.push(item);
679            }
680        }
681    }
682
683    valid_items.sort_by_key(|item| item.timestamp());
684    valid_items.reverse();
685    valid_items.dedup_by_key(|item| item.key().to_vec());
686
687    tracing::debug!(
688        "collect_peers_with_timeout: finished with {} peers",
689        valid_items.len()
690    );
691    valid_items.iter().map(|item| *item.key()).collect()
692}
693
694fn topic_hash_32(topic_bytes: &Vec<u8>) -> [u8; 32] {
695    let mut hasher = sha2::Sha512::new();
696    hasher.update("/iroh/topic-discovery/v2");
697    hasher.update(topic_bytes);
698    hasher.finalize()[..32].try_into().expect("hashing failed")
699}
700
701fn topic_hash_20(topic_hash_32: &[u8; 32]) -> [u8; 20] {
702    let mut hasher = sha2::Sha512::new();
703    hasher.update(topic_hash_32);
704    hasher.finalize()[..20].try_into().expect("hashing failed")
705}