From 527514689f1c30670b5d0c3959cdb38dbe47fe27 Mon Sep 17 00:00:00 2001 From: Arjen <18398758+Origami74@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:43:11 +0200 Subject: [PATCH] feat(peer): transport-preference roaming cutover When a peer is reachable over more than one transport at once, prefer the faster one and hold it. roam_current_addr upgrades eagerly to a higher- preference transport (BLE -> Wi-Fi Aware / UDP) but falls back to a lower one only after the preferred link has been silent past a hysteresis window (~2 heartbeat intervals), so a stray BLE keepalive can't drag an active Aware session back to BLE. poll_transport_discovery additionally stops re-probing an alternate path strictly worse than the one the peer is already on, so BLE rediscovery no longer keeps grabbing a peer that belongs on Aware. Equal preferences reduce to plain last-authenticated- packet-wins roaming, so single-transport deployments are unaffected. --- src/node/handlers/encrypted.rs | 23 ++++++++- src/node/lifecycle.rs | 12 +++++ src/node/mod.rs | 23 +++++++++ src/peer/active.rs | 88 ++++++++++++++++++++++++++++++++++ src/transport/mod.rs | 21 ++++++++ 5 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/node/handlers/encrypted.rs b/src/node/handlers/encrypted.rs index 765f964..b26e778 100644 --- a/src/node/handlers/encrypted.rs +++ b/src/node/handlers/encrypted.rs @@ -263,6 +263,11 @@ impl Node { let ce_flag = header.flags & FLAG_CE != 0; let sp_flag = header.flags & FLAG_SP != 0; + // Transport-preference cutover: gate roaming so a faster transport + // (Wi-Fi Aware / UDP) isn't dragged back to a slower one (BLE) by a + // stray packet. Computed before the peer borrow (needs `self`). + let roam_pref = self.transport_preference(packet.transport_id); + let roam_hysteresis = self.roam_hysteresis_ms(); if let Some(peer) = self.peers.get_mut(&node_addr) { if let Some(mmp) = peer.mmp_mut() { mmp.receiver.record_recv( @@ -274,7 +279,13 @@ impl Node { ); let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now); } - peer.set_current_addr(packet.transport_id, packet.remote_addr.clone()); + peer.roam_current_addr( + packet.transport_id, + packet.remote_addr.clone(), + roam_pref, + packet.timestamp_ms, + roam_hysteresis, + ); peer.link_stats_mut() .record_recv(packet.data.len(), packet.timestamp_ms); peer.touch(packet.timestamp_ms); @@ -356,10 +367,18 @@ impl Node { return; }; let now = Instant::now(); + let roam_pref = self.transport_preference(transport_id); + let roam_hysteresis = self.roam_hysteresis_ms(); let mut address_changed = false; if let Some(peer) = self.peers.get_mut(node_addr) { peer.reset_decrypt_failures(); - address_changed = peer.set_current_addr(transport_id, remote_addr.clone()); + address_changed = peer.roam_current_addr( + transport_id, + remote_addr.clone(), + roam_pref, + packet_timestamp_ms, + roam_hysteresis, + ); peer.link_stats_mut() .record_recv(packet_len, packet_timestamp_ms); peer.touch(packet_timestamp_ms); diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 4bd5e62..08d8c73 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -620,6 +620,18 @@ impl Node { let remote_addr = peer.addr; if self.peers.contains_key(&node_addr) { + // Don't re-probe an alternate path that is strictly worse + // than the one the peer is already on: a peer settled on a + // faster transport (Wi-Fi Aware / UDP) must not be pulled + // back to BLE by BLE rediscovery. Equal-or-better candidates + // still refresh, so BLE → Aware upgrades proceed. + if let Some(cur) = self.peers.get(&node_addr).and_then(|p| p.transport_id()) { + if self.transport_preference(cur) + > self.transport_preference(candidate_transport_id) + { + continue; + } + } let transport_name = transport.transport_type().name; let candidate = PeerAddress::new(transport_name, remote_addr.to_string()); if self.active_peer_candidate_is_fresh_enough_to_skip( diff --git a/src/node/mod.rs b/src/node/mod.rs index 320ecb9..871fe33 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -1093,6 +1093,29 @@ impl Node { .map(|(id, _)| *id) } + /// Link preference of a transport instance by id (higher = preferred), + /// from its transport type. See [`crate::transport::transport_link_preference`]. + /// Unknown ids get 0 so any real transport outranks a stale/removed one. + pub(crate) fn transport_preference(&self, id: TransportId) -> u8 { + self.transports + .get(&id) + .map(|h| crate::transport::transport_link_preference(h.transport_type().name)) + .unwrap_or(0) + } + + /// How long a preferred transport must be silent before a lower-preference + /// transport may take over the peer's link (the roaming cutover hysteresis). + /// ~2 heartbeat intervals so a single missed heartbeat can't cause a + /// spurious downgrade; floored so a tiny configured interval can't flap. + pub(crate) fn roam_hysteresis_ms(&self) -> u64 { + (self + .config() + .node + .heartbeat_interval_secs + .saturating_mul(2_000)) + .max(15_000) + } + /// Resolve an Ethernet peer address ("interface/mac") to a transport ID /// and binary TransportAddr. /// diff --git a/src/peer/active.rs b/src/peer/active.rs index 1ae58fd..482fb22 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -99,6 +99,14 @@ pub struct ActivePeer { transport_id: Option, /// Current transport address (for roaming support). current_addr: Option, + /// Link preference of the current transport (higher = preferred). Gates + /// roaming so a fast transport (e.g. Wi-Fi Aware / UDP) is not dragged back + /// onto a slow one (BLE) by a stray packet — see `roam_current_addr`. + current_transport_preference: u8, + /// When the current transport last delivered an authenticated packet (Unix + /// ms). Lets the roam gate detect that the preferred transport has gone + /// silent, so a lower-preference transport may take over. + current_transport_last_recv_ms: u64, // === Spanning Tree === /// Their latest parent declaration. @@ -232,6 +240,8 @@ impl ActivePeer { their_index: None, transport_id: None, current_addr: None, + current_transport_preference: 0, + current_transport_last_recv_ms: 0, declaration: None, ancestry: None, tree_announce_min_interval_ms: 500, @@ -318,6 +328,11 @@ impl ActivePeer { their_index: Some(their_index), transport_id: Some(transport_id), current_addr: Some(current_addr), + // Preference 0 until the first authenticated data packet sets the + // real value (via roam_current_addr); last-recv starts fresh at + // authentication so the preferred link isn't seen as stale. + current_transport_preference: 0, + current_transport_last_recv_ms: authenticated_at, declaration: None, ancestry: None, tree_announce_min_interval_ms: 500, @@ -535,6 +550,45 @@ impl ActivePeer { changed } + /// Roam to `(transport_id, addr)` under the transport-preference cutover + /// policy, returning whether `(transport_id, addr)` actually changed. + /// + /// The new path is adopted when it is the current transport (ordinary + /// address roaming, e.g. BLE MAC rotation), when its `preference` is `>=` + /// the current transport's (eager upgrade to a faster link — BLE → + /// Wi-Fi Aware), or when the current, higher-preference transport has gone + /// silent for at least `hysteresis_ms` (the preferred link died — fall + /// back). A lower-preference packet arriving on a still-live preferred + /// transport is ignored for roaming, so e.g. a BLE keepalive cannot drag an + /// active Wi-Fi Aware session back onto BLE. Equal preferences reduce to + /// plain last-authenticated-packet-wins roaming. + pub fn roam_current_addr( + &mut self, + transport_id: TransportId, + addr: TransportAddr, + preference: u8, + now_ms: u64, + hysteresis_ms: u64, + ) -> bool { + let same_transport = self.transport_id == Some(transport_id); + let adopt = self.transport_id.is_none() + || same_transport + || preference >= self.current_transport_preference + || now_ms.saturating_sub(self.current_transport_last_recv_ms) >= hysteresis_ms; + if !adopt { + // Stay on the preferred transport; do not refresh its last-recv, + // so it keeps aging toward the hysteresis fallback if it is dead. + return false; + } + let changed = + self.transport_id != Some(transport_id) || self.current_addr.as_ref() != Some(&addr); + self.transport_id = Some(transport_id); + self.current_addr = Some(addr); + self.current_transport_preference = preference; + self.current_transport_last_recv_ms = now_ms; + changed + } + // === Handshake Resend === /// Store wire-format msg2 for resend on duplicate msg1. @@ -1245,6 +1299,40 @@ mod tests { assert!(!peer.can_send()); } + #[test] + fn test_roam_transport_preference_cutover() { + let mut peer = ActivePeer::new(make_peer_identity(), LinkId::new(1), 1000); + let ble = TransportId::new(1); + let udp = TransportId::new(2); + let ble_addr = TransportAddr::from_string("ble0/AA:BB"); + let udp_addr = TransportAddr::from_string("[fe80::1%3]:4870"); + const BLE_PREF: u8 = 50; + const UDP_PREF: u8 = 100; + const HYST: u64 = 20_000; + + // First packet (no current transport): adopted. + assert!(peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1000, HYST)); + assert_eq!(peer.transport_id(), Some(ble)); + + // Higher-preference transport: eager upgrade BLE -> Aware/UDP. + assert!(peer.roam_current_addr(udp, udp_addr.clone(), UDP_PREF, 1100, HYST)); + assert_eq!(peer.transport_id(), Some(udp)); + + // Lower-preference keepalive while UDP is fresh: ignored, stays on UDP. + assert!(!peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1200, HYST)); + assert_eq!(peer.transport_id(), Some(udp)); + + // UDP silent past the hysteresis window: a BLE packet now wins (fall + // back). Last UDP recv was 1100; this arrives at 1100 + HYST + 1. + assert!(peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1100 + HYST + 1, HYST)); + assert_eq!(peer.transport_id(), Some(ble)); + + // Same-transport address roaming is always allowed. + let ble_addr2 = TransportAddr::from_string("ble0/CC:DD"); + assert!(peer.roam_current_addr(ble, ble_addr2.clone(), BLE_PREF, 1100 + HYST + 2, HYST)); + assert_eq!(peer.current_addr(), Some(&ble_addr2)); + } + #[test] fn test_tree_position() { let identity = make_peer_identity(); diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 3375da7..b5da1b0 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -280,6 +280,27 @@ impl fmt::Display for TransportType { } } +/// Link preference for a transport type — higher is preferred for carrying a +/// peer's traffic when that peer is reachable over more than one transport at +/// once. Used by the roaming cutover (`ActivePeer::roam_current_addr`) so a +/// peer on a fast transport (Wi-Fi Aware / UDP) is not dragged onto a slow +/// one (BLE) by a stray packet. Equal preferences reduce to plain +/// last-authenticated-packet-wins roaming, so single-transport deployments are +/// unaffected. +pub fn transport_link_preference(name: &str) -> u8 { + match name { + // High-bandwidth IP transports (incl. the Wi-Fi Aware data path, which + // rides the UDP transport). + "wifi" | "udp" | "tcp" => 100, + "ethernet" => 90, + // Always-on but low-bandwidth control link. + "ble" => 50, + // Anonymity overlays: high latency. + "tor" | "nym" => 40, + _ => 80, + } +} + // ============================================================================ // Transport State // ============================================================================