Skip to main content

fips_core/node/
route_impl.rs

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