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