Skip to main content

fips_core/node/
route_impl.rs

1use super::*;
2
3pub(in crate::node) enum TransitNextHopPlan {
4    Route(NodeAddr),
5    Loop(NodeAddr),
6    NoRoute,
7}
8
9impl Node {
10    // === Routing ===
11
12    pub(in crate::node) fn cache_current_root_coords(
13        &mut self,
14        node_addr: NodeAddr,
15        coords: crate::tree::TreeCoordinate,
16        now_ms: u64,
17    ) -> bool {
18        if coords.node_addr() != &node_addr
19            || coords.root_id() != self.tree_state.my_coords().root_id()
20        {
21            return false;
22        }
23        self.coord_cache.insert(node_addr, coords, now_ms);
24        true
25    }
26
27    /// Check if a peer is a tree neighbor (parent or child in the spanning tree).
28    ///
29    /// Returns true if the peer is our current tree parent, or if the peer
30    /// has declared us as their parent (making them our child).
31    pub(crate) fn is_tree_peer(&self, peer_addr: &NodeAddr) -> bool {
32        // Peer is our parent
33        if !self.tree_state.is_root() && self.tree_state.my_declaration().parent_id() == peer_addr {
34            return true;
35        }
36        // Peer is our child (their declaration names us as parent)
37        if let Some(decl) = self.tree_state.peer_declaration(peer_addr)
38            && decl.parent_id() == self.node_addr()
39        {
40            return true;
41        }
42        false
43    }
44
45    /// Find next hop for a destination node address.
46    ///
47    /// Routing priority:
48    /// 1. Destination is self → `None` (local delivery)
49    /// 2. Destination is a healthy direct peer → that peer. A known fallback
50    ///    next-hop may beat a non-static direct path when it has a meaningful
51    ///    link-quality advantage; operator-configured static UDP peers stay
52    ///    pinned to direct while healthy and endpoint traffic is getting
53    ///    authenticated return traffic.
54    /// 3. Reply-learned routes in `reply_learned` mode. These are locally
55    ///    observed reverse paths, selected with weighted multipath plus
56    ///    periodic coordinate/tree exploration.
57    /// 4. Bloom filter candidates with cached dest coords → among peers whose
58    ///    bloom filter contains the destination, pick the one that minimizes
59    ///    tree distance to the destination, with
60    ///    `(link_cost, tree_distance_to_dest, node_addr)` tie-breaking.
61    ///    The self-distance check ensures only peers strictly closer to the
62    ///    destination than us are considered (prevents routing loops).
63    /// 5. Greedy tree routing fallback (requires cached dest coords)
64    /// 6. No route → `None`
65    ///
66    /// Both the bloom filter and tree routing paths require cached destination
67    /// coordinates (checked in `coord_cache`). Without coordinates, the node
68    /// cannot make loop-free forwarding decisions. The caller should signal
69    /// `CoordsRequired` back to the source when `None` is returned for a
70    /// non-local destination.
71    pub fn find_next_hop(&mut self, dest_node_addr: &NodeAddr) -> Option<&ActivePeer> {
72        // 1. Local delivery
73        if dest_node_addr == self.node_addr() {
74            return None;
75        }
76        let now_ms = Self::now_ms();
77        let direct_path_hard_degraded =
78            self.session_direct_path_is_degraded(dest_node_addr, now_ms);
79        let direct_path_soft_degraded = !direct_path_hard_degraded
80            && self.session_direct_discovered_endpoint_trust_expired(dest_node_addr, now_ms);
81        let fallback_peer_available = self.has_sendable_fallback_lookup_peer(dest_node_addr);
82        let direct_session_degraded =
83            fallback_peer_available && (direct_path_hard_degraded || direct_path_soft_degraded);
84        let direct_session_untrusted = !direct_session_degraded
85            && self.session_direct_path_exclusive_trust_expired(dest_node_addr, now_ms);
86        let stale_traversal_direct_route = self
87            .peers
88            .get(dest_node_addr)
89            .filter(|peer| {
90                !direct_path_hard_degraded
91                    && !direct_session_degraded
92                    && !peer.is_healthy()
93                    && peer.can_send()
94            })
95            .and_then(|_| {
96                self.configured_peer(dest_node_addr)
97                    .and_then(|peer_config| {
98                        (peer_config.is_auto_connect()
99                            && self.active_peer_uses_traversal_path(dest_node_addr, peer_config))
100                        .then_some(*dest_node_addr)
101                    })
102            });
103
104        let healthy_direct_route = self
105            .peers
106            .get(dest_node_addr)
107            .filter(|peer| peer.is_healthy() && !direct_session_degraded)
108            .map(|_| *dest_node_addr);
109        let direct_session_has_recent_data_return =
110            self.session_direct_path_has_recent_data_return(dest_node_addr, now_ms);
111        if let Some(direct_addr) = healthy_direct_route
112            && direct_session_has_recent_data_return
113        {
114            return self.peers.get(&direct_addr);
115        }
116        if let Some(direct_addr) = healthy_direct_route
117            && !direct_session_untrusted
118            && self.dataplane_fmp_link_cost(&direct_addr)
119                <= 1.0 + ROUTING_FALLBACK_MIN_COST_ADVANTAGE
120        {
121            return self.peers.get(&direct_addr);
122        }
123        let direct_payload_eligible = healthy_direct_route.is_some();
124        let payload_candidate_can_send = |addr: &NodeAddr, peer: &ActivePeer| {
125            if addr == dest_node_addr {
126                direct_payload_eligible
127            } else {
128                peer.is_healthy()
129            }
130        };
131
132        // A healthy direct path is not automatically the best path. A
133        // hotspot/NAT hairpin can remain sendable with high RTT or mild loss;
134        // in that case a lower-cost mesh next-hop should carry traffic while
135        // direct probes continue in the background.
136        let fallback_beats_direct = |node: &Self, fallback_addr: NodeAddr| {
137            if direct_session_untrusted {
138                return healthy_direct_route != Some(fallback_addr)
139                    && node
140                        .peers
141                        .get(&fallback_addr)
142                        .is_some_and(|peer| peer.is_healthy());
143            }
144            node.route_candidate_beats_direct(healthy_direct_route, fallback_addr)
145        };
146
147        let sendable_learned_peers = if self.config.node.routing.mode == RoutingMode::ReplyLearned {
148            Some(
149                self.peers
150                    .iter()
151                    .filter(|(addr, peer)| payload_candidate_can_send(addr, peer))
152                    .map(|(addr, _)| *addr)
153                    .collect::<HashSet<_>>(),
154            )
155        } else {
156            None
157        };
158
159        let explore_fallback = sendable_learned_peers.as_ref().is_some_and(|sendable| {
160            self.learned_routes.should_explore_fallback(
161                dest_node_addr,
162                now_ms,
163                self.config.node.routing.learned_fallback_explore_interval,
164                |addr| sendable.contains(addr),
165            )
166        });
167        if let Some(next_hop_addr) = sendable_learned_peers.as_ref().and_then(|sendable| {
168            let activity = self.dataplane.fsp_owner_activity(dest_node_addr)?;
169            let next_hop_addr = activity.last_outbound_next_hop()?;
170            if next_hop_addr == *dest_node_addr
171                || !activity.has_recent_outbound_activity(
172                    now_ms,
173                    self.session_direct_path_exclusive_trust_timeout_ms(),
174                )
175                || !sendable.contains(&next_hop_addr)
176                || !fallback_beats_direct(self, next_hop_addr)
177            {
178                return None;
179            }
180            Some(next_hop_addr)
181        }) {
182            self.learned_routes
183                .record_selected(dest_node_addr, &next_hop_addr, now_ms);
184            return self.peers.get(&next_hop_addr);
185        }
186
187        // 3. Optional reply-learned routing. These entries are not peer
188        // claims; they are local observations of which peer carried traffic
189        // or a verified lookup response back from the destination. Most
190        // packets use weighted multipath over learned routes, but periodic
191        // fallback exploration lets coord/bloom/tree routes discover better
192        // candidates.
193        if let Some(sendable) = &sendable_learned_peers
194            && !explore_fallback
195        {
196            let eligible = sendable
197                .iter()
198                .copied()
199                .filter(|addr| fallback_beats_direct(self, *addr))
200                .collect::<HashSet<_>>();
201            if !eligible.is_empty()
202                && let Some(next_hop_addr) =
203                    self.learned_routes
204                        .select_next_hop(dest_node_addr, now_ms, |addr| eligible.contains(addr))
205            {
206                return self.peers.get(&next_hop_addr);
207            }
208        }
209
210        // Look up cached destination coordinates (required by both bloom and tree paths).
211        let Some(dest_coords) = self
212            .coord_cache
213            .get_and_touch(dest_node_addr, now_ms)
214            .cloned()
215        else {
216            if (healthy_direct_route.is_none() || explore_fallback)
217                && let Some(sendable) = &sendable_learned_peers
218                && let Some(next_hop_addr) =
219                    self.learned_routes
220                        .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
221            {
222                return self.peers.get(&next_hop_addr);
223            }
224            if let Some(direct_addr) = healthy_direct_route {
225                return self.peers.get(&direct_addr);
226            }
227            if let Some(direct_addr) = stale_traversal_direct_route {
228                return self.peers.get(&direct_addr);
229            }
230            return None;
231        };
232
233        // 4. Bloom filter candidates — requires dest_coords for loop-free selection.
234        //    If no candidate is strictly closer, fall through to tree routing.
235        let coordinate_route_addr = {
236            let candidates: Vec<&ActivePeer> = self
237                .peers
238                .iter()
239                .filter(|(addr, peer)| {
240                    payload_candidate_can_send(addr, peer) && peer.may_reach(dest_node_addr)
241                })
242                .map(|(_, peer)| peer)
243                .collect();
244            if !candidates.is_empty() {
245                self.select_best_candidate(&candidates, &dest_coords)
246                    .map(|peer| *peer.node_addr())
247            } else {
248                None
249            }
250        };
251        if let Some(next_hop_addr) = coordinate_route_addr
252            && fallback_beats_direct(self, next_hop_addr)
253        {
254            return self.peers.get(&next_hop_addr);
255        }
256
257        // 5. Greedy tree routing fallback
258        let tree_route_addr = self.select_tree_payload_candidate(
259            &dest_coords,
260            dest_node_addr,
261            direct_payload_eligible,
262        );
263        if let Some(next_hop_addr) = tree_route_addr
264            && fallback_beats_direct(self, next_hop_addr)
265        {
266            return self.peers.get(&next_hop_addr);
267        }
268
269        if explore_fallback
270            && let Some(peer) = sendable_learned_peers.as_ref().and_then(|sendable| {
271                self.learned_routes
272                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
273                    .and_then(|next_hop_addr| self.peers.get(&next_hop_addr))
274            })
275        {
276            return Some(peer);
277        }
278
279        if let Some(direct_addr) = healthy_direct_route {
280            return self.peers.get(&direct_addr);
281        }
282
283        if let Some(sendable) = &sendable_learned_peers
284            && let Some(next_hop_addr) =
285                self.learned_routes
286                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
287        {
288            return self.peers.get(&next_hop_addr);
289        }
290
291        if let Some(direct_addr) = stale_traversal_direct_route {
292            return self.peers.get(&direct_addr);
293        }
294
295        None
296    }
297
298    pub(in crate::node) fn plan_transit_next_hop(
299        &mut self,
300        dest_node_addr: &NodeAddr,
301        previous_hop: &NodeAddr,
302    ) -> TransitNextHopPlan {
303        if dest_node_addr == self.node_addr() {
304            return TransitNextHopPlan::NoRoute;
305        }
306
307        if dest_node_addr != previous_hop
308            && self
309                .peers
310                .get(dest_node_addr)
311                .is_some_and(|peer| peer.is_healthy())
312        {
313            return TransitNextHopPlan::Route(*dest_node_addr);
314        }
315
316        // A forwarded LookupResponse proves this direction of the transit
317        // path. Keep an established encrypted flow on that learned path while
318        // it is live; origin-side route exploration must not spray transit
319        // records into an unproven branch. Failure handling removes/decays the
320        // route and the ordinary coordinate/tree fallback remains below.
321        if self.config.node.routing.mode == RoutingMode::ReplyLearned {
322            let sendable = self
323                .peers
324                .iter()
325                .filter(|(addr, peer)| *addr != previous_hop && peer.is_healthy())
326                .map(|(addr, _)| *addr)
327                .collect::<HashSet<_>>();
328            if let Some(next_hop_addr) =
329                self.learned_routes
330                    .select_handshake_route(dest_node_addr, Self::now_ms(), |addr| {
331                        sendable.contains(addr)
332                    })
333            {
334                return TransitNextHopPlan::Route(next_hop_addr);
335            }
336            if let Some(next_hop_addr) =
337                self.learned_routes
338                    .select_next_hop(dest_node_addr, Self::now_ms(), |addr| {
339                        sendable.contains(addr)
340                    })
341            {
342                return TransitNextHopPlan::Route(next_hop_addr);
343            }
344        }
345
346        let Some(next_hop_addr) = self
347            .find_next_hop(dest_node_addr)
348            .map(|peer| *peer.node_addr())
349        else {
350            return TransitNextHopPlan::NoRoute;
351        };
352        if next_hop_addr == *dest_node_addr && &next_hop_addr != previous_hop {
353            return TransitNextHopPlan::Route(next_hop_addr);
354        }
355
356        let now_ms = Self::now_ms();
357        let dest_coords = self
358            .coord_cache
359            .get_and_touch(dest_node_addr, now_ms)
360            .cloned();
361        let selected_strictly_progresses = dest_coords.as_ref().is_some_and(|dest_coords| {
362            self.tree_state.my_coords().root_id() == dest_coords.root_id()
363                && self
364                    .tree_state
365                    .peer_coords(&next_hop_addr)
366                    .is_some_and(|peer_coords| {
367                        peer_coords.distance_to(dest_coords)
368                            < self.tree_state.my_coords().distance_to(dest_coords)
369                    })
370        });
371
372        if &next_hop_addr != previous_hop && (dest_coords.is_none() || selected_strictly_progresses)
373        {
374            return TransitNextHopPlan::Route(next_hop_addr);
375        }
376
377        let coordinate_fallback = dest_coords.and_then(|dest_coords| {
378            self.select_tree_payload_candidate_avoiding(
379                &dest_coords,
380                dest_node_addr,
381                false,
382                Some(previous_hop),
383            )
384        });
385        if let Some(next_hop_addr) = coordinate_fallback {
386            return TransitNextHopPlan::Route(next_hop_addr);
387        }
388        TransitNextHopPlan::Loop(next_hop_addr)
389    }
390
391    #[cfg(test)]
392    pub(in crate::node) fn find_transit_next_hop(
393        &mut self,
394        dest_node_addr: &NodeAddr,
395        previous_hop: &NodeAddr,
396    ) -> Option<NodeAddr> {
397        match self.plan_transit_next_hop(dest_node_addr, previous_hop) {
398            TransitNextHopPlan::Route(next_hop_addr) => Some(next_hop_addr),
399            TransitNextHopPlan::Loop(next_hop_addr) => {
400                self.record_route_failure(*dest_node_addr, next_hop_addr);
401                None
402            }
403            TransitNextHopPlan::NoRoute => None,
404        }
405    }
406
407    pub(super) fn route_candidate_beats_direct(
408        &self,
409        healthy_direct_route: Option<NodeAddr>,
410        candidate_addr: NodeAddr,
411    ) -> bool {
412        let Some(direct_addr) = healthy_direct_route else {
413            return true;
414        };
415        if candidate_addr == direct_addr {
416            return false;
417        }
418
419        if !self.peers.contains_key(&direct_addr) {
420            return true;
421        }
422        if self.active_peer_uses_configured_static_udp_path(&direct_addr) {
423            return false;
424        }
425        let Some(candidate) = self.peers.get(&candidate_addr) else {
426            return false;
427        };
428        if !candidate.is_healthy() {
429            return false;
430        }
431
432        let direct_cost = self.dataplane_fmp_link_cost(&direct_addr);
433        let candidate_cost = self.dataplane_fmp_link_cost(&candidate_addr);
434        candidate_cost + ROUTING_FALLBACK_MIN_COST_ADVANTAGE < direct_cost
435    }
436
437    pub(super) fn select_tree_payload_candidate(
438        &self,
439        dest_coords: &crate::tree::TreeCoordinate,
440        direct_dest: &NodeAddr,
441        direct_payload_eligible: bool,
442    ) -> Option<NodeAddr> {
443        self.select_tree_payload_candidate_avoiding(
444            dest_coords,
445            direct_dest,
446            direct_payload_eligible,
447            None,
448        )
449    }
450
451    fn select_tree_payload_candidate_avoiding(
452        &self,
453        dest_coords: &crate::tree::TreeCoordinate,
454        direct_dest: &NodeAddr,
455        direct_payload_eligible: bool,
456        excluded: Option<&NodeAddr>,
457    ) -> Option<NodeAddr> {
458        if self.tree_state.my_coords().root_id() != dest_coords.root_id() {
459            return None;
460        }
461
462        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
463        let mut best: Option<(NodeAddr, usize)> = None;
464
465        for (peer_addr, peer) in &self.peers {
466            if excluded == Some(peer_addr) {
467                continue;
468            }
469            if peer_addr == direct_dest {
470                if !direct_payload_eligible {
471                    continue;
472                }
473            } else if !peer.is_healthy() {
474                continue;
475            }
476
477            let Some(peer_coords) = self.tree_state.peer_coords(peer_addr) else {
478                continue;
479            };
480            let distance = peer_coords.distance_to(dest_coords);
481            if distance >= my_distance {
482                continue;
483            }
484
485            let dominated = match &best {
486                None => true,
487                Some((best_id, best_dist)) => {
488                    distance < *best_dist || (distance == *best_dist && peer_addr < best_id)
489                }
490            };
491            if dominated {
492                best = Some((*peer_addr, distance));
493            }
494        }
495
496        best.map(|(peer_addr, _)| peer_addr)
497    }
498
499    pub(in crate::node) fn session_direct_path_is_degraded(
500        &mut self,
501        dest: &NodeAddr,
502        now_ms: u64,
503    ) -> bool {
504        self.session_direct_degradation.is_degraded(dest, now_ms)
505    }
506
507    pub(in crate::node) fn session_direct_path_degradation_active(
508        &self,
509        dest: &NodeAddr,
510        now_ms: u64,
511    ) -> bool {
512        self.session_direct_degradation.is_degraded_at(dest, now_ms)
513    }
514
515    pub(in crate::node) fn session_direct_path_blocks_direct_payload(
516        &mut self,
517        dest: &NodeAddr,
518        now_ms: u64,
519    ) -> bool {
520        self.session_direct_path_is_degraded(dest, now_ms)
521            || self.session_direct_discovered_endpoint_trust_expired(dest, now_ms)
522    }
523
524    pub(in crate::node) fn session_direct_path_exclusive_trust_timeout_ms(&self) -> u64 {
525        self.config
526            .node
527            .heartbeat_interval_secs
528            .saturating_mul(1000)
529            .saturating_add(1_500)
530            .max(SESSION_DIRECT_MIN_EXCLUSIVE_TRUST_MS)
531    }
532
533    pub(in crate::node) fn session_direct_path_exclusive_trust_expired(
534        &self,
535        dest: &NodeAddr,
536        now_ms: u64,
537    ) -> bool {
538        if !self
539            .peers
540            .get(dest)
541            .is_some_and(|peer| peer.is_healthy() && peer.can_send())
542        {
543            return false;
544        }
545        let Some(activity) = self.dataplane.fsp_owner_activity(dest) else {
546            return false;
547        };
548        activity.has_recent_outbound_without_data_return_from(
549            dest,
550            now_ms,
551            self.session_direct_path_exclusive_trust_timeout_ms(),
552        )
553    }
554
555    pub(in crate::node) fn session_direct_path_has_recent_data_return(
556        &self,
557        dest: &NodeAddr,
558        now_ms: u64,
559    ) -> bool {
560        self.dataplane
561            .fsp_owner_activity(dest)
562            .is_some_and(|activity| {
563                activity.has_recent_data_return_from(
564                    dest,
565                    now_ms,
566                    self.session_direct_path_exclusive_trust_timeout_ms(),
567                )
568            })
569    }
570
571    fn session_direct_discovered_endpoint_trust_expired(
572        &self,
573        dest: &NodeAddr,
574        now_ms: u64,
575    ) -> bool {
576        self.session_direct_path_exclusive_trust_expired(dest, now_ms)
577            && self.configured_peer(dest).is_some_and(|peer_config| {
578                peer_config.is_auto_connect()
579                    && self.active_peer_uses_traversal_path(dest, peer_config)
580            })
581    }
582
583    pub(in crate::node) fn mark_session_direct_path_degraded(
584        &mut self,
585        dest: NodeAddr,
586        now_ms: u64,
587    ) -> bool {
588        let changed = self.session_direct_degradation.mark_degraded(
589            dest,
590            now_ms,
591            SESSION_DIRECT_DEGRADED_HOLD_MS,
592        );
593        if changed {
594            let _ = self.refresh_dataplane_fsp_owner_routes(&dest);
595        }
596        changed
597    }
598
599    pub(in crate::node) fn clear_session_direct_path_degraded(&mut self, dest: &NodeAddr) -> bool {
600        let changed = self.session_direct_degradation.clear(dest);
601        if changed {
602            let _ = self.refresh_dataplane_fsp_owner_routes(dest);
603        }
604        changed
605    }
606
607    pub(in crate::node) fn clear_session_direct_path_degraded_after_promotion(
608        &mut self,
609        dest: &NodeAddr,
610        now_ms: u64,
611    ) {
612        let keep_degraded = self.session_direct_path_blocks_direct_payload(dest, now_ms);
613        if !keep_degraded {
614            self.clear_session_direct_path_degraded(dest);
615        } else if self.promoted_path_matches_configured_static_peer(dest) {
616            debug!(
617                peer = %self.peer_display_name(dest),
618                "Clearing direct payload degradation after configured direct-path promotion"
619            );
620            self.clear_session_direct_path_degraded(dest);
621        } else {
622            debug!(
623                peer = %self.peer_display_name(dest),
624                "Keeping direct payload degraded after direct-path promotion"
625            );
626        }
627    }
628
629    fn promoted_path_matches_configured_static_peer(&self, peer_node_addr: &NodeAddr) -> bool {
630        self.config
631            .auto_connect_peers()
632            .filter(|peer_config| {
633                PeerIdentity::from_npub(&peer_config.npub)
634                    .ok()
635                    .is_some_and(|identity| identity.node_addr() == peer_node_addr)
636            })
637            .any(|peer_config| {
638                self.static_peer_addresses(peer_config)
639                    .iter()
640                    .any(|candidate| self.active_peer_matches_candidate(peer_node_addr, candidate))
641            })
642    }
643
644    pub(in crate::node) fn learn_reverse_route(
645        &mut self,
646        destination: NodeAddr,
647        next_hop: NodeAddr,
648    ) {
649        if self.config.node.routing.mode != RoutingMode::ReplyLearned
650            || destination == *self.node_addr()
651        {
652            return;
653        }
654        let now_ms = Self::now_ms();
655        self.learned_routes.learn(
656            destination,
657            next_hop,
658            now_ms,
659            self.config.node.routing.learned_ttl_secs,
660            self.config.node.routing.max_learned_routes_per_dest,
661        );
662        // Discovery may return through more than one live seed. Once an FSP
663        // handshake has authenticated one complete path, keep that owner route
664        // stable while its physical next hop remains usable; later learned
665        // candidates stay available for explicit failure/degradation recovery.
666        let _ = self.refresh_dataplane_fsp_owner_routes_retaining_current(&destination);
667    }
668
669    pub(in crate::node) fn pin_handshake_reverse_route(
670        &mut self,
671        destination: NodeAddr,
672        next_hop: NodeAddr,
673    ) {
674        if self.config.node.routing.mode != RoutingMode::ReplyLearned
675            || destination == *self.node_addr()
676        {
677            return;
678        }
679        self.learned_routes.pin_handshake_route(
680            destination,
681            next_hop,
682            Self::now_ms(),
683            self.config.node.routing.learned_ttl_secs,
684            self.config.node.routing.max_learned_routes_per_dest,
685        );
686    }
687
688    pub(in crate::node) fn routing_error_matches_active_path(
689        &mut self,
690        destination: &NodeAddr,
691        previous_hop: &NodeAddr,
692        reporter: &NodeAddr,
693    ) -> bool {
694        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
695            return true;
696        }
697        let Some(pinned_hop) = self
698            .learned_routes
699            .active_handshake_route(destination, Self::now_ms())
700        else {
701            return true;
702        };
703        // Liveness may briefly mark a still-connected traversal hop stale
704        // while authenticated endpoint traffic is in flight. Plaintext route
705        // errors have no packet identifier or end-to-end authentication, so a
706        // stale branch can route its report back through the active branch.
707        // Only the pinned adjacent hop can authoritatively report failure for
708        // that pinned route. Explicit send/link failure removes the pin through
709        // `record_route_failure`, preserving normal multi-hop recovery without
710        // trusting an uncorrelated downstream report.
711        pinned_hop == *previous_hop && pinned_hop == *reporter
712    }
713
714    pub(in crate::node) fn record_route_failure(
715        &mut self,
716        destination: NodeAddr,
717        next_hop: NodeAddr,
718    ) {
719        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
720            return;
721        }
722        self.learned_routes.record_failure(&destination, &next_hop);
723        let _ = self.refresh_dataplane_fsp_owner_routes(&destination);
724    }
725
726    pub(crate) fn learned_route_table_snapshot(&self, now_ms: u64) -> LearnedRouteTableSnapshot {
727        self.learned_routes.snapshot(now_ms)
728    }
729
730    pub(in crate::node) fn purge_learned_routes(&mut self, now_ms: u64) {
731        self.learned_routes.purge_expired(now_ms);
732    }
733
734    /// Select the best peer from a set of bloom filter candidates.
735    ///
736    /// Uses distance from each candidate's tree coordinates to the destination
737    /// as the primary metric (after link_cost). Only selects peers that are
738    /// strictly closer to the destination than we are (self-distance check
739    /// prevents routing loops).
740    ///
741    /// Ordering: `(link_cost, distance_to_dest, node_addr)`.
742    pub(super) fn select_best_candidate<'a>(
743        &'a self,
744        candidates: &[&'a ActivePeer],
745        dest_coords: &crate::tree::TreeCoordinate,
746    ) -> Option<&'a ActivePeer> {
747        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
748
749        let mut best: Option<(&ActivePeer, f64, usize)> = None;
750
751        for &candidate in candidates {
752            if !candidate.can_send() {
753                continue;
754            }
755
756            let cost = self.dataplane_fmp_link_cost(candidate.node_addr());
757
758            let dist = self
759                .tree_state
760                .peer_coords(candidate.node_addr())
761                .map(|pc| pc.distance_to(dest_coords))
762                .unwrap_or(usize::MAX);
763
764            // Self-distance check: only consider peers strictly closer
765            // to the destination than we are (prevents routing loops)
766            if dist >= my_distance {
767                continue;
768            }
769
770            let dominated = match &best {
771                None => true,
772                Some((_, best_cost, best_dist)) => {
773                    cost < *best_cost
774                        || (cost == *best_cost && dist < *best_dist)
775                        || (cost == *best_cost
776                            && dist == *best_dist
777                            && candidate.node_addr() < best.as_ref().unwrap().0.node_addr())
778                }
779            };
780
781            if dominated {
782                best = Some((candidate, cost, dist));
783            }
784        }
785
786        best.map(|(peer, _, _)| peer)
787    }
788
789    /// Check if a destination is in any peer's bloom filter.
790    pub fn destination_in_filters(&self, dest: &NodeAddr) -> Vec<&ActivePeer> {
791        self.peers.values().filter(|p| p.may_reach(dest)).collect()
792    }
793}