Skip to main content

saorsa_core/
dht_lookup.rs

1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is licensed under the MIT license <LICENSE-MIT or
4// https://opensource.org/licenses/MIT> or the Apache License, Version 2.0
5// <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0>, at your
6// option. This file may not be copied, modified, or distributed except
7// according to those terms.
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under these licenses is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
13#![forbid(unsafe_code)]
14#![warn(missing_docs)]
15#![warn(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
16
17//! Transport-independent Kademlia iterative lookup scheduling.
18//!
19//! [`run_iterative_lookup`] drives an [`IterativeLookup`] through a generic
20//! [`LookupQuery`] adapter. Native QUIC and browser WebRTC therefore
21//! share the complete round loop, ordering, peer-state, capacity, and
22//! convergence implementation without either transport becoming a dependency
23//! of this module.
24
25use futures::{Stream, StreamExt, future::Either, future::select};
26use std::collections::{BTreeMap, HashMap, HashSet};
27use std::error::Error;
28use std::fmt;
29use std::future::{Future, ready};
30
31/// Default Kademlia bucket and lookup result count.
32pub const DEFAULT_K_VALUE: usize = 20;
33
34/// Default concurrent queries in one lookup round.
35pub const DEFAULT_ALPHA_VALUE: usize = 3;
36
37/// Additional wait for remaining queries after the first response in a round.
38/// Bounds slow dial cascades while allowing already-connected peers to reply.
39pub const ITERATION_GRACE_TIMEOUT_SECS: u64 = 5;
40
41/// Overall lookup budget shared by native and browser clients, in seconds.
42/// Request and round grace timeouts still apply inside this ceiling.
43pub const LOOKUP_TIMEOUT_SECS: u32 = 120;
44
45/// Canonical 256-bit DHT key or peer identity.
46pub type LookupKey = [u8; 32];
47
48/// A lookup candidate with a stable 256-bit peer identity.
49pub trait LookupNode: Clone {
50    /// Return the peer identity used for XOR ordering and deduplication.
51    fn lookup_peer_id(&self) -> LookupKey;
52}
53
54/// Iterative lookup limits shared by all transports.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct LookupConfig {
57    /// Number of closest successful responders returned. Zero completes without queries.
58    pub count: usize,
59    /// Maximum queries issued concurrently in one round.
60    pub alpha: usize,
61    /// Maximum number of query rounds.
62    pub max_iterations: usize,
63    /// Maximum queued candidates retained from untrusted network input.
64    pub max_candidates: usize,
65}
66
67impl LookupConfig {
68    /// Saorsa's standard Kademlia lookup limits for a requested result count.
69    #[must_use]
70    pub const fn saorsa(count: usize) -> Self {
71        Self {
72            count,
73            alpha: DEFAULT_ALPHA_VALUE,
74            max_iterations: 20,
75            max_candidates: 200,
76        }
77    }
78
79    fn validate(self) -> Result<Self, LookupError> {
80        if self.alpha == 0 {
81            return Err(LookupError::InvalidConfig(
82                "lookup alpha must be greater than zero",
83            ));
84        }
85        if self.max_iterations == 0 {
86            return Err(LookupError::InvalidConfig(
87                "lookup iteration limit must be greater than zero",
88            ));
89        }
90        if self.max_candidates == 0 {
91            return Err(LookupError::InvalidConfig(
92                "lookup candidate limit must be greater than zero",
93            ));
94        }
95        Ok(self)
96    }
97}
98
99/// Final reason an iterative lookup stopped.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum LookupTermination {
102    /// The complete closest-result set stopped changing and no queued peer
103    /// could improve it.
104    Converged,
105    /// No contactable candidates remain.
106    Exhausted,
107    /// The configured round limit was reached.
108    IterationLimit,
109    /// The overall deadline elapsed; in-flight queries were cancelled.
110    TimedOut,
111}
112
113/// Result of completing or attempting to begin a lookup round.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum LookupProgress {
116    /// The transport should begin or continue querying.
117    Continue,
118    /// The lookup has stopped for the supplied reason.
119    Finished(LookupTermination),
120}
121
122/// Result of inserting a candidate into the bounded closest-first queue.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum CandidateInsertion {
125    /// A new candidate was queued. When capacity was full, `evicted` identifies
126    /// the farther peer that was removed.
127    Inserted {
128        /// Farther peer removed to make room for this candidate.
129        evicted: Option<LookupKey>,
130    },
131    /// The existing queued representation for this peer was updated.
132    Replaced,
133    /// The peer already has a final state in this lookup.
134    AlreadyContacted,
135    /// The candidate was farther than every retained candidate at capacity.
136    TooFar,
137}
138
139/// Per-lookup state of a peer that has been contacted.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum LookupPeerState {
142    /// A query is currently in flight.
143    Waiting,
144    /// The peer returned a response.
145    Succeeded,
146    /// The query returned an explicit error.
147    Failed,
148    /// The transport abandoned the query after its round grace period.
149    Unresponsive,
150}
151
152/// State-machine misuse or invalid lookup configuration.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum LookupError {
155    /// A numeric lookup limit was zero.
156    InvalidConfig(&'static str),
157    /// Driver methods were called out of order.
158    InvalidState(&'static str),
159    /// A result was recorded for a peer that is not currently in flight.
160    PeerNotWaiting(LookupKey),
161}
162
163/// Result of one transport query in an iterative lookup batch.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum LookupQueryOutcome<N> {
166    /// The responder returned a valid response and zero or more validated
167    /// candidates.
168    Succeeded {
169        /// Peer that answered the query.
170        responder: LookupKey,
171        /// Candidates admitted by the transport's response validator.
172        candidates: Vec<N>,
173    },
174    /// The query completed with a transport or protocol failure.
175    Failed {
176        /// Peer whose query failed.
177        responder: LookupKey,
178    },
179    /// The transport abandoned the query after its batch grace period.
180    Unresponsive {
181        /// Peer whose query did not finish in time.
182        responder: LookupKey,
183    },
184}
185
186impl<N> LookupQueryOutcome<N> {
187    /// Peer this outcome belongs to.
188    #[must_use]
189    pub const fn responder(&self) -> &LookupKey {
190        match self {
191            Self::Succeeded { responder, .. }
192            | Self::Failed { responder }
193            | Self::Unresponsive { responder } => responder,
194        }
195    }
196}
197
198/// Transport and response-validation boundary used by the shared DHT walk.
199///
200/// Implementations receive a complete α-sized batch so they can execute its
201/// queries concurrently using transport-specific cancellation and timeout
202/// facilities. Only validated candidates should be returned to the engine.
203/// The shared runner may drop any pending adapter future at its overall
204/// deadline. Implementations must support cancellation without detached work.
205pub trait LookupQuery<N: LookupNode> {
206    /// Error returned by the transport adapter.
207    type Error;
208
209    /// Whether the current address view for a candidate is usable.
210    ///
211    /// Returning `false` discards this representation without assigning a
212    /// final peer state. A later response may therefore reintroduce the same
213    /// peer with a usable address.
214    fn is_candidate_eligible(
215        &mut self,
216        _candidate: &N,
217    ) -> impl Future<Output = Result<bool, Self::Error>> {
218        ready(Ok(true))
219    }
220
221    /// Execute one concurrent lookup batch and validate its responses.
222    ///
223    /// A missing outcome is treated as [`LookupQueryOutcome::Unresponsive`].
224    fn query_batch(
225        &mut self,
226        target: LookupKey,
227        count: usize,
228        iteration: usize,
229        batch: Vec<N>,
230    ) -> impl Future<Output = Result<Vec<LookupQueryOutcome<N>>, Self::Error>>;
231
232    /// Observe eviction of a farther queued candidate.
233    ///
234    /// Stateful validators can use this to release per-candidate evidence.
235    fn candidate_evicted(
236        &mut self,
237        _peer: LookupKey,
238    ) -> impl Future<Output = Result<(), Self::Error>> {
239        ready(Ok(()))
240    }
241}
242
243/// Collect a concurrent query batch without letting slow stragglers hold an
244/// iterative lookup round open indefinitely.
245///
246/// The first result is awaited without a grace deadline because the lookup
247/// cannot make progress until at least one query finishes. Use this collector
248/// inside [`run_iterative_lookup`] so its overall deadline bounds that wait.
249/// Once the first result arrives,
250/// `grace` is created and the remaining results are accepted only until that
251/// future completes. Dropping the supplied stream cancels any futures that are
252/// still pending.
253///
254/// Keeping the grace future runtime-agnostic lets native Tokio callers and
255/// browser WASM callers share this exact scheduling policy while supplying
256/// their platform's timer implementation.
257pub async fn collect_after_first_with_grace<S, F, G>(mut stream: S, grace: F) -> Vec<S::Item>
258where
259    S: Stream + Unpin,
260    F: FnOnce() -> G,
261    G: Future<Output = ()>,
262{
263    let mut results = Vec::new();
264    let Some(first) = stream.next().await else {
265        return results;
266    };
267    results.push(first);
268
269    let mut grace = Box::pin(grace());
270    while let Either::Left((Some(item), _)) = select(stream.next(), grace.as_mut()).await {
271        results.push(item);
272    }
273    results
274}
275
276/// Failure produced while the shared engine is driving a transport adapter.
277#[derive(Debug)]
278pub enum LookupRunError<E> {
279    /// The lookup state machine rejected an operation.
280    Lookup(LookupError),
281    /// The transport adapter failed the complete lookup.
282    Query(E),
283    /// The overall deadline elapsed before the lookup completed.
284    TimedOut,
285    /// An adapter returned an outcome for a peer outside the active batch or
286    /// returned more than one outcome for the same peer.
287    UnexpectedResponder(LookupKey),
288}
289
290impl<E: fmt::Display> fmt::Display for LookupRunError<E> {
291    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
292        match self {
293            Self::Lookup(error) => write!(formatter, "lookup state error: {error}"),
294            Self::Query(error) => write!(formatter, "lookup query error: {error}"),
295            Self::TimedOut => formatter.write_str("lookup deadline elapsed"),
296            Self::UnexpectedResponder(peer) => write!(
297                formatter,
298                "lookup adapter returned unexpected responder {}",
299                encode_hex(peer)
300            ),
301        }
302    }
303}
304
305impl<E: Error + 'static> Error for LookupRunError<E> {
306    fn source(&self) -> Option<&(dyn Error + 'static)> {
307        match self {
308            Self::Lookup(error) => Some(error),
309            Self::Query(error) => Some(error),
310            Self::UnexpectedResponder(_) | Self::TimedOut => None,
311        }
312    }
313}
314
315impl<E> From<LookupError> for LookupRunError<E> {
316    fn from(error: LookupError) -> Self {
317        Self::Lookup(error)
318    }
319}
320
321impl fmt::Display for LookupError {
322    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
323        match self {
324            Self::InvalidConfig(message) | Self::InvalidState(message) => {
325                formatter.write_str(message)
326            }
327            Self::PeerNotWaiting(peer) => {
328                write!(formatter, "peer {} is not waiting", encode_hex(peer))
329            }
330        }
331    }
332}
333
334impl Error for LookupError {}
335
336/// Pull-based, transport-independent Kademlia lookup state machine.
337#[derive(Debug)]
338pub struct IterativeLookup<N: LookupNode> {
339    target: LookupKey,
340    config: LookupConfig,
341    candidates: BTreeMap<(LookupKey, LookupKey), N>,
342    peer_states: HashMap<LookupKey, LookupPeerState>,
343    query_order: Vec<LookupKey>,
344    in_flight: HashMap<LookupKey, N>,
345    successful: HashMap<LookupKey, N>,
346    previous_top_k: Vec<LookupKey>,
347    iterations: usize,
348    round_active: bool,
349    round_queries: usize,
350    termination: Option<LookupTermination>,
351}
352
353impl<N: LookupNode> IterativeLookup<N> {
354    /// Construct an empty lookup for `target`.
355    pub fn new(target: LookupKey, config: LookupConfig) -> Result<Self, LookupError> {
356        Ok(Self {
357            target,
358            config: config.validate()?,
359            candidates: BTreeMap::new(),
360            peer_states: HashMap::new(),
361            query_order: Vec::new(),
362            in_flight: HashMap::new(),
363            successful: HashMap::new(),
364            previous_top_k: Vec::new(),
365            iterations: 0,
366            round_active: false,
367            round_queries: 0,
368            termination: (config.count == 0).then_some(LookupTermination::Converged),
369        })
370    }
371
372    /// Target key for this lookup.
373    #[must_use]
374    pub const fn target(&self) -> LookupKey {
375        self.target
376    }
377
378    /// Active lookup configuration.
379    #[must_use]
380    pub const fn config(&self) -> LookupConfig {
381        self.config
382    }
383
384    /// Number of rounds begun so far.
385    #[must_use]
386    pub const fn iterations(&self) -> usize {
387        self.iterations
388    }
389
390    /// Current terminal state, if the lookup has stopped.
391    #[must_use]
392    pub const fn termination(&self) -> Option<LookupTermination> {
393        self.termination
394    }
395
396    /// Whether a transport round is currently active.
397    #[must_use]
398    pub const fn round_active(&self) -> bool {
399        self.round_active
400    }
401
402    /// State currently assigned to a peer.
403    #[must_use]
404    pub fn peer_state(&self, peer: &LookupKey) -> Option<LookupPeerState> {
405        self.peer_states.get(peer).copied()
406    }
407
408    /// Whether the peer has never been contacted by this lookup.
409    #[must_use]
410    pub fn is_contactable(&self, peer: &LookupKey) -> bool {
411        !self.peer_states.contains_key(peer)
412    }
413
414    /// Peers selected for a transport query, in query order.
415    #[must_use]
416    pub fn queried_peers(&self) -> &[LookupKey] {
417        &self.query_order
418    }
419
420    /// Add a successful result that must never be queried, such as the local
421    /// node competing in a native lookup's final XOR ordering.
422    pub fn add_known_result(&mut self, node: N) {
423        let peer = node.lookup_peer_id();
424        self.candidates.retain(|(_, id), _| *id != peer);
425        self.peer_states.insert(peer, LookupPeerState::Succeeded);
426        self.successful.entry(peer).or_insert(node);
427    }
428
429    /// Add or update an unqueried candidate.
430    pub fn add_candidate(&mut self, node: N) -> CandidateInsertion {
431        let peer = node.lookup_peer_id();
432        if !self.is_contactable(&peer) {
433            return CandidateInsertion::AlreadyContacted;
434        }
435
436        let candidate_key = (xor_distance(&peer, &self.target), peer);
437        if let std::collections::btree_map::Entry::Occupied(mut entry) =
438            self.candidates.entry(candidate_key)
439        {
440            entry.insert(node);
441            return CandidateInsertion::Replaced;
442        }
443
444        if self.candidates.len() >= self.config.max_candidates {
445            let Some(farthest_key) = self.candidates.keys().next_back().copied() else {
446                return CandidateInsertion::TooFar;
447            };
448            if candidate_key >= farthest_key {
449                return CandidateInsertion::TooFar;
450            }
451            self.candidates.remove(&farthest_key);
452            self.candidates.insert(candidate_key, node);
453            return CandidateInsertion::Inserted {
454                evicted: Some(farthest_key.1),
455            };
456        }
457
458        self.candidates.insert(candidate_key, node);
459        CandidateInsertion::Inserted { evicted: None }
460    }
461
462    /// Begin the next α-query round.
463    pub fn begin_round(&mut self) -> Result<LookupProgress, LookupError> {
464        if self.round_active {
465            return Err(LookupError::InvalidState(
466                "cannot begin a lookup round while another round is active",
467            ));
468        }
469        if let Some(reason) = self.termination {
470            return Ok(LookupProgress::Finished(reason));
471        }
472        if self.iterations >= self.config.max_iterations {
473            return Ok(self.finish(LookupTermination::IterationLimit));
474        }
475        self.discard_contacted_candidates();
476        if self.candidates.is_empty() {
477            return Ok(self.finish(LookupTermination::Exhausted));
478        }
479
480        self.iterations += 1;
481        self.round_queries = 0;
482        self.round_active = true;
483        Ok(LookupProgress::Continue)
484    }
485
486    /// Remove and return the closest candidate for the active round.
487    ///
488    /// The driver must either call [`Self::mark_waiting`] for the returned
489    /// node or discard it as temporarily ineligible before asking for another.
490    pub fn take_next_candidate(&mut self) -> Result<Option<N>, LookupError> {
491        if !self.round_active {
492            return Err(LookupError::InvalidState(
493                "cannot select a candidate outside an active lookup round",
494            ));
495        }
496        if self.round_queries >= self.config.alpha {
497            return Ok(None);
498        }
499        self.discard_contacted_candidates();
500        Ok(self.candidates.pop_first().map(|(_, node)| node))
501    }
502
503    /// Mark a selected node as an in-flight query in the active round.
504    pub fn mark_waiting(&mut self, node: N) -> Result<(), LookupError> {
505        if !self.round_active {
506            return Err(LookupError::InvalidState(
507                "cannot start a query outside an active lookup round",
508            ));
509        }
510        if self.round_queries >= self.config.alpha {
511            return Err(LookupError::InvalidState(
512                "lookup round already reached its alpha limit",
513            ));
514        }
515        let peer = node.lookup_peer_id();
516        if !self.is_contactable(&peer) {
517            return Err(LookupError::InvalidState(
518                "cannot query a peer that already has lookup state",
519            ));
520        }
521        self.peer_states.insert(peer, LookupPeerState::Waiting);
522        self.query_order.push(peer);
523        self.in_flight.insert(peer, node);
524        self.round_queries += 1;
525        Ok(())
526    }
527
528    /// Record a successful response and retain the queried peer as a result.
529    pub fn record_success(&mut self, peer: &LookupKey) -> Result<(), LookupError> {
530        let node = self.take_waiting(peer)?;
531        self.peer_states.insert(*peer, LookupPeerState::Succeeded);
532        self.successful.entry(*peer).or_insert(node);
533        Ok(())
534    }
535
536    /// Record an explicit query or transport failure.
537    pub fn record_failure(&mut self, peer: &LookupKey) -> Result<(), LookupError> {
538        self.take_waiting(peer)?;
539        self.peer_states.insert(*peer, LookupPeerState::Failed);
540        Ok(())
541    }
542
543    /// Record a query abandoned after the transport's round grace period.
544    pub fn record_unresponsive(&mut self, peer: &LookupKey) -> Result<(), LookupError> {
545        self.take_waiting(peer)?;
546        self.peer_states
547            .insert(*peer, LookupPeerState::Unresponsive);
548        Ok(())
549    }
550
551    /// Peers still waiting in the active round, in XOR order.
552    #[must_use]
553    pub fn waiting_peers(&self) -> Vec<LookupKey> {
554        let mut peers = self.in_flight.keys().copied().collect::<Vec<_>>();
555        peers.sort_by_key(|peer| (xor_distance(peer, &self.target), *peer));
556        peers
557    }
558
559    /// Complete the current round and evaluate native Saorsa convergence.
560    pub fn complete_round(&mut self) -> Result<LookupProgress, LookupError> {
561        if !self.round_active {
562            return Err(LookupError::InvalidState(
563                "cannot complete a lookup round when none is active",
564            ));
565        }
566        if !self.in_flight.is_empty() {
567            return Err(LookupError::InvalidState(
568                "cannot complete a lookup round with queries still waiting",
569            ));
570        }
571        self.round_active = false;
572
573        let current_top_k = self.result_peer_ids();
574        if current_top_k == self.previous_top_k {
575            if current_top_k.len() < self.config.count && !self.candidates.is_empty() {
576                self.previous_top_k = current_top_k;
577                return Ok(LookupProgress::Continue);
578            }
579            let has_promising_candidate = self.has_promising_candidate();
580            if !has_promising_candidate {
581                return Ok(self.finish(LookupTermination::Converged));
582            }
583        }
584        self.previous_top_k = current_top_k;
585
586        if self.iterations >= self.config.max_iterations {
587            return Ok(self.finish(LookupTermination::IterationLimit));
588        }
589        self.discard_contacted_candidates();
590        if self.candidates.is_empty() {
591            return Ok(self.finish(LookupTermination::Exhausted));
592        }
593        Ok(LookupProgress::Continue)
594    }
595
596    /// Successful responders sorted closest-first and truncated to K.
597    #[must_use]
598    pub fn results(&self) -> Vec<N> {
599        let mut nodes = self.successful.values().cloned().collect::<Vec<_>>();
600        nodes.sort_by_key(|node| {
601            let peer = node.lookup_peer_id();
602            (xor_distance(&peer, &self.target), peer)
603        });
604        nodes.truncate(self.config.count);
605        nodes
606    }
607
608    fn take_waiting(&mut self, peer: &LookupKey) -> Result<N, LookupError> {
609        if self.peer_states.get(peer) != Some(&LookupPeerState::Waiting) {
610            return Err(LookupError::PeerNotWaiting(*peer));
611        }
612        self.in_flight
613            .remove(peer)
614            .ok_or(LookupError::PeerNotWaiting(*peer))
615    }
616
617    fn result_peer_ids(&self) -> Vec<LookupKey> {
618        self.results()
619            .into_iter()
620            .map(|node| node.lookup_peer_id())
621            .collect()
622    }
623
624    fn has_promising_candidate(&self) -> bool {
625        let Some(worst_result) = self.result_peer_ids().last().copied() else {
626            return !self.candidates.is_empty();
627        };
628        let worst_distance = xor_distance(&worst_result, &self.target);
629        self.candidates
630            .keys()
631            .next()
632            .is_some_and(|(distance, _)| *distance < worst_distance)
633    }
634
635    fn discard_contacted_candidates(&mut self) {
636        self.candidates
637            .retain(|(_, peer), _| !self.peer_states.contains_key(peer));
638    }
639
640    fn finish(&mut self, reason: LookupTermination) -> LookupProgress {
641        self.round_active = false;
642        self.termination = Some(reason);
643        LookupProgress::Finished(reason)
644    }
645}
646
647/// Run an iterative lookup to completion through a transport/query adapter.
648///
649/// This function owns the complete transport-independent walk: round
650/// creation, closest-first α selection, peer state transitions, admission of
651/// validated candidates, bounded-queue eviction, convergence, exhaustion,
652/// and the iteration limit. The adapter owns only address eligibility,
653/// concurrent request execution, and response validation.
654///
655/// `deadline` must complete when the overall budget elapses. Callers supply
656/// their runtime's timer using [`LOOKUP_TIMEOUT_SECS`]. The timer covers every
657/// adapter await, including eligibility checks and the first batch response.
658/// Expiration drops the walk and its pending adapter future, marks waiting
659/// peers unresponsive, and returns [`LookupRunError::TimedOut`]. Previously
660/// successful results remain available through [`IterativeLookup::results`].
661/// Like other async deadlines, this requires futures to yield when pending.
662pub async fn run_iterative_lookup<N, Q, D>(
663    lookup: &mut IterativeLookup<N>,
664    query: &mut Q,
665    deadline: D,
666) -> Result<LookupTermination, LookupRunError<Q::Error>>
667where
668    N: LookupNode,
669    Q: LookupQuery<N>,
670    D: Future<Output = ()>,
671{
672    if lookup.termination() == Some(LookupTermination::TimedOut) {
673        return Err(LookupRunError::TimedOut);
674    }
675    let result = {
676        let walk = Box::pin(run_lookup_rounds(lookup, query));
677        match select(walk, Box::pin(deadline)).await {
678            Either::Left((result, _)) => Some(result),
679            Either::Right(((), _walk)) => None,
680        }
681    };
682    match result {
683        Some(result) => result,
684        None => {
685            for (peer, _) in lookup.in_flight.drain() {
686                lookup
687                    .peer_states
688                    .insert(peer, LookupPeerState::Unresponsive);
689            }
690            lookup.finish(LookupTermination::TimedOut);
691            Err(LookupRunError::TimedOut)
692        }
693    }
694}
695
696async fn run_lookup_rounds<N, Q>(
697    lookup: &mut IterativeLookup<N>,
698    query: &mut Q,
699) -> Result<LookupTermination, LookupRunError<Q::Error>>
700where
701    N: LookupNode,
702    Q: LookupQuery<N>,
703{
704    loop {
705        match lookup.begin_round()? {
706            LookupProgress::Continue => {}
707            LookupProgress::Finished(reason) => return Ok(reason),
708        }
709
710        let mut batch = Vec::new();
711        while let Some(candidate) = lookup.take_next_candidate()? {
712            if query
713                .is_candidate_eligible(&candidate)
714                .await
715                .map_err(LookupRunError::Query)?
716            {
717                lookup.mark_waiting(candidate.clone())?;
718                batch.push(candidate);
719            }
720        }
721
722        if batch.is_empty() {
723            match lookup.complete_round()? {
724                LookupProgress::Continue => continue,
725                LookupProgress::Finished(reason) => return Ok(reason),
726            }
727        }
728
729        let mut awaiting = batch
730            .iter()
731            .map(LookupNode::lookup_peer_id)
732            .collect::<HashSet<_>>();
733        let outcomes = query
734            .query_batch(
735                lookup.target(),
736                lookup.config().count,
737                lookup.iterations(),
738                batch,
739            )
740            .await
741            .map_err(LookupRunError::Query)?;
742
743        for outcome in outcomes {
744            let responder = *outcome.responder();
745            if !awaiting.remove(&responder) {
746                return Err(LookupRunError::UnexpectedResponder(responder));
747            }
748            match outcome {
749                LookupQueryOutcome::Succeeded {
750                    responder,
751                    candidates,
752                } => {
753                    lookup.record_success(&responder)?;
754                    for candidate in candidates {
755                        if !query
756                            .is_candidate_eligible(&candidate)
757                            .await
758                            .map_err(LookupRunError::Query)?
759                        {
760                            continue;
761                        }
762                        if let CandidateInsertion::Inserted {
763                            evicted: Some(evicted),
764                        } = lookup.add_candidate(candidate)
765                        {
766                            query
767                                .candidate_evicted(evicted)
768                                .await
769                                .map_err(LookupRunError::Query)?;
770                        }
771                    }
772                }
773                LookupQueryOutcome::Failed { responder } => {
774                    lookup.record_failure(&responder)?;
775                }
776                LookupQueryOutcome::Unresponsive { responder } => {
777                    lookup.record_unresponsive(&responder)?;
778                }
779            }
780        }
781
782        for responder in awaiting {
783            lookup.record_unresponsive(&responder)?;
784        }
785
786        match lookup.complete_round()? {
787            LookupProgress::Continue => {}
788            LookupProgress::Finished(reason) => return Ok(reason),
789        }
790    }
791}
792
793/// Compute the unsigned 256-bit XOR distance used for Kademlia ordering.
794#[must_use]
795pub fn xor_distance(left: &LookupKey, right: &LookupKey) -> LookupKey {
796    let mut distance = [0u8; 32];
797    for (index, output) in distance.iter_mut().enumerate() {
798        *output = left[index] ^ right[index];
799    }
800    distance
801}
802
803fn encode_hex(bytes: &[u8]) -> String {
804    const HEX: &[u8; 16] = b"0123456789abcdef";
805    let mut output = String::with_capacity(bytes.len() * 2);
806    for byte in bytes {
807        output.push(char::from(HEX[usize::from(byte >> 4)]));
808        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
809    }
810    output
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816    use futures::stream::FuturesUnordered;
817    use std::convert::Infallible;
818    use std::pin::Pin;
819
820    #[derive(Debug, Clone, PartialEq, Eq)]
821    struct Node(LookupKey);
822
823    impl LookupNode for Node {
824        fn lookup_peer_id(&self) -> LookupKey {
825            self.0
826        }
827    }
828
829    fn node(last: u8) -> Node {
830        let mut peer = [0; 32];
831        peer[31] = last;
832        Node(peer)
833    }
834
835    fn peer(last: u8) -> LookupKey {
836        node(last).0
837    }
838
839    fn start_batch(lookup: &mut IterativeLookup<Node>) -> Vec<Node> {
840        assert_eq!(
841            lookup.begin_round().expect("begin round"),
842            LookupProgress::Continue
843        );
844        let mut batch = Vec::new();
845        while let Some(candidate) = lookup.take_next_candidate().expect("take candidate") {
846            lookup
847                .mark_waiting(candidate.clone())
848                .expect("mark waiting");
849            batch.push(candidate);
850        }
851        batch
852    }
853
854    #[test]
855    fn orders_batches_by_xor_distance_and_enforces_alpha() {
856        let mut lookup =
857            IterativeLookup::new([0; 32], LookupConfig::saorsa(20)).expect("valid lookup");
858        for id in [9, 1, 7, 2, 3] {
859            lookup.add_candidate(node(id));
860        }
861
862        let batch = start_batch(&mut lookup);
863        assert_eq!(batch, vec![node(1), node(2), node(3)]);
864    }
865
866    #[test]
867    fn failed_and_unresponsive_peers_cannot_be_reintroduced() {
868        let mut lookup =
869            IterativeLookup::new([0; 32], LookupConfig::saorsa(3)).expect("valid lookup");
870        lookup.add_candidate(node(1));
871        lookup.add_candidate(node(2));
872        let batch = start_batch(&mut lookup);
873        lookup.record_failure(&batch[0].0).expect("record failure");
874        lookup
875            .record_unresponsive(&batch[1].0)
876            .expect("record timeout");
877
878        assert_eq!(
879            lookup.add_candidate(node(1)),
880            CandidateInsertion::AlreadyContacted
881        );
882        assert_eq!(
883            lookup.add_candidate(node(2)),
884            CandidateInsertion::AlreadyContacted
885        );
886        assert!(!lookup.is_contactable(&peer(1)));
887        assert!(!lookup.is_contactable(&peer(2)));
888    }
889
890    #[test]
891    fn bounded_queue_evicts_only_a_farther_candidate() {
892        let config = LookupConfig {
893            max_candidates: 2,
894            ..LookupConfig::saorsa(2)
895        };
896        let mut lookup = IterativeLookup::new([0; 32], config).expect("valid lookup");
897        lookup.add_candidate(node(10));
898        lookup.add_candidate(node(20));
899        assert_eq!(
900            lookup.add_candidate(node(5)),
901            CandidateInsertion::Inserted {
902                evicted: Some(peer(20))
903            }
904        );
905        assert_eq!(lookup.add_candidate(node(30)), CandidateInsertion::TooFar);
906    }
907
908    #[test]
909    fn runs_a_multi_round_lookup_to_exhaustion() {
910        let mut lookup =
911            IterativeLookup::new([0; 32], LookupConfig::saorsa(3)).expect("valid lookup");
912        for id in [30, 40, 50] {
913            lookup.add_candidate(node(id));
914        }
915
916        let first = start_batch(&mut lookup);
917        for candidate in first {
918            lookup.record_success(&candidate.0).expect("record success");
919        }
920        lookup.add_candidate(node(10));
921        lookup.add_candidate(node(20));
922        assert_eq!(
923            lookup.complete_round().expect("complete first"),
924            LookupProgress::Continue
925        );
926
927        let second = start_batch(&mut lookup);
928        assert_eq!(second, vec![node(10), node(20)]);
929        for candidate in second {
930            lookup.record_success(&candidate.0).expect("record success");
931        }
932        assert_eq!(
933            lookup.complete_round().expect("complete second"),
934            LookupProgress::Finished(LookupTermination::Exhausted)
935        );
936        assert_eq!(lookup.results(), vec![node(10), node(20), node(30)]);
937    }
938
939    #[test]
940    fn unchanged_top_k_converges_when_only_farther_candidates_remain() {
941        let config = LookupConfig {
942            alpha: 1,
943            ..LookupConfig::saorsa(1)
944        };
945        let mut lookup = IterativeLookup::new([0; 32], config).expect("valid lookup");
946        lookup.add_candidate(node(10));
947        lookup.add_candidate(node(30));
948        lookup.add_candidate(node(40));
949
950        let first = start_batch(&mut lookup);
951        lookup.record_success(&first[0].0).expect("record success");
952        assert_eq!(
953            lookup.complete_round().expect("complete first"),
954            LookupProgress::Continue
955        );
956
957        let second = start_batch(&mut lookup);
958        lookup.record_success(&second[0].0).expect("record success");
959        assert_eq!(
960            lookup.complete_round().expect("complete second"),
961            LookupProgress::Finished(LookupTermination::Converged)
962        );
963        assert_eq!(lookup.results(), vec![node(10)]);
964    }
965
966    #[derive(Default)]
967    struct MockQuery {
968        batches: Vec<Vec<LookupKey>>,
969    }
970
971    impl LookupQuery<Node> for MockQuery {
972        type Error = Infallible;
973
974        fn query_batch(
975            &mut self,
976            _target: LookupKey,
977            _count: usize,
978            iteration: usize,
979            batch: Vec<Node>,
980        ) -> impl Future<Output = Result<Vec<LookupQueryOutcome<Node>>, Self::Error>> {
981            self.batches
982                .push(batch.iter().map(LookupNode::lookup_peer_id).collect());
983            let outcomes = if iteration == 1 {
984                vec![
985                    LookupQueryOutcome::Succeeded {
986                        responder: peer(1),
987                        candidates: vec![node(0)],
988                    },
989                    LookupQueryOutcome::Failed { responder: peer(2) },
990                ]
991            } else {
992                batch
993                    .into_iter()
994                    .map(|candidate| LookupQueryOutcome::Succeeded {
995                        responder: candidate.lookup_peer_id(),
996                        candidates: Vec::new(),
997                    })
998                    .collect()
999            };
1000            ready(Ok(outcomes))
1001        }
1002    }
1003
1004    #[test]
1005    fn shared_runner_owns_rounds_and_drives_query_batches() {
1006        let config = LookupConfig {
1007            alpha: 2,
1008            ..LookupConfig::saorsa(2)
1009        };
1010        let mut lookup = IterativeLookup::new([0; 32], config).expect("valid lookup");
1011        for id in [3, 1, 2] {
1012            lookup.add_candidate(node(id));
1013        }
1014        let mut query = MockQuery::default();
1015
1016        let reason = futures::executor::block_on(run_iterative_lookup(
1017            &mut lookup,
1018            &mut query,
1019            std::future::pending(),
1020        ))
1021        .expect("run lookup");
1022
1023        assert_eq!(reason, LookupTermination::Exhausted);
1024        assert_eq!(
1025            query.batches,
1026            vec![vec![peer(1), peer(2)], vec![peer(0), peer(3)]]
1027        );
1028        assert_eq!(lookup.results(), vec![node(0), node(1)]);
1029    }
1030
1031    struct MissingOutcomeQuery;
1032
1033    impl LookupQuery<Node> for MissingOutcomeQuery {
1034        type Error = Infallible;
1035
1036        fn query_batch(
1037            &mut self,
1038            _target: LookupKey,
1039            _count: usize,
1040            _iteration: usize,
1041            batch: Vec<Node>,
1042        ) -> impl Future<Output = Result<Vec<LookupQueryOutcome<Node>>, Self::Error>> {
1043            ready(Ok(batch
1044                .first()
1045                .map(|candidate| LookupQueryOutcome::Succeeded {
1046                    responder: candidate.lookup_peer_id(),
1047                    candidates: Vec::new(),
1048                })
1049                .into_iter()
1050                .collect()))
1051        }
1052    }
1053
1054    #[test]
1055    fn shared_runner_marks_missing_batch_outcomes_unresponsive() {
1056        let config = LookupConfig {
1057            alpha: 2,
1058            ..LookupConfig::saorsa(2)
1059        };
1060        let mut lookup = IterativeLookup::new([0; 32], config).expect("valid lookup");
1061        lookup.add_candidate(node(1));
1062        lookup.add_candidate(node(2));
1063
1064        futures::executor::block_on(run_iterative_lookup(
1065            &mut lookup,
1066            &mut MissingOutcomeQuery,
1067            std::future::pending(),
1068        ))
1069        .expect("run lookup");
1070
1071        assert_eq!(
1072            lookup.peer_state(&peer(1)),
1073            Some(LookupPeerState::Succeeded)
1074        );
1075        assert_eq!(
1076            lookup.peer_state(&peer(2)),
1077            Some(LookupPeerState::Unresponsive)
1078        );
1079    }
1080
1081    #[test]
1082    fn grace_collector_cancels_pending_stragglers_after_first_result() {
1083        let queries: FuturesUnordered<Pin<Box<dyn Future<Output = u8>>>> = FuturesUnordered::new();
1084        queries.push(Box::pin(ready(7)));
1085        queries.push(Box::pin(std::future::pending()));
1086
1087        let results =
1088            futures::executor::block_on(collect_after_first_with_grace(queries, || ready(())));
1089
1090        assert_eq!(results, vec![7]);
1091    }
1092
1093    #[test]
1094    fn grace_collector_keeps_completed_batch_results() {
1095        let queries = futures::stream::iter([1_u8, 2_u8, 3_u8]);
1096
1097        let results = futures::executor::block_on(collect_after_first_with_grace(queries, || {
1098            std::future::pending::<()>()
1099        }));
1100
1101        assert_eq!(results, vec![1, 2, 3]);
1102    }
1103
1104    #[test]
1105    fn zero_count_completes_without_querying_even_with_candidates() {
1106        let mut lookup = IterativeLookup::new([0; 32], LookupConfig::saorsa(0)).unwrap();
1107        lookup.add_known_result(node(1));
1108        lookup.add_candidate(node(2));
1109        let mut query = MockQuery::default();
1110        let reason = futures::executor::block_on(run_iterative_lookup(
1111            &mut lookup,
1112            &mut query,
1113            std::future::pending(),
1114        ))
1115        .unwrap();
1116
1117        assert_eq!(reason, LookupTermination::Converged);
1118        assert!(lookup.results().is_empty());
1119        assert!(lookup.queried_peers().is_empty());
1120        assert!(query.batches.is_empty());
1121        assert_eq!(lookup.iterations(), 0);
1122    }
1123
1124    struct StalledQuery<'a> {
1125        eligibility_stalls: bool,
1126        cancelled: &'a std::cell::Cell<bool>,
1127    }
1128
1129    struct CancellationFlag<'a>(&'a std::cell::Cell<bool>);
1130
1131    impl Drop for CancellationFlag<'_> {
1132        fn drop(&mut self) {
1133            self.0.set(true);
1134        }
1135    }
1136
1137    impl LookupQuery<Node> for StalledQuery<'_> {
1138        type Error = Infallible;
1139
1140        async fn is_candidate_eligible(&mut self, _: &Node) -> Result<bool, Self::Error> {
1141            if self.eligibility_stalls {
1142                let _flag = CancellationFlag(self.cancelled);
1143                std::future::pending::<()>().await;
1144            }
1145            Ok(true)
1146        }
1147
1148        async fn query_batch(
1149            &mut self,
1150            _: LookupKey,
1151            _: usize,
1152            _: usize,
1153            _: Vec<Node>,
1154        ) -> Result<Vec<LookupQueryOutcome<Node>>, Self::Error> {
1155            let _flag = CancellationFlag(self.cancelled);
1156            // No first response ever arrives: the round grace timer cannot help.
1157            let queries = futures::stream::pending::<LookupQueryOutcome<Node>>();
1158            Ok(collect_after_first_with_grace(queries, || ready(())).await)
1159        }
1160    }
1161
1162    #[test]
1163    fn deadline_cancels_stalled_eligibility_and_all_pending_batches() {
1164        for eligibility_stalls in [true, false] {
1165            let mut lookup = IterativeLookup::new([0; 32], LookupConfig::saorsa(2)).unwrap();
1166            lookup.add_known_result(node(0));
1167            lookup.add_candidate(node(1));
1168            let cancelled = std::cell::Cell::new(false);
1169            let mut query = StalledQuery {
1170                eligibility_stalls,
1171                cancelled: &cancelled,
1172            };
1173            let result = futures::executor::block_on(run_iterative_lookup(
1174                &mut lookup,
1175                &mut query,
1176                ready(()),
1177            ));
1178            assert!(matches!(result, Err(LookupRunError::TimedOut)));
1179            assert!(cancelled.get());
1180            assert_eq!(lookup.termination(), Some(LookupTermination::TimedOut));
1181            assert!(!lookup.round_active());
1182            assert!(lookup.in_flight.is_empty());
1183            assert_eq!(lookup.results(), vec![node(0)]);
1184            if !eligibility_stalls {
1185                assert_eq!(
1186                    lookup.peer_state(&peer(1)),
1187                    Some(LookupPeerState::Unresponsive)
1188                );
1189            }
1190        }
1191    }
1192
1193    #[test]
1194    fn rejects_zero_limits() {
1195        let invalid = LookupConfig {
1196            alpha: 0,
1197            ..LookupConfig::saorsa(1)
1198        };
1199        assert!(matches!(
1200            IterativeLookup::<Node>::new([0; 32], invalid),
1201            Err(LookupError::InvalidConfig(_))
1202        ));
1203    }
1204}