From 0480c8303e80e7e23bb395f9914e811f60e149e4 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 13 Aug 2026 12:38:27 -0600 Subject: [PATCH 1/2] fix(reticulum): rebase Ratspeak overlays onto floated origin/main Release packaging failed when clone-ratspeak-stack.sh could not apply the LinkClient Nomad overlay on rsReticulum 70b7399. Regenerate drifted overlays, switch RRC recall to upstream RecallDestination, and keep host-peer terminal accounting in lxmd parity. --- reticulum-sidecar/patches/README.md | 19 +- ...MF-propagation-client-abort-transfer.patch | 12 +- ...tion-node-deferred-messagestore-load.patch | 17 +- .../rsReticulum-ble-rnode-bond-desync.patch | 16 +- ...le-rnode-pairing-transition-debounce.patch | 14 +- .../rsReticulum-link-client-nomad.patch | 380 +- .../rsReticulum-path-medium-slots.patch | 3902 ++++++++--------- reticulum-sidecar/src/stack/lxmf_outbound.rs | 30 +- reticulum-sidecar/src/stack/mod.rs | 4 +- .../src/stack/propagation_bridge.rs | 31 + reticulum-sidecar/src/stack/rrc_link.rs | 45 +- .../apply-rsReticulum-link-client-nomad.sh | 19 +- ...ply-rsReticulum-link-client-nomad.test.mjs | 141 + 13 files changed, 2403 insertions(+), 2227 deletions(-) create mode 100644 scripts/apply-rsReticulum-link-client-nomad.test.mjs diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index bea9769e7..baa328592 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -129,17 +129,16 @@ When [ratspeak/rsReticulum#11](https://github.com/ratspeak/rsReticulum/pull/11) Recall cached destination public keys in `LinkClient` before waiting on path-response announces; GC temporary announce handlers without wiping long-lived Nomad directory listeners. Fixes Nomad page loads hanging until overall timeout. +Upstream `a945ba0` landed HasPath-gated `RecallDestination`, but still waits on a fresh announce when the path table is cold and still Deregisters handlers by `aspect_filter`. This overlay uses `RecallDestination` without the HasPath gate, then `await_path`, and GCs closed handlers only. + | Field | Value | | ----- | ----- | -| **Base commit** | `9928abed269a83ec5a7ef165ff1142d938cad706` | +| **Base commit** | `70b7399` (`ratspeak/rsReticulum` `origin/main`) | | **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/14 | -**Modifies (4 files):** +**Modifies (1 file):** -- `crates/rns-runtime/src/link_client.rs` — recall + `await_path`; safe announce-handler GC -- `crates/rns-transport/src/messages.rs` — `RecallDestinationPublicKey`, `PublicKeyResult` -- `crates/rns-transport/src/actor/rpc.rs` — recall handler -- `crates/rns-transport/src/actor/mod.rs` — unit tests +- `crates/rns-runtime/src/link_client.rs` — `discover_remote_public_key` + `await_path`; safe announce-handler GC ### Apply locally @@ -202,7 +201,7 @@ Debounce BLE RNode reconnect after mid-SMP disconnect (`BLE pairing in progress` | Field | Value | | ----- | ----- | -| **Base commit** | `9928abed269a83ec5a7ef165ff1142d938cad706` (after prior overlays) | +| **Base commit** | `70b7399` (`ratspeak/rsReticulum` `origin/main`) | | **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/20 | **Modifies (1 file):** @@ -399,7 +398,7 @@ When [ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6) merges and f | Field | Value | | ----- | ----- | -| **Base commit** | tip of `ratspeak/rsLXMF` `main` + `rsLXMF-propagation-node-policy-setters` overlay | +| **Base commit** | `f9ed81e` (`ratspeak/rsLXMF` `origin/main`) + `rsLXMF-propagation-node-policy-setters` overlay | | **Upstream PR** | (none yet — mesh-client local API) | **Modifies (1 file):** @@ -461,7 +460,7 @@ Ranked multi-path slots (up to 3 per destination) plus global / per-peer RF-vs-n | Field | Value | | ----- | ----- | -| **Base commit** | `9928abed269a83ec5a7ef165ff1142d938cad706` (+ prior mesh-client overlays) | +| **Base commit** | `70b7399` (+ prior mesh-client overlays) | | **Upstream PR** | none yet (mesh-client-local) | **Touches:** `constants.rs`, `path_table.rs`, `messages.rs`, `actor/{inbound,mod,rpc,outbound,persistence}.rs` @@ -503,7 +502,7 @@ Adds `PropagationClient::abort_transfer` so Cancel / mid-transfer abort leaves t | Field | Value | | ----- | ----- | -| **Base commit** | floated `origin/main` (regenerate; record short SHA in PR) | +| **Base commit** | `f9ed81e` (`ratspeak/rsLXMF` `origin/main`) | | **Upstream PR** | none yet (mesh-client-local; watch ratspeak/rsLXMF) | **Touches:** rsLXMF `PropagationClient` (abort in-flight list/get transfer → Idle) diff --git a/reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch b/reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch index fed85d521..0515f3788 100644 --- a/reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch +++ b/reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch @@ -1,10 +1,11 @@ +diff --git a/crates/lxmf-core/src/propagation_client.rs b/crates/lxmf-core/src/propagation_client.rs +index a410020..8b8a663 100644 --- a/crates/lxmf-core/src/propagation_client.rs +++ b/crates/lxmf-core/src/propagation_client.rs -@@ -182,6 +182,24 @@ - self.status = PropagationTransferStatus::default(); +@@ -202,6 +202,24 @@ impl PropagationClient { true } -+ + + /// Abort an in-flight or terminal download and return to [`Idle`]. + /// + /// Unlike [`Self::acknowledge_transfer`], this also tears down mid-transfer @@ -22,6 +23,7 @@ + self.identified = false; + self.started_at = None; + } - ++ pub fn start_download(&mut self) -> bool { - let node_hash = match self.outbound_propagation_node { + self.start_download_with_limit(None) + } diff --git a/reticulum-sidecar/patches/rsLXMF-propagation-node-deferred-messagestore-load.patch b/reticulum-sidecar/patches/rsLXMF-propagation-node-deferred-messagestore-load.patch index 91f2c2b16..5510d5957 100644 --- a/reticulum-sidecar/patches/rsLXMF-propagation-node-deferred-messagestore-load.patch +++ b/reticulum-sidecar/patches/rsLXMF-propagation-node-deferred-messagestore-load.patch @@ -1,9 +1,12 @@ +diff --git a/crates/lxmf-core/src/propagation_node.rs b/crates/lxmf-core/src/propagation_node.rs +index 26a6c6f..2cdc924 100644 --- a/crates/lxmf-core/src/propagation_node.rs +++ b/crates/lxmf-core/src/propagation_node.rs -@@ -333,8 +333,21 @@ +@@ -416,9 +416,22 @@ impl PropagationNode { + config: PropagationNodeConfig, dest_hash: [u8; 16], storage_path: PathBuf, - ) -> std::io::Result { ++ ) -> std::io::Result { + let mut node = Self::with_storage_unloaded(config, dest_hash, storage_path)?; + node.load_messagestore_from_disk()?; + Ok(node) @@ -16,17 +19,17 @@ + config: PropagationNodeConfig, + dest_hash: [u8; 16], + storage_path: PathBuf, -+ ) -> std::io::Result { + ) -> std::io::Result { std::fs::create_dir_all(&storage_path)?; - let mut node = Self { + Ok(Self { config, store: PropagationStore::new(), sync_sessions: HashMap::new(), -@@ -342,9 +355,13 @@ - storage_path: Some(storage_path), - last_offer_times: HashMap::new(), +@@ -428,9 +441,13 @@ impl PropagationNode { offer_generation: 0, + pending_write_ids: HashSet::new(), + pending_write_bytes: 0, - }; - node.load_from_disk()?; - Ok(node) @@ -39,4 +42,4 @@ + self.load_from_disk() } - /// Returns `true` if the message was stored, `false` on duplicate, overflow, + fn reserve_store_write( diff --git a/reticulum-sidecar/patches/rsReticulum-ble-rnode-bond-desync.patch b/reticulum-sidecar/patches/rsReticulum-ble-rnode-bond-desync.patch index 10d50ebb7..e3d4f9ab1 100644 --- a/reticulum-sidecar/patches/rsReticulum-ble-rnode-bond-desync.patch +++ b/reticulum-sidecar/patches/rsReticulum-ble-rnode-bond-desync.patch @@ -1,8 +1,8 @@ diff --git a/crates/rns-interface/src/ble_rnode.rs b/crates/rns-interface/src/ble_rnode.rs -index 1111111..2222222 100644 +index 66d5610..dd36309 100644 --- a/crates/rns-interface/src/ble_rnode.rs +++ b/crates/rns-interface/src/ble_rnode.rs -@@ -190,6 +190,14 @@ +@@ -237,6 +237,14 @@ fn is_pairing_transition_error(error: &InterfaceError) -> bool { ) } @@ -17,7 +17,7 @@ index 1111111..2222222 100644 /// Android's native bridge can come up immediately after SMP completes, while /// rsCardputer's RNode BLE stack is still settling. Probe detect a few times /// inside one connection attempt so a single dropped early frame does not cost -@@ -1319,6 +1327,7 @@ +@@ -1366,6 +1374,7 @@ enum NativeBridgeWrite { async fn connect_rnode( adapter: &Adapter, ble_uri: &str, @@ -25,7 +25,7 @@ index 1111111..2222222 100644 ) -> Result { ble_diag(format!("[ble] connect_rnode start uri={ble_uri}")); let peripheral = resolve_ble_target(adapter, ble_uri).await?; -@@ -1403,20 +1412,68 @@ +@@ -1450,20 +1459,68 @@ async fn connect_rnode( // kills any pending subscribe. iOS/macOS share CoreBluetooth; Windows // (WinRT) and Android auto-prompt and retry on encrypted-char reads. // Linux used explicit BlueZ pairing before `connect()`, above. @@ -100,7 +100,7 @@ index 1111111..2222222 100644 // 244 = ATT MTU 247 - 3-byte header. Larger writes silently drop on // peripherals with smaller negotiated MTU; 512 (GATT ceiling) isn't -@@ -1963,6 +2020,9 @@ +@@ -2025,6 +2082,9 @@ pub async fn spawn_ble_rnode_interface_with_driver_and_options( let mut tries: usize = 0; let mut backoff = RECONNECT_WAIT; let mut initial_attempt = true; @@ -110,7 +110,7 @@ index 1111111..2222222 100644 // Drop guard: every early return must clear the running-flag map // entry, or stale entries confuse later spawns reusing the id. -@@ -2009,9 +2069,25 @@ +@@ -2071,9 +2131,25 @@ pub async fn spawn_ble_rnode_interface_with_driver_and_options( } }; @@ -138,7 +138,7 @@ index 1111111..2222222 100644 let pairing_transition = is_pairing_transition_error(&e); let retry_wait = if pairing_transition { PAIRING_TRANSITION_RETRY_WAIT -@@ -5432,7 +5508,7 @@ +@@ -5607,7 +5683,7 @@ mod tests { #[ignore] async fn test_ble_connect_to_rnode() { let adapter = get_adapter().await.expect("No BLE adapter"); @@ -147,7 +147,7 @@ index 1111111..2222222 100644 .await .expect("No RNode found. Pair an RNode first."); assert!(conn.peripheral.is_connected().await.unwrap_or(false)); -@@ -5466,4 +5542,15 @@ +@@ -5641,4 +5717,15 @@ mod tests { "BLE device not found: RNode".into() ))); } diff --git a/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch b/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch index ac4758178..61708d7b6 100644 --- a/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch +++ b/reticulum-sidecar/patches/rsReticulum-ble-rnode-pairing-transition-debounce.patch @@ -1,11 +1,11 @@ diff --git a/crates/rns-interface/src/ble_rnode.rs b/crates/rns-interface/src/ble_rnode.rs -index a0cf11a..9bd4780 100644 +index e431f77..66d5610 100644 --- a/crates/rns-interface/src/ble_rnode.rs +++ b/crates/rns-interface/src/ble_rnode.rs -@@ -53,6 +53,10 @@ pub const NUS_TX_CHAR_UUID: Uuid = Uuid::from_u128(0x6E400003_B5A3_F393_E0A9_E50 - const RECONNECT_WAIT: u64 = 5; - /// Capped below TCP's 300s — a BLE radio is either in range or not. - const RECONNECT_WAIT_MAX: u64 = 120; +@@ -54,6 +54,10 @@ const RECONNECT_WAIT: u64 = 1; + /// Fast early recovery, then indefinite low-duty retries. A two-minute cap made + /// a reachable radio appear dead long after returning to range. + const RECONNECT_WAIT_MAX: u64 = 30; +/// After a mid-SMP disconnect (`BLE pairing in progress`), wait before +/// reconnecting so the OS passkey dialog is not re-fired every second while +/// the user is typing the PIN (desktop BLE pairing UX). @@ -13,7 +13,7 @@ index a0cf11a..9bd4780 100644 /// `None` retries forever; teardown goes via `stop_ble_rnode_interface`. const MAX_RECONNECT_TRIES: Option = None; const SCAN_TIMEOUT: u64 = 3; -@@ -2009,7 +2013,11 @@ pub async fn spawn_ble_rnode_interface_with_driver_and_options( +@@ -2071,7 +2075,11 @@ pub async fn spawn_ble_rnode_interface_with_driver_and_options( Ok(c) => c, Err(e) => { let pairing_transition = is_pairing_transition_error(&e); @@ -26,7 +26,7 @@ index a0cf11a..9bd4780 100644 snapshot_publisher.connection_attempt_failed(); tracing::warn!(name = %log_name, error = %e, "BLE RNode connect failed"); ble_diag(format!( -@@ -5447,4 +5455,15 @@ mod tests { +@@ -5622,4 +5630,15 @@ mod tests { tokio::time::sleep(Duration::from_secs(2)).await; handle.online.store(false, Ordering::SeqCst); } diff --git a/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch b/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch index ce61947ab..c6eae86ec 100644 --- a/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch +++ b/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch @@ -1,75 +1,93 @@ diff --git a/crates/rns-runtime/src/link_client.rs b/crates/rns-runtime/src/link_client.rs -index fcfc3dc..c024504 100644 +index 9874942..631d014 100644 --- a/crates/rns-runtime/src/link_client.rs +++ b/crates/rns-runtime/src/link_client.rs -@@ -16,8 +16,15 @@ use rns_identity::identity::Identity; +@@ -16,11 +16,15 @@ use rns_identity::identity::Identity; use rns_link::link::{CloseReason, Link}; use rns_protocol::resource::{InboundTransfer, TransferAction}; use rns_protocol::resource_adv::ResourceAdvertisement; +use rns_transport::await_path::{AwaitPathError, await_path}; use rns_transport::link_messages::DestinationEvent; --use rns_transport::messages::{AnnounceHandlerEvent, OutboundRequest, TransportMessage}; -+use rns_transport::messages::{ -+ AnnounceHandlerEvent, OutboundRequest, TransportMessage, TransportQuery, TransportQueryResponse, -+}; -+use tokio::sync::oneshot; -+ + use rns_transport::messages::{ + AnnounceHandlerEvent, OutboundRequest, TransportMessage, TransportQuery, TransportQueryResponse, + }; + +/// MeshChat `NomadnetDownloader` path_lookup_timeout default (seconds). +const PATH_LOOKUP_TIMEOUT: Duration = Duration::from_secs(15); - ++ #[derive(Debug, thiserror::Error)] pub enum LinkClientError { -@@ -70,27 +77,9 @@ impl LinkClient { + #[error("transport channel closed or full")] +@@ -72,36 +76,9 @@ impl LinkClient { let dest_hash = Destination::hash_from_name_and_identity(app_name, Some(&remote_transport_hash)); -- // Register the handler before the path request so the answering -- // announce (carrying the pubkey) is observed. -- let (ann_tx, mut ann_rx) = mpsc::channel::(64); -- self.send_msg(TransportMessage::RegisterAnnounceHandler { -- aspect_filter: Some(app_name.to_string()), -- receive_path_responses: true, -- callback_tx: ann_tx, -- }) -- .await?; +- let pubkey = +- if let Some(public_key) = self.recall_routable_pubkey(dest_hash, deadline).await? { +- public_key +- } else { +- // Register the handler before the path request so the answering +- // announce (carrying the pubkey) is observed. +- let (ann_tx, mut ann_rx) = mpsc::channel::(64); +- self.send_msg(TransportMessage::RegisterAnnounceHandler { +- aspect_filter: Some(app_name.to_string()), +- receive_path_responses: true, +- callback_tx: ann_tx, +- }) +- .await?; - -- self.send_msg(TransportMessage::RequestPath { -- destination_hash: dest_hash, -- }) -- .await?; +- self.send_msg(TransportMessage::RequestPath { +- destination_hash: dest_hash, +- }) +- .await?; - -- let pubkey = wait_for_pubkey(&mut ann_rx, dest_hash, time_remaining(deadline)?).await?; -- let _ = self -- .transport_tx -- .try_send(TransportMessage::DeregisterAnnounceHandler { -- aspect_filter: Some(app_name.to_string()), -- }); +- let public_key = match time_remaining(deadline) { +- Ok(remaining) => wait_for_pubkey(&mut ann_rx, dest_hash, remaining).await, +- Err(error) => Err(error), +- }; +- let _ = self +- .transport_tx +- .try_send(TransportMessage::DeregisterAnnounceHandler { +- aspect_filter: Some(app_name.to_string()), +- }); +- public_key? +- }; + let pubkey = self + .discover_remote_public_key(dest_hash, app_name, deadline) + .await?; let (mut link, request_data) = Link::new_initiator(dest_hash, hops); let link_id = link.link_id; -@@ -190,6 +179,90 @@ impl LinkClient { - response +@@ -208,34 +185,66 @@ impl LinkClient { + .map_err(|_| LinkClientError::TransportUnavailable) } +- async fn recall_routable_pubkey( + /// Resolve remote identity public key + path before link establishment. + /// + /// Prefer recalling a key already cached from a prior announce (Nomad nodes -+ /// that appear in the directory almost always have one). Fall back to the -+ /// historical RequestPath + announce wait only when the cache misses. -+ /// Temporary announce handlers are always GC'd without wiping other -+ /// `aspect_filter` registrations (e.g. a long-lived Nomad discoverer). ++ /// that appear in the directory almost always have one), even when `HasPath` ++ /// is currently false. Fall back to RequestPath + announce wait only on a ++ /// cache miss. Temporary announce handlers are always GC'd without wiping ++ /// other `aspect_filter` registrations (e.g. a long-lived Nomad discoverer). + async fn discover_remote_public_key( -+ &self, + &self, +- destination_hash: [u8; 16], + dest_hash: [u8; 16], + app_name: &str, -+ deadline: Instant, + deadline: Instant, +- ) -> Result, LinkClientError> { +- let has_path = self +- .transport_query( +- TransportQuery::HasPath { +- dest: destination_hash, +- }, +- deadline, +- ) + ) -> Result<[u8; 64], LinkClientError> { + let path_budget = PATH_LOOKUP_TIMEOUT.min(time_remaining(deadline)?); + -+ if let Some(pubkey) = self.recall_destination_public_key(dest_hash).await? { ++ if let Some(pubkey) = self.recall_cached_pubkey(dest_hash, deadline).await? { + self.await_path_or_timeout(dest_hash, path_budget).await?; + return Ok(pubkey); + } @@ -89,34 +107,44 @@ index fcfc3dc..c024504 100644 + self.send_msg(TransportMessage::RequestPath { + destination_hash: dest_hash, + }) -+ .await?; + .await?; +- if !matches!(has_path, TransportQueryResponse::BoolResult(true)) { +- return Ok(None); + wait_for_pubkey(&mut ann_rx, dest_hash, time_remaining(deadline)?).await -+ } + } + .await; + + drop(ann_rx); + self.gc_closed_announce_handlers(); + discovery + } -+ -+ async fn recall_destination_public_key( + ++ /// Recall pubkey from `recent_announces` without requiring a live path. ++ async fn recall_cached_pubkey( + &self, + dest_hash: [u8; 16], ++ deadline: Instant, + ) -> Result, LinkClientError> { -+ let (response_tx, response_rx) = oneshot::channel(); -+ self.send_msg(TransportMessage::Rpc { -+ query: TransportQuery::RecallDestinationPublicKey { dest: dest_hash }, -+ response_tx, -+ }) -+ .await?; -+ match timeout(Duration::from_secs(5), response_rx).await { -+ Ok(Ok(TransportQueryResponse::PublicKeyResult(pk))) => Ok(pk), -+ Ok(Ok(_)) => Ok(None), -+ Ok(Err(_)) => Err(LinkClientError::TransportUnavailable), -+ Err(_) => Err(LinkClientError::Timeout("pubkey recall")), -+ } -+ } -+ + match self + .transport_query( +- TransportQuery::RecallDestination { +- dest: destination_hash, +- }, ++ TransportQuery::RecallDestination { dest: dest_hash }, + deadline, + ) + .await? + { + TransportQueryResponse::RecalledDestination(Some(destination)) +- if destination.dest_hash == destination_hash => ++ if destination.dest_hash == dest_hash => + { + Ok(Some(destination.public_key)) + } +@@ -246,6 +255,27 @@ impl LinkClient { + } + } + + async fn await_path_or_timeout( + &self, + dest_hash: [u8; 16], @@ -138,40 +166,105 @@ index fcfc3dc..c024504 100644 + }); + } + - async fn send_msg(&self, msg: TransportMessage) -> Result<(), LinkClientError> { - self.transport_tx - .send(msg) -@@ -697,4 +770,42 @@ mod tests { - assert_eq!(header.context, rns_wire::context::PacketContext::LinkClose); - assert!(responder.receive_teardown(&request.raw[offset..])); + async fn transport_query( + &self, + query: TransportQuery, +@@ -737,7 +767,7 @@ mod tests { } + + #[tokio::test] +- async fn validated_cached_identity_and_live_path_skip_network_discovery() { ++ async fn recall_cached_pubkey_hits_announce_cache_without_has_path() { + let destination_hash = [0xD4; 16]; + let remote_identity = Identity::new(); + let public_key = remote_identity.get_public_key(); +@@ -745,16 +775,48 @@ mod tests { + let responder = tokio::spawn(async move { + let Some(TransportMessage::Rpc { query, response_tx }) = transport_rx.recv().await + else { +- panic!("expected path query"); ++ panic!("expected destination recall"); + }; + assert!(matches!( + query, +- TransportQuery::HasPath { dest } if dest == destination_hash ++ TransportQuery::RecallDestination { dest } if dest == destination_hash + )); + response_tx +- .send(TransportQueryResponse::BoolResult(true)) ++ .send(TransportQueryResponse::RecalledDestination(Some( ++ rns_transport::messages::RecalledDestinationRpcEntry { ++ dest_hash: destination_hash, ++ public_key, ++ app_data: None, ++ ratchet: None, ++ hops: 1, ++ timestamp: 1.0, ++ }, ++ ))) + .unwrap(); ++ assert!( ++ transport_rx.try_recv().is_err(), ++ "cache hit must not emit HasPath or a path request" ++ ); ++ }); + -+ #[tokio::test] -+ async fn recall_destination_public_key_reads_rpc_result() { -+ let dest = [0xAB; 16]; -+ let expected = [0x42u8; 64]; -+ let (transport_tx, mut transport_rx) = mpsc::channel(4); + let client = LinkClient::new(transport_tx, Identity::new()); -+ -+ let recall = tokio::spawn(async move { client.recall_destination_public_key(dest).await }); -+ -+ let msg = transport_rx.recv().await.expect("rpc message"); -+ let TransportMessage::Rpc { -+ query: TransportQuery::RecallDestinationPublicKey { dest: got_dest }, -+ response_tx, -+ } = msg -+ else { -+ panic!("expected RecallDestinationPublicKey rpc, got {msg:?}"); -+ }; -+ assert_eq!(got_dest, dest); -+ response_tx -+ .send(TransportQueryResponse::PublicKeyResult(Some(expected))) -+ .unwrap(); -+ -+ let recalled = recall.await.unwrap().unwrap(); -+ assert_eq!(recalled, Some(expected)); ++ assert_eq!( ++ client ++ .recall_cached_pubkey(destination_hash, Instant::now() + Duration::from_secs(1),) ++ .await ++ .unwrap(), ++ Some(public_key) ++ ); ++ responder.await.unwrap(); + } + ++ #[tokio::test] ++ async fn discover_remote_public_key_cache_hit_awaits_path_without_announce_wait() { ++ let destination_hash = [0xD4; 16]; ++ let remote_identity = Identity::new(); ++ let public_key = remote_identity.get_public_key(); ++ let (transport_tx, mut transport_rx) = mpsc::channel(8); ++ let responder = tokio::spawn(async move { + let Some(TransportMessage::Rpc { query, response_tx }) = transport_rx.recv().await + else { + panic!("expected destination recall"); +@@ -775,23 +837,48 @@ mod tests { + }, + ))) + .unwrap(); + ++ let Some(TransportMessage::AwaitPath { dest, reply }) = transport_rx.recv().await ++ else { ++ panic!("expected AwaitPath"); ++ }; ++ assert_eq!(dest, destination_hash); ++ reply.send(true).unwrap(); + assert!( + transport_rx.try_recv().is_err(), +- "cache hit must not emit a path request" ++ "cache hit must not emit a path request or announce handler" + ); + }); + + let client = LinkClient::new(transport_tx, Identity::new()); + assert_eq!( + client +- .recall_routable_pubkey(destination_hash, Instant::now() + Duration::from_secs(1),) ++ .discover_remote_public_key( ++ destination_hash, ++ "nomadnetwork.node", ++ Instant::now() + Duration::from_secs(2), ++ ) + .await + .unwrap(), +- Some(public_key) ++ public_key + ); + responder.await.unwrap(); + } + + #[test] + fn gc_closed_announce_handlers_sends_aspect_filter_none() { + let (transport_tx, mut transport_rx) = mpsc::channel(4); @@ -179,118 +272,13 @@ index fcfc3dc..c024504 100644 + client.gc_closed_announce_handlers(); + let msg = transport_rx.try_recv().unwrap(); + match msg { -+ TransportMessage::DeregisterAnnounceHandler { aspect_filter: None } => {} ++ TransportMessage::DeregisterAnnounceHandler { ++ aspect_filter: None, ++ } => {} + other => panic!("expected GC deregister, got {other:?}"), + } + } - } -diff --git a/crates/rns-transport/src/actor/mod.rs b/crates/rns-transport/src/actor/mod.rs -index 0b5a20e..13313f1 100644 ---- a/crates/rns-transport/src/actor/mod.rs -+++ b/crates/rns-transport/src/actor/mod.rs -@@ -10809,6 +10809,60 @@ mod tests { - )); - } - -+ #[test] -+ fn recall_destination_public_key_hit_and_miss() { -+ let (mut actor, _tx) = TransportActor::new(); -+ let identity = rns_identity::identity::Identity::new(); -+ let dest_hash = [0xD1; 16]; -+ insert_announce_for(&mut actor, dest_hash, &identity); + -+ let hit = actor.handle_query(crate::messages::TransportQuery::RecallDestinationPublicKey { -+ dest: dest_hash, -+ }); -+ match hit { -+ crate::messages::TransportQueryResponse::PublicKeyResult(Some(pk)) => { -+ assert_eq!(pk, identity.get_public_key()); -+ } -+ other => panic!("expected PublicKeyResult(Some(_)), got {other:?}"), -+ } -+ -+ let miss = -+ actor.handle_query(crate::messages::TransportQuery::RecallDestinationPublicKey { -+ dest: [0xEE; 16], -+ }); -+ assert!(matches!( -+ miss, -+ crate::messages::TransportQueryResponse::PublicKeyResult(None) -+ )); -+ } -+ -+ #[test] -+ fn deregister_announce_handler_none_only_sweeps_closed() { -+ let (mut actor, _tx) = TransportActor::new(); -+ let (live_tx, _live_rx) = tokio::sync::mpsc::channel(4); -+ let (dead_tx, dead_rx) = tokio::sync::mpsc::channel(4); -+ drop(dead_rx); -+ -+ actor.announce_handlers.push(AnnounceHandlerRegistration { -+ aspect_filter: Some("nomadnetwork.node".into()), -+ receive_path_responses: false, -+ tx: live_tx, -+ }); -+ actor.announce_handlers.push(AnnounceHandlerRegistration { -+ aspect_filter: Some("nomadnetwork.node".into()), -+ receive_path_responses: true, -+ tx: dead_tx, -+ }); -+ -+ actor.handle_message(TransportMessage::DeregisterAnnounceHandler { aspect_filter: None }); -+ assert_eq!(actor.announce_handlers.len(), 1); -+ assert_eq!( -+ actor.announce_handlers[0].aspect_filter.as_deref(), -+ Some("nomadnetwork.node") -+ ); -+ assert!(!actor.announce_handlers[0].tx.is_closed()); -+ } -+ - #[test] - fn filter_blackholed_dests_returns_only_blackholed() { - let (mut actor, _tx) = TransportActor::new(); -diff --git a/crates/rns-transport/src/actor/rpc.rs b/crates/rns-transport/src/actor/rpc.rs -index 07f3b52..8113412 100644 ---- a/crates/rns-transport/src/actor/rpc.rs -+++ b/crates/rns-transport/src/actor/rpc.rs -@@ -616,6 +616,13 @@ impl TransportActor { - } - TransportQueryResponse::HashResult(None) - } -+ TransportQuery::RecallDestinationPublicKey { dest } => { -+ let public_key = self -+ .recent_announces -+ .get(&dest) -+ .and_then(|entry| entry.public_key); -+ TransportQueryResponse::PublicKeyResult(public_key) -+ } - TransportQuery::FilterBlackholedDests { dests } => { - let mut hits = Vec::new(); - for dest in &dests { -diff --git a/crates/rns-transport/src/messages.rs b/crates/rns-transport/src/messages.rs -index 34b0443..23ae6c8 100644 ---- a/crates/rns-transport/src/messages.rs -+++ b/crates/rns-transport/src/messages.rs -@@ -768,6 +768,13 @@ pub enum TransportQuery { - /// `recent_announces`. Returns `IntResult(count_purged)`. Use sparingly — - /// this can drop legit-but-unseen entries. - PurgeUnverifiedBlackholes, -+ /// Recall the 64-byte identity public key for a destination previously -+ /// learned from a validated announce (`recent_announces`). Used by -+ /// `LinkClient` so Nomad/page browsers skip a fresh path-response wait when -+ /// the key is already cached. Response: `PublicKeyResult`. -+ RecallDestinationPublicKey { -+ dest: [u8; 16], -+ }, - } - - #[derive(Debug)] -@@ -781,6 +788,8 @@ pub enum TransportQueryResponse { - FloatResult(Option), - StringResult(Option), - HashResult(Option<[u8; 16]>), -+ /// 64-byte `X25519_pub || Ed25519_pub` for `RecallDestinationPublicKey`. -+ PublicKeyResult(Option<[u8; 64]>), - BoolResult(bool), - PathStateResult(crate::constants::PathState), - BlackholeList(Vec), + #[tokio::test] + async fn send_close_uses_authenticated_teardown_payload() { + let dest_hash = [0xCC; 16]; diff --git a/reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch b/reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch index 3fa49e753..0ec2eeaac 100644 --- a/reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch +++ b/reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch @@ -1,2153 +1,2103 @@ ---- a/crates/rns-transport/src/constants.rs -+++ b/crates/rns-transport/src/constants.rs -@@ -47,6 +47,12 @@ - /// Max local rebroadcasts before stopping. - pub const LOCAL_REBROADCASTS_MAX: u32 = 2; - -+/// Ranked path slots retained per destination: one active route plus -+/// `MAX_PATH_SLOTS - 1` backups. Backups let a probe/link failure reroute -+/// without waiting for a fresh announce, and are bounded so a destination -+/// heard on many interfaces cannot grow the table without limit. -+pub const MAX_PATH_SLOTS: usize = 3; -+ - /// Path request timeout (seconds). - pub const PATH_REQUEST_TIMEOUT: f64 = 15.0; - -@@ -272,8 +278,74 @@ - Unknown = 0x00, - Unresponsive = 0x01, - Responsive = 0x02, -+} -+ -+/// Transport medium a path was learned over. -+/// -+/// Coarser than [`InterfaceMode`] on purpose: routing preference is only ever -+/// expressed as "radio" versus "network", so callers do not have to enumerate -+/// every mode. See `path_table::path_medium` for the mapping. -+#[derive(Debug, Clone, Copy, PartialEq, Eq)] -+pub enum PathMedium { -+ /// LoRa / RNode radio links. -+ Rf, -+ /// IP-style links — TCP/UDP/I2P hubs, gateways, boundaries, shared instances. -+ Network, -+} -+ -+impl PathMedium { -+ pub fn as_str(self) -> &'static str { -+ match self { -+ Self::Rf => "rf", -+ Self::Network => "network", +diff --git a/crates/rns-transport/src/actor/inbound.rs b/crates/rns-transport/src/actor/inbound.rs +index e63666c..830cfb2 100644 +--- a/crates/rns-transport/src/actor/inbound.rs ++++ b/crates/rns-transport/src/actor/inbound.rs +@@ -375,24 +375,51 @@ impl TransportActor { + .unwrap_or_default(); + let suppressed = + self.is_path_interface_suppressed(header.destination_hash, interface_id, now_f64()); +- let should_add = if suppressed { +- false +- } else if let Some(existing) = self.path_table.get(&header.destination_hash) { +- let random_seen = existing.has_random_blob(&announce_random_hash); +- let path_timebase = path_timebase_from_random_blobs(existing.random_blobs.iter()); +- if header.hops <= existing.hops { +- !random_seen && announce_emitted > path_timebase +- } else if existing.is_expired() || announce_emitted > path_timebase { +- !random_seen +- } else if announce_emitted == path_timebase { +- self.path_table.get_state(&header.destination_hash) +- == crate::constants::PathState::Unresponsive +- } else { +- false +- } +- } else { +- true ++ let candidate = crate::path_table::AnnouncedPath { ++ interface_id, ++ next_hop: header.transport_id, ++ hops: header.hops, ++ medium: crate::path_table::path_medium(iface_mode), ++ random_blob: announce_random_hash, ++ emitted: announce_emitted, ++ suppressed, + }; ++ let preference = self.effective_path_medium_preference(&header.destination_hash); ++ let rank = self ++ .path_table ++ .rank_announced(&header.destination_hash, &candidate, preference); ++ if rank == crate::path_table::PathRank::Reject { ++ debug!( ++ dest = hex::encode(header.destination_hash), ++ hops = header.hops, ++ announce_emitted, ++ interface_id, ++ suppressed, ++ "ignoring replayed or stale announce" ++ ); ++ return; + } -+ } ++ let should_add = rank == crate::path_table::PathRank::Activate; + -+ /// Parse the wire/RPC spelling produced by [`PathMedium::as_str`]. -+ pub fn from_str_opt(value: &str) -> Option { -+ match value.trim().to_ascii_lowercase().as_str() { -+ "rf" => Some(Self::Rf), -+ "network" => Some(Self::Network), -+ _ => None, ++ // For Header2 announces via a transport node, next_hop is the relay's ++ // hash from transport_id; for Header1 announces the destination is ++ // directly reachable and next_hop stays None. ++ let mut entry = crate::path_table::PathEntry::new( ++ header.transport_id, ++ header.hops, ++ interface_id, ++ iface_mode, ++ ); ++ if !random_blobs.contains(&announce_random_hash) { ++ if random_blobs.len() >= MAX_RANDOM_BLOBS { ++ random_blobs.pop_front(); ++ } ++ random_blobs.push_back(announce_random_hash); + } -+ } - } ++ entry.random_blobs = random_blobs; ++ // Store the announce packet hash so a later CacheRequest for this ++ // destination can replay the exact announce bytes. ++ entry.packet_hash = Some(rns_wire::hash::packet_hash(raw, header.flags.header_type)); -+/// Which medium should own the active path slot when both are reachable. -+/// -+/// `Lowest` applies no medium bias and ranks purely by hop count. The other -+/// two are "prefer if possible": when the preferred medium has no live slot, -+/// the best slot of the other medium becomes active without clearing the -+/// preference, so the preferred medium can reclaim the route later. -+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -+pub enum PathMediumPreference { -+ #[default] -+ Lowest, -+ Network, -+ Rf, -+} -+ -+impl PathMediumPreference { -+ pub fn as_str(self) -> &'static str { -+ match self { -+ Self::Lowest => "lowest", -+ Self::Network => "network", -+ Self::Rf => "rf", + if should_add { + // Announce-table update MUST precede path-table update — the dedup +@@ -434,26 +461,6 @@ impl TransportActor { + .insert(header.destination_hash, announce_entry); + } + +- // For Header2 announces via a transport node, next_hop is the relay's +- // hash from transport_id; for Header1 announces the destination is +- // directly reachable and next_hop stays None. +- let mut entry = crate::path_table::PathEntry::new( +- header.transport_id, +- header.hops, +- interface_id, +- iface_mode, +- ); +- if !random_blobs.contains(&announce_random_hash) { +- if random_blobs.len() >= MAX_RANDOM_BLOBS { +- random_blobs.pop_front(); +- } +- random_blobs.push_back(announce_random_hash); +- } +- entry.random_blobs = random_blobs; +- // Store the announce packet hash so a later CacheRequest for this +- // destination can replay the exact announce bytes. +- let announce_packet_hash = rns_wire::hash::packet_hash(raw, header.flags.header_type); +- entry.packet_hash = Some(announce_packet_hash); + let tunnel_path = crate::tunnel::TunnelPath { + timestamp: entry.timestamp, + next_hop: entry.next_hop, +@@ -462,7 +469,12 @@ impl TransportActor { + random_blobs: entry.random_blobs.iter().copied().collect(), + packet_hash: entry.packet_hash, + }; +- self.path_table.insert(header.destination_hash, entry); ++ self.path_table.upsert_ranked( ++ header.destination_hash, ++ entry, ++ crate::path_table::PathRank::Activate, ++ preference, ++ ); + if let Some(tunnel) = self.tunnel_table.get_mut_by_interface(interface_id) { + tunnel + .tunnel_paths +@@ -494,13 +506,24 @@ impl TransportActor { + "path learned from announce" + ); + } else { ++ // A route that loses the active slot can still be worth keeping: ++ // parking it in a backup slot lets a later probe or link failure ++ // reroute without a fresh announce. Everything else about the ++ // announce is still ignored — no rebroadcast, no handler dispatch, ++ // no announce-cache refresh — so replays stay inert. ++ let stored_hops = entry.hops; ++ self.path_table.upsert_ranked( ++ header.destination_hash, ++ entry, ++ crate::path_table::PathRank::Backup, ++ preference, ++ ); ++ self.state_dirty = true; + debug!( + dest = hex::encode(header.destination_hash), +- hops = header.hops, +- announce_emitted, ++ hops = stored_hops, + interface_id, +- suppressed, +- "ignoring replayed or stale announce" ++ "alternate path stored in backup slot" + ); + return; + } +diff --git a/crates/rns-transport/src/actor/mod.rs b/crates/rns-transport/src/actor/mod.rs +index 9670424..34b4128 100644 +--- a/crates/rns-transport/src/actor/mod.rs ++++ b/crates/rns-transport/src/actor/mod.rs +@@ -19,6 +19,7 @@ use crate::messages::{ + InterfaceEntry, InterfaceId, InterfaceRole, TransportMessage, msg_variant_name, + }; + use crate::path_table::PathTable; ++pub(crate) use crate::path_table::{announce_timebase, path_timebase_from_random_blobs}; + use crate::rate_limit::RateTable; + use crate::reverse_table::ReverseTable; + use crate::traffic::TrafficCounter; +@@ -99,6 +100,11 @@ pub struct TransportActor { + /// Used when a Direct LinkRequest timed out on one route, so the next + /// path request can discover alternates instead of instantly reusing it. + pub path_interface_suppressions: HashMap<([u8; 16], InterfaceId), f64>, ++ /// Global medium preference for the active path slot. ++ pub path_medium_preference: PathMediumPreference, ++ /// Per-destination medium pins overriding `path_medium_preference`. Only ++ /// pinned destinations appear here; clearing a pin removes the entry. ++ pub peer_medium_pins: HashMap<[u8; 16], PathMedium>, + last_discovery_pr_tx: f64, + /// External interface waiting for a path response from a local shared client. + /// Python calls this `pending_local_path_requests`. +@@ -334,6 +340,8 @@ impl TransportActor { + discovery_pr_tags: HashMap::new(), + pending_discovery_prs: VecDeque::new(), + path_interface_suppressions: HashMap::new(), ++ path_medium_preference: PathMediumPreference::default(), ++ peer_medium_pins: HashMap::new(), + last_discovery_pr_tx: 0.0, + pending_local_path_requests: HashMap::new(), + path_states: HashMap::new(), +@@ -1269,15 +1277,92 @@ impl TransportActor { + let until = now_f64() + duration; + self.path_interface_suppressions + .insert((dest, interface_id), until); ++ // Reroute onto the best remaining slot now: rediscovery would otherwise ++ // run with the failed route still installed. ++ let preference = self.effective_path_medium_preference(&dest); ++ let rerouted = self ++ .path_table ++ .suppress_interface(&dest, interface_id, preference); ++ if rerouted { ++ self.state_dirty = true; + } + debug!( + dest = %hex::encode(dest), + interface_id, + duration, ++ rerouted, + "temporarily suppressing path interface" + ); + true + } + ++ /// Preference in force for a destination: its pin if any, else the global ++ /// setting. ++ pub(super) fn effective_path_medium_preference(&self, dest: &[u8; 16]) -> PathMediumPreference { ++ crate::path_table::effective_path_medium_preference( ++ self.path_medium_preference, ++ self.peer_medium_pins.get(dest).copied(), ++ ) + } + -+ /// Parse the wire/RPC spelling produced by -+ /// [`PathMediumPreference::as_str`]. -+ pub fn from_str_opt(value: &str) -> Option { -+ match value.trim().to_ascii_lowercase().as_str() { -+ "lowest" => Some(Self::Lowest), -+ "network" => Some(Self::Network), -+ "rf" => Some(Self::Rf), -+ _ => None, ++ /// Set the global medium preference and re-rank every known destination so ++ /// the change applies without waiting for fresh announces. Returns the ++ /// number of destinations whose active route moved. ++ pub(super) fn set_path_medium_preference(&mut self, preference: PathMediumPreference) -> usize { ++ self.path_medium_preference = preference; ++ self.path_table.set_preference(preference); ++ let moved = self.rerank_all_paths(); ++ debug!( ++ preference = ?preference, ++ moved, "path medium preference updated" ++ ); ++ moved ++ } ++ ++ /// Pin or unpin a destination to a medium. Returns `true` when the pin ++ /// changed the destination's active route. ++ pub(super) fn set_peer_medium_pin(&mut self, dest: [u8; 16], pin: Option) -> bool { ++ match pin { ++ Some(medium) => { ++ self.peer_medium_pins.insert(dest, medium); ++ } ++ None => { ++ self.peer_medium_pins.remove(&dest); ++ } ++ } ++ let preference = self.effective_path_medium_preference(&dest); ++ let moved = self.path_table.rerank(&dest, preference); ++ if moved { ++ self.state_dirty = true; + } ++ debug!( ++ dest = %hex::encode(dest), ++ pin = ?pin, ++ moved, ++ "peer medium pin updated" ++ ); ++ moved + } -+} + - /// Configured operating mode for an interface. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum InterfaceMode { ---- a/crates/rns-transport/src/path_table.rs -+++ b/crates/rns-transport/src/path_table.rs -@@ -8,6 +8,8 @@ - use crate::messages::InterfaceId; - use rns_wire::types::DestHash; - -+pub use crate::constants::{MAX_PATH_SLOTS, PathMedium, PathMediumPreference}; ++ fn rerank_all_paths(&mut self) -> usize { ++ let dests: Vec<[u8; 16]> = self ++ .path_table ++ .iter() ++ .map(|(hash, _)| hash.into_bytes()) ++ .collect(); ++ let mut moved = 0usize; ++ for dest in dests { ++ let preference = self.effective_path_medium_preference(&dest); ++ if self.path_table.rerank(&dest, preference) { ++ moved += 1; ++ } ++ } ++ if moved > 0 { ++ self.state_dirty = true; ++ } ++ moved ++ } + - /// One known path to a destination. - #[derive(Debug, Clone)] - pub struct PathEntry { -@@ -22,6 +24,9 @@ - /// anti-replay memory on long-lived paths. - pub random_blobs: VecDeque<[u8; 10]>, - pub interface_id: InterfaceId, -+ /// Medium of the interface this path was learned on, captured at insert so -+ /// ranking never has to reach back into the interface table. -+ pub medium: PathMedium, - /// Hash of the cached announce packet — used to satisfy CacheRequest - /// without holding the full packet bytes. - pub packet_hash: Option<[u8; 32]>, -@@ -44,6 +49,7 @@ - expires, - random_blobs: VecDeque::new(), - interface_id, -+ medium: path_medium(interface_mode), - packet_hash: None, - } - } -@@ -86,20 +92,76 @@ - } + fn is_path_interface_suppressed( + &mut self, + dest: [u8; 16], +@@ -1731,16 +1816,6 @@ fn interface_marked_offline(entry: &InterfaceEntry) -> bool { + .unwrap_or(false) } --/// Destination-hash → path mapping plus a parallel liveness state map so we --/// can probe unresponsive paths without rewriting the entries. -+/// A path candidate carried by an inbound announce, before it becomes a -+/// [`PathEntry`]. Ranking is a pure read over this so callers can order their -+/// own bookkeeping (announce table, rebroadcast dedup) before mutating. -+#[derive(Debug, Clone, Copy)] -+pub struct AnnouncedPath { -+ pub interface_id: InterfaceId, -+ /// Next-hop transport id; `None` for a directly reachable destination. -+ pub next_hop: Option<[u8; 16]>, -+ pub hops: u8, -+ pub medium: PathMedium, -+ /// Announce random blob — the anti-replay key. -+ pub random_blob: [u8; 10], -+ /// Announce timebase decoded from `random_blob` (see `announce_timebase`). -+ pub emitted: u64, -+ /// The learning interface is temporarily barred from installing paths for -+ /// this destination (probe or link-establishment failure). -+ pub suppressed: bool, -+} -+ -+/// Where a ranked candidate belongs. -+#[derive(Debug, Clone, Copy, PartialEq, Eq)] -+pub enum PathRank { -+ /// Candidate takes the active slot; the previous active is demoted to a -+ /// backup unless it is the same route. -+ Activate, -+ /// Candidate is a usable alternate but must not displace the active slot. -+ Backup, -+ /// Candidate is dropped — suppressed interface, or a replay of the route -+ /// that already owns the active slot. -+ Reject, -+} -+ -+/// Destination-hash → ranked path slots, plus a parallel liveness state map so -+/// we can probe unresponsive paths without rewriting the entries. -+/// -+/// `entries` holds the active route per destination — every legacy accessor -+/// (`get`, `has_path`, `hops_to`, `iter`) reads it, so routing is unchanged for -+/// callers that do not care about alternates. `backups` holds up to -+/// `MAX_PATH_SLOTS - 1` ranked alternates, best first, and only ever exists -+/// alongside an active entry. - #[derive(Clone)] - pub struct PathTable { - entries: HashMap, -+ backups: HashMap>, - states: HashMap, -+ /// Global medium preference. Ranking calls that know the destination pass -+ /// their own effective preference; cull and interface-drop promotions have -+ /// no destination context and fall back to this. -+ preference: PathMediumPreference, - } +-fn announce_timebase(random_blob: &[u8; 10]) -> u64 { +- let mut emitted = [0u8; 8]; +- emitted[3..].copy_from_slice(&random_blob[5..10]); +- u64::from_be_bytes(emitted) +-} +- +-fn path_timebase_from_random_blobs<'a>(random_blobs: impl Iterator) -> u64 { +- random_blobs.map(announce_timebase).max().unwrap_or(0) +-} +- + /// Random jitter in `[0, PATHFINDER_RW)` for announce rebroadcast timing. + /// The jitter avoids synchronized retransmits when many nodes learn the + /// same announce in the same tick. +@@ -2043,6 +2118,14 @@ mod tests { + (entry, rx) + } - impl PathTable { - pub fn new() -> Self { - Self { - entries: HashMap::new(), -+ backups: HashMap::new(), - states: HashMap::new(), -+ preference: PathMediumPreference::default(), - } -+ } -+ -+ /// Set the global medium preference used by promotions that have no -+ /// per-destination pin available. Callers that own pins should follow this -+ /// with a `rerank` pass over known destinations. -+ pub fn set_preference(&mut self, preference: PathMediumPreference) { -+ self.preference = preference; ++ /// Radio interfaces run in access-point mode, so paths learned here rank as ++ /// [`PathMedium::Rf`]. ++ fn make_rf_test_interface(name: &str) -> (InterfaceEntry, mpsc::Receiver) { ++ let (mut entry, rx) = make_test_interface(name); ++ entry.mode = InterfaceMode::AccessPoint; ++ (entry, rx) + } + -+ pub fn preference(&self) -> PathMediumPreference { -+ self.preference + #[test] + fn test_actor_creation() { + let (actor, _tx) = TransportActor::new(); +@@ -4383,6 +4466,9 @@ mod tests { + assert!(actor.state_dirty); } - /// Insert or replace a path entry. The parallel liveness state is -@@ -113,9 +175,15 @@ - /// sites (announce install, PathResponse install, tunnel path restore, disk - /// load), so the invariant is enforced here: state is never older than the - /// entry it describes. -+ /// -+ /// Backup slots for the same route are dropped so the alternate ring never -+ /// shadows the freshly installed active path; alternates on other -+ /// interfaces are left alone. Use [`PathTable::upsert_ranked`] when the old -+ /// active should be demoted rather than discarded. - pub fn insert(&mut self, dest_hash: impl Into, entry: PathEntry) { - let hash: DestHash = dest_hash.into(); - self.states.remove(&hash); -+ self.drop_backup_slot(&hash, entry.interface_id, entry.next_hop); - self.entries.insert(hash, entry); - } ++ /// A replay of the same announce on another interface that is *no closer* ++ /// to the source keeps its hands off the active route — it only earns a ++ /// backup slot, and none of the announce-driven side effects fire. + #[test] + fn test_replayed_announce_random_blob_does_not_replace_path() { + let (mut actor, _tx) = TransportActor::new(); +@@ -4395,9 +4481,9 @@ mod tests { + let identity = rns_identity::identity::Identity::new(); + let blob = random_blob(0xA1, 100); + let (raw_first, dest_hash) = +- make_announce_for_with_random_blob(&identity, "test.replay.same", 3, blob); +- let (raw_replay, _) = + make_announce_for_with_random_blob(&identity, "test.replay.same", 1, blob); ++ let (raw_replay, _) = ++ make_announce_for_with_random_blob(&identity, "test.replay.same", 3, blob); + let (htx, mut hrx) = mpsc::channel(8); + actor.announce_handlers.push(AnnounceHandlerRegistration { + id: crate::messages::AnnounceHandlerId(0), +@@ -4415,7 +4501,7 @@ mod tests { + q: None, + }); + let first_event = hrx.try_recv().expect("fresh announce should dispatch"); +- assert_eq!(first_event.hops, 4); ++ assert_eq!(first_event.hops, 2); -@@ -145,29 +213,39 @@ - self.get_live(dest_hash).map(|e| e.hops) - } + actor.on_inbound(InboundPacket { + raw: raw_replay, +@@ -4426,13 +4512,19 @@ mod tests { + }); -+ /// Remove every slot for a destination, active and backups. - pub fn remove(&mut self, dest_hash: &[u8; 16]) -> Option { - self.states.remove(dest_hash); -+ self.backups.remove(dest_hash); - self.entries.remove(dest_hash) + let path = actor.path_table.get(&dest_hash).unwrap(); +- assert_eq!(path.hops, 4); ++ assert_eq!(path.hops, 2); + assert_eq!(path.interface_id, 1); + assert_eq!(path.random_blobs.len(), 1); + assert!(path.has_random_blob(&blob)); ++ assert_eq!( ++ actor.path_table.backups(&dest_hash).len(), ++ 1, ++ "the longer route is still worth keeping as an alternate" ++ ); ++ assert_eq!(actor.path_table.backups(&dest_hash)[0].interface_id, 2); + assert_eq!( + actor.recent_announces.get(&dest_hash).unwrap().hops, +- 4, ++ 2, + "replayed announces must not refresh recent announce state" + ); + assert!( +@@ -4450,6 +4542,334 @@ mod tests { + ); } -- /// Drop every path whose interface id matches — used when an interface -- /// goes down so we don't keep routing through a dead transport. -+ /// Drop every slot whose interface id matches — used when an interface goes -+ /// down so we don't keep routing through a dead transport. Destinations -+ /// whose active path is dropped promote their best live alternate, so a -+ /// multi-homed peer stays routable. Returns the number of active paths -+ /// removed (promoted or not). - pub fn drop_all_via(&mut self, interface_id: InterfaceId) -> usize { -- let before = self.entries.len(); -- self.entries.retain(|_, e| e.interface_id != interface_id); -- before - self.entries.len() -+ self.retain_backups(|entry| entry.interface_id != interface_id); -+ self.drop_active_where(|entry| entry.interface_id == interface_id) - } - - pub fn drop_all_via_next_hop(&mut self, next_hop: &[u8; 16]) -> usize { -- let before = self.entries.len(); -- self.entries.retain(|_, e| e.next_hop != Some(*next_hop)); -- before - self.entries.len() -+ let next_hop = *next_hop; -+ self.retain_backups(|entry| entry.next_hop != Some(next_hop)); -+ self.drop_active_where(|entry| entry.next_hop == Some(next_hop)) - } - -- /// Force-expire a path and cull immediately. Useful when the caller -- /// already knows the path is bad (e.g. a link proof failed) and -+ /// Force-drop a destination's route and cull immediately. Useful when the -+ /// caller already knows the path is bad (e.g. a link proof failed) and - /// shouldn't wait for the periodic cull cycle. -+ /// -+ /// Backups are dropped too: callers use this to make a destination -+ /// *unrouted* (path rediscovery, `DropPath` RPC), so silently promoting an -+ /// alternate would defeat the request. Use -+ /// [`PathTable::suppress_interface`] to reroute onto a backup instead. - pub fn expire(&mut self, dest_hash: &[u8; 16]) -> bool { -+ self.backups.remove(dest_hash); - if let Some(entry) = self.entries.get_mut(dest_hash) { - entry.expires = 0.0; - self.cull_expired(); -@@ -189,17 +267,17 @@ - } - - /// Full cull pass. Prefer `cull_expired_batch` on the hot path to bound -- /// per-tick work. -+ /// per-tick work. Expired alternates are dropped first so a destination -+ /// whose active path expired can only promote a still-live alternate. -+ /// Returns the number of expired active paths removed. - pub fn cull_expired(&mut self) -> usize { -- let before = self.entries.len(); -- self.entries.retain(|_, entry| !entry.is_expired()); -- self.states -- .retain(|hash, _| self.entries.contains_key(hash)); -- before - self.entries.len() -+ self.retain_backups(|entry| !entry.is_expired()); -+ self.drop_active_where(|entry| entry.is_expired()) - } - - /// Batched cull — removes at most `limit` expired entries so the actor -- /// cannot stall on a very large path table. -+ /// cannot stall on a very large path table. Alternates are only inspected -+ /// for the destinations in this batch, keeping per-tick work bounded. - pub fn cull_expired_batch(&mut self, limit: usize) -> usize { - let to_remove: Vec = self - .entries -@@ -212,21 +290,321 @@ - for hash in &to_remove { - self.entries.remove(hash.as_bytes()); - self.states.remove(hash.as_bytes()); -+ self.promote_best_backup(hash, self.preference); - } - count - } - -- /// Drop paths whose interface id is no longer active. -+ /// Drop slots whose interface id is no longer active, promoting alternates -+ /// on live interfaces where possible. - pub fn cull_dead_interfaces( - &mut self, - active_interfaces: &std::collections::HashSet, - ) -> usize { -- let before = self.entries.len(); -- self.entries -- .retain(|_, entry| active_interfaces.contains(&entry.interface_id)); -+ self.retain_backups(|entry| active_interfaces.contains(&entry.interface_id)); -+ self.drop_active_where(|entry| !active_interfaces.contains(&entry.interface_id)) ++ /// Same announce, fewer hops, second interface: the shorter route wins even ++ /// though the random blob was already seen. This is the multi-TCP / RF+TCP ++ /// case that the pre-multipath replay guard used to block. ++ #[test] ++ fn replayed_announce_with_fewer_hops_takes_over_and_demotes_the_old_route() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (entry1, _rx1) = make_test_interface("iface1"); ++ let (entry2, _rx2) = make_test_interface("iface2"); ++ actor.interfaces.insert(1, entry1); ++ actor.interfaces.insert(2, entry2); ++ ++ let identity = rns_identity::identity::Identity::new(); ++ let blob = random_blob(0xA5, 100); ++ let (raw_far, dest_hash) = ++ make_announce_for_with_random_blob(&identity, "test.replay.closer", 3, blob); ++ let (raw_close, _) = ++ make_announce_for_with_random_blob(&identity, "test.replay.closer", 1, blob); ++ ++ actor.on_inbound(InboundPacket { ++ raw: raw_far, ++ interface_id: 1, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ actor.on_inbound(InboundPacket { ++ raw: raw_close, ++ interface_id: 2, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ ++ let path = actor.path_table.get(&dest_hash).unwrap(); ++ assert_eq!(path.hops, 2); ++ assert_eq!(path.interface_id, 2); ++ let backups = actor.path_table.backups(&dest_hash); ++ assert_eq!(backups.len(), 1); ++ assert_eq!(backups[0].interface_id, 1); ++ assert_eq!(backups[0].hops, 4); + } + -+ // ----- ranked multi-path slots ------------------------------------------- ++ /// A failed probe or link attempt reroutes onto the backup immediately — ++ /// no path request, no fresh announce. ++ #[test] ++ fn suppressing_the_current_path_interface_promotes_the_backup_route() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (entry1, _rx1) = make_test_interface("iface1"); ++ let (entry2, _rx2) = make_test_interface("iface2"); ++ actor.interfaces.insert(1, entry1); ++ actor.interfaces.insert(2, entry2); + -+ /// Every known slot for a destination, active first, then alternates in -+ /// rank order. Empty when the destination is unknown. -+ pub fn slots(&self, dest_hash: &[u8; 16]) -> Vec<&PathEntry> { -+ let mut slots = Vec::new(); -+ if let Some(active) = self.entries.get(dest_hash) { -+ slots.push(active); -+ } else { -+ return slots; ++ let identity = rns_identity::identity::Identity::new(); ++ let blob = random_blob(0xA6, 100); ++ let (raw_close, dest_hash) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.failover", 1, blob); ++ let (raw_far, _) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.failover", 4, blob); ++ for (raw, interface_id) in [(raw_close, 1u64), (raw_far, 2)] { ++ actor.on_inbound(InboundPacket { ++ raw, ++ interface_id, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); + } -+ if let Some(backups) = self.backups.get(dest_hash) { -+ slots.extend(backups.iter()); ++ assert_eq!(actor.path_table.get(&dest_hash).unwrap().interface_id, 1); ++ assert_eq!(actor.path_table.backups(&dest_hash).len(), 1); ++ ++ match actor.handle_query(TransportQuery::SuppressCurrentPathInterface { ++ dest: dest_hash, ++ duration: 30.0, ++ }) { ++ TransportQueryResponse::BoolResult(true) => {} ++ other => panic!("expected current path-interface suppression, got {other:?}"), + } -+ slots -+ } + -+ /// Ranked alternates for a destination, best first. -+ pub fn backups(&self, dest_hash: &[u8; 16]) -> &[PathEntry] { -+ self.backups -+ .get(dest_hash) -+ .map(|backups| backups.as_slice()) -+ .unwrap_or(&[]) ++ let path = actor ++ .path_table ++ .get(&dest_hash) ++ .expect("the backup route should now carry the destination"); ++ assert_eq!(path.interface_id, 2); ++ assert_eq!(path.hops, 5); ++ assert!(actor.path_table.backups(&dest_hash).is_empty()); + } + -+ /// Decide where a freshly heard announce belongs. Pure read — apply the -+ /// result with [`PathTable::upsert_ranked`]. -+ /// -+ /// `preference` is the *effective* preference for this destination (a -+ /// per-destination pin already resolved against the global setting). -+ /// -+ /// Layering, outermost first: -+ /// 1. A suppressed interface is always rejected. -+ /// 2. A live path on the preferred medium is never displaced by the other -+ /// medium, and a preferred-medium candidate always reclaims the active -+ /// slot from the other medium. -+ /// 3. Within one medium rank, a strictly shorter route on a *different* -+ /// interface or next hop wins — this is what lets a second TCP hub or an -+ /// RF neighbour take over when it hears the same announce closer to the -+ /// source. -+ /// 4. Otherwise the pre-multipath announce rules decide (newer announce -+ /// timebase, unseen random blob, expired or unresponsive path), so equal -+ /// hop counts still cannot flap the route. -+ pub fn rank_announced( -+ &self, -+ dest_hash: &[u8; 16], -+ candidate: &AnnouncedPath, -+ preference: PathMediumPreference, -+ ) -> PathRank { -+ if candidate.suppressed { -+ return PathRank::Reject; ++ #[test] ++ fn network_preference_keeps_an_rf_shortcut_out_of_the_active_slot() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (tcp, _tcp_rx) = make_test_interface("tcp"); ++ let (rf, _rf_rx) = make_rf_test_interface("rnode"); ++ actor.interfaces.insert(1, tcp); ++ actor.interfaces.insert(2, rf); ++ assert_eq!( ++ actor.set_path_medium_preference(PathMediumPreference::Network), ++ 0 ++ ); ++ ++ let identity = rns_identity::identity::Identity::new(); ++ let blob = random_blob(0xA7, 100); ++ let (raw_tcp, dest_hash) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.netpref", 4, blob); ++ let (raw_rf, _) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.netpref", 0, blob); ++ for (raw, interface_id) in [(raw_tcp, 1u64), (raw_rf, 2)] { ++ actor.on_inbound(InboundPacket { ++ raw, ++ interface_id, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); + } -+ let Some(active) = self.entries.get(dest_hash) else { -+ return PathRank::Activate; -+ }; + -+ let random_seen = active.has_random_blob(&candidate.random_blob); -+ let path_timebase = path_timebase_from_random_blobs(active.random_blobs.iter()); -+ let timebase_allows = if candidate.hops <= active.hops { -+ !random_seen && candidate.emitted > path_timebase -+ } else if active.is_expired() || candidate.emitted > path_timebase { -+ !random_seen -+ } else if candidate.emitted == path_timebase { -+ self.get_state(dest_hash) == PathState::Unresponsive -+ } else { -+ false -+ }; ++ let path = actor.path_table.get(&dest_hash).unwrap(); ++ assert_eq!(path.medium, PathMedium::Network); ++ assert_eq!(path.interface_id, 1); ++ let backups = actor.path_table.backups(&dest_hash); ++ assert_eq!(backups.len(), 1); ++ assert_eq!(backups[0].medium, PathMedium::Rf); ++ assert_eq!(backups[0].hops, 1); ++ } + -+ let same_route = is_same_route(active, candidate.interface_id, candidate.next_hop); -+ let candidate_rank = medium_rank(candidate.medium, preference); -+ let active_rank = medium_rank(active.medium, preference); ++ /// A per-destination RF pin beats the global `Lowest` default, survives an ++ /// RF failure, and reclaims the route once RF is heard again. ++ #[test] ++ fn rf_pin_overrides_global_preference_and_survives_failover() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (tcp, _tcp_rx) = make_test_interface("tcp"); ++ let (rf, _rf_rx) = make_rf_test_interface("rnode"); ++ actor.interfaces.insert(1, tcp); ++ actor.interfaces.insert(2, rf); + -+ let activate = if active.is_expired() { -+ // Nothing live to protect: prefer-if-possible means the other -+ // medium may take over until the preferred one is heard again. -+ timebase_allows -+ } else if candidate_rank < active_rank { -+ true -+ } else if candidate_rank > active_rank { -+ false -+ } else if !same_route && candidate.hops < active.hops { -+ true -+ } else { -+ timebase_allows -+ }; ++ let identity = rns_identity::identity::Identity::new(); ++ let (raw_tcp, dest_hash) = make_announce_for_with_random_blob( ++ &identity, ++ "test.multipath.pin", ++ 0, ++ random_blob(0xA8, 100), ++ ); ++ let (raw_rf, _) = make_announce_for_with_random_blob( ++ &identity, ++ "test.multipath.pin", ++ 5, ++ random_blob(0xA9, 101), ++ ); ++ assert!(!actor.set_peer_medium_pin(dest_hash, Some(PathMedium::Rf))); ++ for (raw, interface_id) in [(raw_tcp, 1u64), (raw_rf, 2)] { ++ actor.on_inbound(InboundPacket { ++ raw, ++ interface_id, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ } + -+ if activate { -+ PathRank::Activate -+ } else if same_route { -+ PathRank::Reject -+ } else { -+ PathRank::Backup ++ // The 6-hop RF route wins over a 1-hop network route because of the pin. ++ assert_eq!( ++ actor.path_table.get(&dest_hash).unwrap().medium, ++ PathMedium::Rf ++ ); ++ ++ match actor.handle_query(TransportQuery::SuppressCurrentPathInterface { ++ dest: dest_hash, ++ duration: 30.0, ++ }) { ++ TransportQueryResponse::BoolResult(true) => {} ++ other => panic!("expected suppression, got {other:?}"), + } ++ assert_eq!( ++ actor.path_table.get(&dest_hash).unwrap().medium, ++ PathMedium::Network, ++ "with no live RF slot the network route must carry traffic" ++ ); ++ assert_eq!( ++ actor.peer_medium_pins.get(&dest_hash).copied(), ++ Some(PathMedium::Rf), ++ "failover must not clear the pin" ++ ); ++ ++ // Once suppression lapses, a fresh RF announce reclaims the route. ++ actor.path_interface_suppressions.clear(); ++ let (raw_rf_again, _) = make_announce_for_with_random_blob( ++ &identity, ++ "test.multipath.pin", ++ 5, ++ random_blob(0xAA, 102), ++ ); ++ actor.on_inbound(InboundPacket { ++ raw: raw_rf_again, ++ interface_id: 2, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); ++ assert_eq!( ++ actor.path_table.get(&dest_hash).unwrap().medium, ++ PathMedium::Rf ++ ); + } + -+ /// Apply a [`PathRank`] decision. Returns `true` when the destination's -+ /// active route changed — a fresh install, or a different interface / -+ /// next hop than before — so callers know when to re-probe or notify. -+ /// -+ /// `Activate` demotes the previous active into the alternate ring; -+ /// `Backup` refreshes the matching alternate slot or inserts a new one, -+ /// evicting the worst-ranked slot once `MAX_PATH_SLOTS` is reached. -+ pub fn upsert_ranked( -+ &mut self, -+ dest_hash: impl Into, -+ entry: PathEntry, -+ rank: PathRank, -+ preference: PathMediumPreference, -+ ) -> bool { -+ let hash: DestHash = dest_hash.into(); -+ match rank { -+ PathRank::Reject => false, -+ PathRank::Activate => { -+ let interface_id = entry.interface_id; -+ let next_hop = entry.next_hop; -+ let previous = self.entries.insert(hash, entry); -+ self.states.remove(&hash); -+ self.drop_backup_slot(&hash, interface_id, next_hop); -+ let changed = match previous { -+ Some(previous) => { -+ let changed = !is_same_route(&previous, interface_id, next_hop); -+ if changed { -+ self.backups.entry(hash).or_default().push(previous); -+ } -+ changed -+ } -+ None => true, -+ }; -+ self.resort_backups(&hash, preference); -+ changed -+ } -+ PathRank::Backup => { -+ if !self.entries.contains_key(hash.as_bytes()) { -+ // No active route to back up — install directly so the -+ // alternate ring never holds the only known path. -+ self.states.remove(&hash); -+ self.entries.insert(hash, entry); -+ return true; -+ } -+ let backups = self.backups.entry(hash).or_default(); -+ match backups -+ .iter_mut() -+ .find(|slot| is_same_route(slot, entry.interface_id, entry.next_hop)) -+ { -+ Some(slot) => *slot = entry, -+ None => backups.push(entry), -+ } -+ self.resort_backups(&hash, preference); -+ false -+ } ++ #[test] ++ fn changing_the_global_preference_reranks_known_destinations() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (tcp, _tcp_rx) = make_test_interface("tcp"); ++ let (rf, _rf_rx) = make_rf_test_interface("rnode"); ++ actor.interfaces.insert(1, tcp); ++ actor.interfaces.insert(2, rf); ++ ++ let identity = rns_identity::identity::Identity::new(); ++ let blob = random_blob(0xAB, 100); ++ let (raw_rf, dest_hash) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.rerank", 0, blob); ++ let (raw_tcp, _) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.rerank", 4, blob); ++ for (raw, interface_id) in [(raw_rf, 2u64), (raw_tcp, 1)] { ++ actor.on_inbound(InboundPacket { ++ raw, ++ interface_id, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); + } ++ assert_eq!( ++ actor.path_table.get(&dest_hash).unwrap().medium, ++ PathMedium::Rf, ++ "Lowest preference should pick the 1-hop RF route" ++ ); ++ ++ assert_eq!( ++ actor.set_path_medium_preference(PathMediumPreference::Network), ++ 1 ++ ); ++ assert_eq!( ++ actor.path_table.get(&dest_hash).unwrap().medium, ++ PathMedium::Network ++ ); ++ assert_eq!( ++ actor.path_table.backups(&dest_hash)[0].medium, ++ PathMedium::Rf ++ ); + } + -+ /// Re-rank a destination's slots after a preference or pin change, without -+ /// waiting for a new announce. Returns `true` when the active route moved. -+ /// Ties keep the incumbent active so a preference write cannot flap a route. -+ pub fn rerank(&mut self, dest_hash: &[u8; 16], preference: PathMediumPreference) -> bool { -+ let hash: DestHash = (*dest_hash).into(); -+ if !self.backups.contains_key(&hash) { -+ return false; -+ } -+ let Some(active) = self.entries.remove(&hash) else { -+ self.backups.remove(&hash); -+ return false; -+ }; -+ let previous = (active.interface_id, active.next_hop); -+ let incumbent_rank = slot_rank_key(&active, preference); -+ let mut slots = vec![active]; -+ slots.extend(self.backups.remove(&hash).unwrap_or_default()); -+ sort_slots(&mut slots, preference); -+ if slot_rank_key(&slots[0], preference) >= incumbent_rank { -+ // Nothing strictly better — keep the incumbent to avoid a flap. -+ let position = slots -+ .iter() -+ .position(|entry| is_same_route(entry, previous.0, previous.1)) -+ .unwrap_or(0); -+ let incumbent = slots.remove(position); -+ slots.insert(0, incumbent); -+ } -+ let best = slots.remove(0); -+ let changed = !is_same_route(&best, previous.0, previous.1); -+ if changed { -+ self.states.remove(&hash); -+ } -+ self.entries.insert(hash, best); -+ if !slots.is_empty() { -+ self.backups.insert(hash, slots); ++ #[test] ++ fn get_path_slots_reports_ranked_slots_and_the_pin() { ++ let (mut actor, _tx) = TransportActor::new(); ++ actor.is_transport_enabled = true; ++ let (tcp, _tcp_rx) = make_test_interface("tcp"); ++ let (rf, _rf_rx) = make_rf_test_interface("rnode"); ++ actor.interfaces.insert(1, tcp); ++ actor.interfaces.insert(2, rf); ++ ++ let identity = rns_identity::identity::Identity::new(); ++ let blob = random_blob(0xAC, 100); ++ let (raw_tcp, dest_hash) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.slots", 1, blob); ++ let (raw_rf, _) = ++ make_announce_for_with_random_blob(&identity, "test.multipath.slots", 4, blob); ++ for (raw, interface_id) in [(raw_tcp, 1u64), (raw_rf, 2)] { ++ actor.on_inbound(InboundPacket { ++ raw, ++ interface_id, ++ rssi: None, ++ snr: None, ++ q: None, ++ }); + } -+ self.resort_backups(&hash, preference); -+ changed -+ } ++ assert!(actor.set_peer_medium_pin(dest_hash, Some(PathMedium::Rf))); + -+ /// Drop every slot for a destination that goes through `interface_id` and -+ /// promote the best remaining live alternate. Called when a probe or link -+ /// attempt over the active interface fails, so the reroute happens before -+ /// rediscovery rather than after it. -+ /// -+ /// Returns `true` when the active slot was owned by `interface_id` — it is -+ /// now either a promoted alternate or absent. -+ pub fn suppress_interface( -+ &mut self, -+ dest_hash: &[u8; 16], -+ interface_id: InterfaceId, -+ preference: PathMediumPreference, -+ ) -> bool { -+ let hash: DestHash = (*dest_hash).into(); -+ if let Some(backups) = self.backups.get_mut(&hash) { -+ backups.retain(|entry| entry.interface_id != interface_id); -+ if backups.is_empty() { -+ self.backups.remove(&hash); ++ match actor.handle_query(TransportQuery::GetPathSlots { dest: dest_hash }) { ++ TransportQueryResponse::PathSlots(entry) => { ++ assert_eq!(entry.dest, dest_hash); ++ assert_eq!(entry.pin, Some(PathMedium::Rf)); ++ assert_eq!(entry.preference, PathMediumPreference::Rf); ++ assert_eq!(entry.slots.len(), 2); ++ assert!(entry.slots[0].active); ++ assert_eq!(entry.slots[0].medium, PathMedium::Rf); ++ assert_eq!(entry.slots[0].interface, "rnode"); ++ assert!(!entry.slots[0].expired); ++ assert!(!entry.slots[1].active); ++ assert_eq!(entry.slots[1].medium, PathMedium::Network); ++ assert_eq!(entry.slots[1].interface, "tcp"); + } ++ other => panic!("expected PathSlots, got {other:?}"), + } -+ let owns_active = self -+ .entries -+ .get(&hash) -+ .is_some_and(|entry| entry.interface_id == interface_id); -+ if !owns_active { -+ return false; -+ } -+ self.entries.remove(&hash); -+ self.states.remove(&hash); -+ self.promote_best_backup(&hash, preference); -+ true -+ } + -+ /// Promote the best live alternate into an empty active slot. Returns -+ /// `true` when a route was promoted. -+ fn promote_best_backup(&mut self, hash: &DestHash, preference: PathMediumPreference) -> bool { -+ if self.entries.contains_key(hash.as_bytes()) { -+ return false; -+ } -+ let Some(mut backups) = self.backups.remove(hash) else { -+ return false; -+ }; -+ backups.retain(|entry| !entry.is_expired()); -+ sort_slots(&mut backups, preference); -+ if backups.is_empty() { -+ return false; -+ } -+ let promoted = backups.remove(0); -+ if !backups.is_empty() { -+ self.backups.insert(*hash, backups); ++ match actor.handle_query(TransportQuery::GetPathSlots { dest: [0xFE; 16] }) { ++ TransportQueryResponse::PathSlots(entry) => { ++ assert!(entry.slots.is_empty()); ++ assert_eq!(entry.pin, None); ++ assert_eq!(entry.preference, PathMediumPreference::Lowest); ++ } ++ other => panic!("expected PathSlots, got {other:?}"), + } -+ // A promoted route is not the one the old liveness reading described. -+ self.states.remove(hash); -+ self.entries.insert(*hash, promoted); -+ true + } + -+ fn resort_backups(&mut self, hash: &DestHash, preference: PathMediumPreference) { -+ let Some(backups) = self.backups.get_mut(hash) else { -+ return; -+ }; -+ sort_slots(backups, preference); -+ backups.truncate(MAX_PATH_SLOTS.saturating_sub(1)); -+ if backups.is_empty() { -+ self.backups.remove(hash); -+ } -+ } ++ #[test] ++ fn clearing_a_peer_medium_pin_restores_the_global_preference() { ++ let (mut actor, _tx) = TransportActor::new(); ++ let dest = [0xCD; 16]; ++ actor.set_peer_medium_pin(dest, Some(PathMedium::Network)); ++ assert_eq!( ++ actor.effective_path_medium_preference(&dest), ++ PathMediumPreference::Network ++ ); + -+ fn drop_backup_slot( -+ &mut self, -+ hash: &DestHash, -+ interface_id: InterfaceId, -+ next_hop: Option<[u8; 16]>, -+ ) { -+ let Some(backups) = self.backups.get_mut(hash) else { -+ return; -+ }; -+ backups.retain(|entry| !is_same_route(entry, interface_id, next_hop)); -+ if backups.is_empty() { -+ self.backups.remove(hash); -+ } -+ } ++ actor.set_peer_medium_pin(dest, None); ++ assert!(actor.peer_medium_pins.is_empty()); ++ assert_eq!( ++ actor.effective_path_medium_preference(&dest), ++ PathMediumPreference::Lowest ++ ); + -+ fn retain_backups(&mut self, keep: impl Fn(&PathEntry) -> bool) { -+ self.backups.retain(|_, backups| { -+ backups.retain(&keep); -+ !backups.is_empty() -+ }); ++ actor.set_path_medium_preference(PathMediumPreference::Rf); ++ assert_eq!( ++ actor.effective_path_medium_preference(&dest), ++ PathMediumPreference::Rf ++ ); + } + -+ /// Remove every active entry matching `drop`, promoting the best live -+ /// alternate for each. Returns the number of active entries removed. -+ fn drop_active_where(&mut self, drop: impl Fn(&PathEntry) -> bool) -> usize { -+ let hashes: Vec = self -+ .entries -+ .iter() -+ .filter(|(_, entry)| drop(entry)) -+ .map(|(hash, _)| *hash) -+ .collect(); -+ let preference = self.preference; -+ for hash in &hashes { -+ self.entries.remove(hash.as_bytes()); -+ self.states.remove(hash.as_bytes()); -+ self.promote_best_backup(hash, preference); -+ } - self.states - .retain(|hash, _| self.entries.contains_key(hash)); -- before - self.entries.len() -+ hashes.len() - } - - pub fn len(&self) -> usize { -@@ -263,10 +641,153 @@ - } - } + #[test] + fn test_newer_equal_or_higher_hop_announce_replaces_path() { + let (mut actor, _tx) = TransportActor::new(); +@@ -7377,6 +7797,7 @@ mod tests { + expires: now - 1.0, + random_blobs: Default::default(), + interface_id: 7, ++ medium: crate::path_table::PathMedium::Network, + packet_hash: None, + }, + ); +diff --git a/crates/rns-transport/src/actor/outbound.rs b/crates/rns-transport/src/actor/outbound.rs +index 4fd8692..82470e8 100644 +--- a/crates/rns-transport/src/actor/outbound.rs ++++ b/crates/rns-transport/src/actor/outbound.rs +@@ -925,6 +925,12 @@ impl TransportActor { + expires: tunnel_path.expires, + random_blobs: tunnel_path.random_blobs.iter().copied().collect(), + interface_id, ++ medium: crate::path_table::path_medium( ++ self.interfaces ++ .get(&interface_id) ++ .map(|entry| entry.mode) ++ .unwrap_or(crate::constants::InterfaceMode::Full), ++ ), + packet_hash: tunnel_path.packet_hash, + }; + self.path_table.insert(*dest_hash, entry); +diff --git a/crates/rns-transport/src/actor/persistence.rs b/crates/rns-transport/src/actor/persistence.rs +index cfeb09d..ee7b3c2 100644 +--- a/crates/rns-transport/src/actor/persistence.rs ++++ b/crates/rns-transport/src/actor/persistence.rs +@@ -1061,6 +1061,14 @@ impl TransportActor { + /// just-registered interface. Bound to `RegisterInterface` so each entry + /// rebinds to whatever `interface_id` the runtime allocated this boot. + pub(super) fn drain_pending_for_interface(&mut self, id: InterfaceId, name: &str) { ++ // Restored entries have no persisted medium — recover it from the ++ // interface that is registering now. ++ let medium = crate::path_table::path_medium( ++ self.interfaces ++ .get(&id) ++ .map(|entry| entry.mode) ++ .unwrap_or(crate::constants::InterfaceMode::Full), ++ ); + if !self.pending_path_entries.is_empty() { + let mut promoted = 0usize; + self.pending_path_entries.retain(|pe| { +@@ -1104,6 +1112,7 @@ impl TransportActor { + }) + .collect(), + interface_id: id, ++ medium, + packet_hash: pe.packet_hash.as_ref().and_then(|h| { + if h.len() == 32 { + let mut arr = [0u8; 32]; +@@ -1316,6 +1325,7 @@ mod announce_sweep_actor_tests { + expires: crate::now_f64() + 600.0, + random_blobs: std::collections::VecDeque::new(), + interface_id: 7, ++ medium: crate::path_table::PathMedium::Network, + packet_hash: Some(path_hash), + }, + ); +@@ -1471,6 +1481,7 @@ mod async_save_tests { + expires: crate::now_f64() + 600.0, + random_blobs: std::collections::VecDeque::new(), + interface_id: 7, ++ medium: crate::path_table::PathMedium::Network, + packet_hash: Some([0x33; 32]), + }, + ); +@@ -1587,6 +1598,7 @@ mod async_save_tests { + expires: crate::now_f64() + 600.0, + random_blobs: std::collections::VecDeque::new(), + interface_id: 7, ++ medium: crate::path_table::PathMedium::Network, + packet_hash: Some([0x66; 32]), + }, + ); +diff --git a/crates/rns-transport/src/actor/rpc.rs b/crates/rns-transport/src/actor/rpc.rs +index 07f3b52..2ec775d 100644 +--- a/crates/rns-transport/src/actor/rpc.rs ++++ b/crates/rns-transport/src/actor/rpc.rs +@@ -262,6 +262,41 @@ impl TransportActor { + self.suppress_path_interface(dest, interface_id, duration) + })) + } ++ TransportQuery::SetPathMediumPreference { preference } => { ++ TransportQueryResponse::IntResult(self.set_path_medium_preference(preference) as i64) ++ } ++ TransportQuery::SetPeerMediumPin { dest, pin } => { ++ TransportQueryResponse::BoolResult(self.set_peer_medium_pin(dest, pin)) ++ } ++ TransportQuery::GetPathSlots { dest } => { ++ let slots: Vec = self ++ .path_table ++ .slots(&dest) ++ .into_iter() ++ .enumerate() ++ .map(|(index, entry)| PathSlotRpcEntry { ++ active: index == 0, ++ hops: entry.hops, ++ via: entry.next_hop, ++ interface_id: entry.interface_id, ++ interface: self ++ .interfaces ++ .get(&entry.interface_id) ++ .map(|e| e.name.clone()) ++ .unwrap_or_else(|| format!("interface_{}", entry.interface_id)), ++ medium: entry.medium, ++ timestamp: entry.timestamp, ++ expires: entry.expires, ++ expired: entry.is_expired(), ++ }) ++ .collect(); ++ TransportQueryResponse::PathSlots(PathSlotsRpcEntry { ++ dest, ++ preference: self.effective_path_medium_preference(&dest), ++ pin: self.peer_medium_pins.get(&dest).copied(), ++ slots, ++ }) ++ } + TransportQuery::DropAnnounceQueues => { + for entry in self.interfaces.values_mut() { + entry.announce_queue.clear(); +diff --git a/crates/rns-transport/src/constants.rs b/crates/rns-transport/src/constants.rs +index 879a965..350ee3e 100644 +--- a/crates/rns-transport/src/constants.rs ++++ b/crates/rns-transport/src/constants.rs +@@ -47,6 +47,12 @@ pub const MAX_RANDOM_BLOBS: usize = 64; + /// Max local rebroadcasts before stopping. + pub const LOCAL_REBROADCASTS_MAX: u32 = 2; -+/// Classify an interface mode into its transport medium. ++/// Ranked path slots retained per destination: one active route plus ++/// `MAX_PATH_SLOTS - 1` backups. Backups let a probe/link failure reroute ++/// without waiting for a fresh announce, and are bounded so a destination ++/// heard on many interfaces cannot grow the table without limit. ++pub const MAX_PATH_SLOTS: usize = 3; ++ + /// Path request timeout (seconds). + pub const PATH_REQUEST_TIMEOUT: f64 = 15.0; + +@@ -274,6 +280,72 @@ pub enum PathState { + Responsive = 0x02, + } + ++/// Transport medium a path was learned over. +/// -+/// RNodes and other radios are configured as access points (the RNode default) -+/// or roaming; every other mode is an IP-style network link. -+pub fn path_medium(mode: InterfaceMode) -> PathMedium { -+ match mode { -+ InterfaceMode::AccessPoint | InterfaceMode::Roaming => PathMedium::Rf, -+ _ => PathMedium::Network, -+ } ++/// Coarser than [`InterfaceMode`] on purpose: routing preference is only ever ++/// expressed as "radio" versus "network", so callers do not have to enumerate ++/// every mode. See `path_table::path_medium` for the mapping. ++#[derive(Debug, Clone, Copy, PartialEq, Eq)] ++pub enum PathMedium { ++ /// LoRa / RNode radio links. ++ Rf, ++ /// IP-style links — TCP/UDP/I2P hubs, gateways, boundaries, shared instances. ++ Network, +} + -+/// Resolve a per-destination pin against the global preference. A pin always -+/// wins; without one the global setting applies. -+pub fn effective_path_medium_preference( -+ global: PathMediumPreference, -+ pin: Option, -+) -> PathMediumPreference { -+ match pin { -+ Some(PathMedium::Rf) => PathMediumPreference::Rf, -+ Some(PathMedium::Network) => PathMediumPreference::Network, -+ None => global, ++impl PathMedium { ++ pub fn as_str(self) -> &'static str { ++ match self { ++ Self::Rf => "rf", ++ Self::Network => "network", ++ } + } -+} + -+/// 0 for the preferred medium, 1 for the other. `Lowest` ranks both at 0 so -+/// hop count alone decides. -+fn medium_rank(medium: PathMedium, preference: PathMediumPreference) -> u8 { -+ let preferred = match preference { -+ PathMediumPreference::Lowest => return 0, -+ PathMediumPreference::Network => PathMedium::Network, -+ PathMediumPreference::Rf => PathMedium::Rf, -+ }; -+ u8::from(medium != preferred) ++ /// Parse the wire/RPC spelling produced by [`PathMedium::as_str`]. ++ pub fn from_str_opt(value: &str) -> Option { ++ match value.trim().to_ascii_lowercase().as_str() { ++ "rf" => Some(Self::Rf), ++ "network" => Some(Self::Network), ++ _ => None, ++ } ++ } +} + -+/// Two slots describe the same route when they share both the learning -+/// interface and the next hop; a hub reachable over two interfaces, or two -+/// relays on one interface, are distinct slots. -+fn is_same_route(entry: &PathEntry, interface_id: InterfaceId, next_hop: Option<[u8; 16]>) -> bool { -+ entry.interface_id == interface_id && entry.next_hop == next_hop ++/// Which medium should own the active path slot when both are reachable. ++/// ++/// `Lowest` applies no medium bias and ranks purely by hop count. The other ++/// two are "prefer if possible": when the preferred medium has no live slot, ++/// the best slot of the other medium becomes active without clearing the ++/// preference, so the preferred medium can reclaim the route later. ++#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] ++pub enum PathMediumPreference { ++ #[default] ++ Lowest, ++ Network, ++ Rf, +} + -+/// Routing quality of a slot: live before expired, preferred medium before the -+/// other, fewer hops before more. Deliberately excludes learn time so a -+/// re-rank cannot hand the active slot to an equally good route. -+fn slot_rank_key(entry: &PathEntry, preference: PathMediumPreference) -> (u8, u8, u8) { -+ ( -+ u8::from(entry.is_expired()), -+ medium_rank(entry.medium, preference), -+ entry.hops, -+ ) -+} ++impl PathMediumPreference { ++ pub fn as_str(self) -> &'static str { ++ match self { ++ Self::Lowest => "lowest", ++ Self::Network => "network", ++ Self::Rf => "rf", ++ } ++ } + -+/// Rank plus newest-learn-time, used to order the backup ring and to decide -+/// which slot is evicted once `MAX_PATH_SLOTS` is reached. -+fn slot_sort_key(entry: &PathEntry, preference: PathMediumPreference) -> (u8, u8, u8, f64) { -+ let (expired, medium, hops) = slot_rank_key(entry, preference); -+ (expired, medium, hops, -entry.timestamp) ++ /// Parse the wire/RPC spelling produced by ++ /// [`PathMediumPreference::as_str`]. ++ pub fn from_str_opt(value: &str) -> Option { ++ match value.trim().to_ascii_lowercase().as_str() { ++ "lowest" => Some(Self::Lowest), ++ "network" => Some(Self::Network), ++ "rf" => Some(Self::Rf), ++ _ => None, ++ } ++ } +} + -+/// Stable sort so equal-ranked slots keep their relative order — callers rely -+/// on that to avoid flapping the active route. -+fn sort_slots(slots: &mut [PathEntry], preference: PathMediumPreference) { -+ slots.sort_by(|a, b| { -+ slot_sort_key(a, preference) -+ .partial_cmp(&slot_sort_key(b, preference)) -+ .unwrap_or(std::cmp::Ordering::Equal) -+ }); + /// Configured operating mode for an interface. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum InterfaceMode { +diff --git a/crates/rns-transport/src/messages.rs b/crates/rns-transport/src/messages.rs +index 1229814..486ee33 100644 +--- a/crates/rns-transport/src/messages.rs ++++ b/crates/rns-transport/src/messages.rs +@@ -668,6 +668,23 @@ pub enum TransportQuery { + dest: [u8; 16], + duration: f64, + }, ++ /// Set the global medium preference for the active path slot and re-rank ++ /// known destinations. Response: `IntResult(destinations_rerouted)`. ++ SetPathMediumPreference { ++ preference: crate::constants::PathMediumPreference, ++ }, ++ /// Pin `dest` to a medium (or clear the pin with `None`), overriding the ++ /// global preference for that destination. ++ /// Response: `BoolResult(active_route_moved)`. ++ SetPeerMediumPin { ++ dest: [u8; 16], ++ pin: Option, ++ }, ++ /// Ranked path slots for `dest` — the active route plus up to ++ /// `MAX_PATH_SLOTS - 1` alternates. Response: `PathSlots`. ++ GetPathSlots { ++ dest: [u8; 16], ++ }, + DropAnnounceQueues, + GetBlackholedIdentities, + BlackholeIdentity { +@@ -776,6 +793,7 @@ pub enum TransportQuery { + #[derive(Debug)] + pub enum TransportQueryResponse { + PathTable(Vec), ++ PathSlots(PathSlotsRpcEntry), + InterfaceStats(Vec), + RateTable(Vec), + Announces(Vec), +@@ -809,6 +827,32 @@ pub struct PathTableRpcEntry { + pub interface_role: InterfaceRole, + } + ++/// Ranked path slots for one destination, active first. ++#[derive(Debug, Clone)] ++pub struct PathSlotsRpcEntry { ++ pub dest: [u8; 16], ++ /// Preference actually applied to this destination (pin resolved against ++ /// the global setting). ++ pub preference: crate::constants::PathMediumPreference, ++ /// Per-destination pin, when one is set. ++ pub pin: Option, ++ pub slots: Vec, +} + -+/// Announce emission timebase encoded in the trailing 5 bytes of an announce -+/// random blob. Used to order announces without trusting the hop count. -+pub fn announce_timebase(random_blob: &[u8; 10]) -> u64 { -+ let mut emitted = [0u8; 8]; -+ emitted[3..].copy_from_slice(&random_blob[5..10]); -+ u64::from_be_bytes(emitted) ++#[derive(Debug, Clone)] ++pub struct PathSlotRpcEntry { ++ /// True for the route currently used for outbound traffic. ++ pub active: bool, ++ pub hops: u8, ++ pub via: Option<[u8; 16]>, ++ pub interface_id: InterfaceId, ++ pub interface: String, ++ pub medium: crate::constants::PathMedium, ++ pub timestamp: f64, ++ pub expires: f64, ++ pub expired: bool, +} + -+/// Newest announce timebase recorded on a path. -+pub fn path_timebase_from_random_blobs<'a>( -+ random_blobs: impl Iterator, -+) -> u64 { -+ random_blobs.map(announce_timebase).max().unwrap_or(0) -+} + #[derive(Debug, Clone)] + pub struct InterfaceStatRpcEntry { + pub id: InterfaceId, +diff --git a/crates/rns-transport/src/path_table.rs b/crates/rns-transport/src/path_table.rs +index e051675..23a7728 100644 +--- a/crates/rns-transport/src/path_table.rs ++++ b/crates/rns-transport/src/path_table.rs +@@ -8,6 +8,8 @@ use crate::constants::{ + use crate::messages::InterfaceId; + use rns_wire::types::DestHash; + ++pub use crate::constants::{MAX_PATH_SLOTS, PathMedium, PathMediumPreference}; + - #[cfg(test)] - mod tests { - use super::*; + /// One known path to a destination. + #[derive(Debug, Clone)] + pub struct PathEntry { +@@ -22,6 +24,9 @@ pub struct PathEntry { + /// anti-replay memory on long-lived paths. + pub random_blobs: VecDeque<[u8; 10]>, + pub interface_id: InterfaceId, ++ /// Medium of the interface this path was learned on, captured at insert so ++ /// ranking never has to reach back into the interface table. ++ pub medium: PathMedium, + /// Hash of the cached announce packet — used to satisfy CacheRequest + /// without holding the full packet bytes. + pub packet_hash: Option<[u8; 32]>, +@@ -44,6 +49,7 @@ impl PathEntry { + expires, + random_blobs: VecDeque::new(), + interface_id, ++ medium: path_medium(interface_mode), + packet_hash: None, + } + } +@@ -86,22 +92,78 @@ impl PathEntry { + } + } -+ /// Announce blob whose trailing bytes encode `emitted` — mirrors what the -+ /// wire carries so `announce_timebase` agrees with the test's intent. -+ fn blob_for(prefix: u8, emitted: u64) -> [u8; 10] { -+ let mut blob = [prefix; 10]; -+ let emitted = emitted.to_be_bytes(); -+ blob[5..].copy_from_slice(&emitted[3..8]); -+ blob -+ } +-/// Destination-hash → path mapping plus a parallel liveness state map so we +-/// can probe unresponsive paths without rewriting the entries. ++/// A path candidate carried by an inbound announce, before it becomes a ++/// [`PathEntry`]. Ranking is a pure read over this so callers can order their ++/// own bookkeeping (announce table, rebroadcast dedup) before mutating. ++#[derive(Debug, Clone, Copy)] ++pub struct AnnouncedPath { ++ pub interface_id: InterfaceId, ++ /// Next-hop transport id; `None` for a directly reachable destination. ++ pub next_hop: Option<[u8; 16]>, ++ pub hops: u8, ++ pub medium: PathMedium, ++ /// Announce random blob — the anti-replay key. ++ pub random_blob: [u8; 10], ++ /// Announce timebase decoded from `random_blob` (see `announce_timebase`). ++ pub emitted: u64, ++ /// The learning interface is temporarily barred from installing paths for ++ /// this destination (probe or link-establishment failure). ++ pub suppressed: bool, ++} + -+ fn announced( -+ hops: u8, -+ interface_id: InterfaceId, -+ medium: PathMedium, -+ blob: [u8; 10], -+ ) -> AnnouncedPath { -+ AnnouncedPath { -+ interface_id, -+ next_hop: None, -+ hops, -+ medium, -+ random_blob: blob, -+ emitted: announce_timebase(&blob), -+ suppressed: false, -+ } -+ } -+ -+ fn slot(hops: u8, interface_id: InterfaceId, medium: PathMedium, blob: [u8; 10]) -> PathEntry { -+ let mode = match medium { -+ PathMedium::Rf => InterfaceMode::AccessPoint, -+ PathMedium::Network => InterfaceMode::Gateway, -+ }; -+ let mut entry = PathEntry::new(None, hops, interface_id, mode); -+ entry.add_random_blob(blob); -+ entry -+ } ++/// Where a ranked candidate belongs. ++#[derive(Debug, Clone, Copy, PartialEq, Eq)] ++pub enum PathRank { ++ /// Candidate takes the active slot; the previous active is demoted to a ++ /// backup unless it is the same route. ++ Activate, ++ /// Candidate is a usable alternate but must not displace the active slot. ++ Backup, ++ /// Candidate is dropped — suppressed interface, or a replay of the route ++ /// that already owns the active slot. ++ Reject, ++} + -+ /// Rank + apply in one step, the way the announce path does. -+ fn learn( -+ table: &mut PathTable, -+ dest: [u8; 16], -+ candidate: &AnnouncedPath, -+ preference: PathMediumPreference, -+ ) -> PathRank { -+ let rank = table.rank_announced(&dest, candidate, preference); -+ let entry = slot( -+ candidate.hops, -+ candidate.interface_id, -+ candidate.medium, -+ candidate.random_blob, -+ ); -+ table.upsert_ranked(dest, entry, rank, preference); -+ rank ++/// Destination-hash → ranked path slots, plus a parallel liveness state map so ++/// we can probe unresponsive paths without rewriting the entries. ++/// ++/// `entries` holds the active route per destination — every legacy accessor ++/// (`get`, `has_path`, `hops_to`, `iter`) reads it, so routing is unchanged for ++/// callers that do not care about alternates. `backups` holds up to ++/// `MAX_PATH_SLOTS - 1` ranked alternates, best first, and only ever exists ++/// alongside an active entry. + #[derive(Clone)] + pub struct PathTable { + entries: HashMap, ++ backups: HashMap>, + states: HashMap, ++ /// Global medium preference. Ranking calls that know the destination pass ++ /// their own effective preference; cull and interface-drop promotions have ++ /// no destination context and fall back to this. ++ preference: PathMediumPreference, + } + + impl PathTable { + pub fn new() -> Self { + Self { + entries: HashMap::new(), ++ backups: HashMap::new(), + states: HashMap::new(), ++ preference: PathMediumPreference::default(), + } + } + ++ /// Set the global medium preference used by promotions that have no ++ /// per-destination pin available. Callers that own pins should follow this ++ /// with a `rerank` pass over known destinations. ++ pub fn set_preference(&mut self, preference: PathMediumPreference) { ++ self.preference = preference; + } + -+ fn interface_ids(slots: &[PathEntry]) -> Vec { -+ slots.iter().map(|entry| entry.interface_id).collect() ++ pub fn preference(&self) -> PathMediumPreference { ++ self.preference + } + - #[test] - fn test_path_table_basic() { - let mut table = PathTable::new(); -@@ -392,6 +913,448 @@ + /// Insert or replace a path entry. The parallel liveness state is + /// cleared so a fresh/replacement entry never inherits a stale + /// `Responsive`/`Unresponsive` reading from its predecessor. `get_state` +@@ -113,9 +175,15 @@ impl PathTable { + /// sites (announce install, PathResponse install, tunnel path restore, disk + /// load), so the invariant is enforced here: state is never older than the + /// entry it describes. ++ /// ++ /// Backup slots for the same route are dropped so the alternate ring never ++ /// shadows the freshly installed active path; alternates on other ++ /// interfaces are left alone. Use [`PathTable::upsert_ranked`] when the old ++ /// active should be demoted rather than discarded. + pub fn insert(&mut self, dest_hash: impl Into, entry: PathEntry) { + let hash: DestHash = dest_hash.into(); + self.states.remove(&hash); ++ self.drop_backup_slot(&hash, entry.interface_id, entry.next_hop); + self.entries.insert(hash, entry); } - #[test] -+ fn path_medium_maps_radio_modes_to_rf() { -+ assert_eq!(path_medium(InterfaceMode::AccessPoint), PathMedium::Rf); -+ assert_eq!(path_medium(InterfaceMode::Roaming), PathMedium::Rf); -+ for mode in [ -+ InterfaceMode::Full, -+ InterfaceMode::Gateway, -+ InterfaceMode::Boundary, -+ InterfaceMode::PointToPoint, -+ InterfaceMode::Internal, -+ ] { -+ assert_eq!(path_medium(mode), PathMedium::Network, "{mode:?}"); -+ } +@@ -145,29 +213,39 @@ impl PathTable { + self.get_live(dest_hash).map(|e| e.hops) + } + ++ /// Remove every slot for a destination, active and backups. + pub fn remove(&mut self, dest_hash: &[u8; 16]) -> Option { + self.states.remove(dest_hash); ++ self.backups.remove(dest_hash); + self.entries.remove(dest_hash) + } + +- /// Drop every path whose interface id matches — used when an interface +- /// goes down so we don't keep routing through a dead transport. ++ /// Drop every slot whose interface id matches — used when an interface goes ++ /// down so we don't keep routing through a dead transport. Destinations ++ /// whose active path is dropped promote their best live alternate, so a ++ /// multi-homed peer stays routable. Returns the number of active paths ++ /// removed (promoted or not). + pub fn drop_all_via(&mut self, interface_id: InterfaceId) -> usize { +- let before = self.entries.len(); +- self.entries.retain(|_, e| e.interface_id != interface_id); +- before - self.entries.len() ++ self.retain_backups(|entry| entry.interface_id != interface_id); ++ self.drop_active_where(|entry| entry.interface_id == interface_id) + } + + pub fn drop_all_via_next_hop(&mut self, next_hop: &[u8; 16]) -> usize { +- let before = self.entries.len(); +- self.entries.retain(|_, e| e.next_hop != Some(*next_hop)); +- before - self.entries.len() ++ let next_hop = *next_hop; ++ self.retain_backups(|entry| entry.next_hop != Some(next_hop)); ++ self.drop_active_where(|entry| entry.next_hop == Some(next_hop)) + } + +- /// Force-expire a path and cull immediately. Useful when the caller +- /// already knows the path is bad (e.g. a link proof failed) and ++ /// Force-drop a destination's route and cull immediately. Useful when the ++ /// caller already knows the path is bad (e.g. a link proof failed) and + /// shouldn't wait for the periodic cull cycle. ++ /// ++ /// Backups are dropped too: callers use this to make a destination ++ /// *unrouted* (path rediscovery, `DropPath` RPC), so silently promoting an ++ /// alternate would defeat the request. Use ++ /// [`PathTable::suppress_interface`] to reroute onto a backup instead. + pub fn expire(&mut self, dest_hash: &[u8; 16]) -> bool { ++ self.backups.remove(dest_hash); + if let Some(entry) = self.entries.get_mut(dest_hash) { + entry.expires = 0.0; + self.cull_expired(); +@@ -189,17 +267,17 @@ impl PathTable { + } + + /// Full cull pass. Prefer `cull_expired_batch` on the hot path to bound +- /// per-tick work. ++ /// per-tick work. Expired alternates are dropped first so a destination ++ /// whose active path expired can only promote a still-live alternate. ++ /// Returns the number of expired active paths removed. + pub fn cull_expired(&mut self) -> usize { +- let before = self.entries.len(); +- self.entries.retain(|_, entry| !entry.is_expired()); +- self.states +- .retain(|hash, _| self.entries.contains_key(hash)); +- before - self.entries.len() ++ self.retain_backups(|entry| !entry.is_expired()); ++ self.drop_active_where(|entry| entry.is_expired()) + } + + /// Batched cull — removes at most `limit` expired entries so the actor +- /// cannot stall on a very large path table. ++ /// cannot stall on a very large path table. Alternates are only inspected ++ /// for the destinations in this batch, keeping per-tick work bounded. + pub fn cull_expired_batch(&mut self, limit: usize) -> usize { + let to_remove: Vec = self + .entries +@@ -212,21 +290,321 @@ impl PathTable { + for hash in &to_remove { + self.entries.remove(hash.as_bytes()); + self.states.remove(hash.as_bytes()); ++ self.promote_best_backup(hash, self.preference); + } + count + } + +- /// Drop paths whose interface id is no longer active. ++ /// Drop slots whose interface id is no longer active, promoting alternates ++ /// on live interfaces where possible. + pub fn cull_dead_interfaces( + &mut self, + active_interfaces: &std::collections::HashSet, + ) -> usize { +- let before = self.entries.len(); +- self.entries +- .retain(|_, entry| active_interfaces.contains(&entry.interface_id)); ++ self.retain_backups(|entry| active_interfaces.contains(&entry.interface_id)); ++ self.drop_active_where(|entry| !active_interfaces.contains(&entry.interface_id)) + } + -+ #[test] -+ fn medium_and_preference_round_trip_their_wire_spelling() { -+ for medium in [PathMedium::Rf, PathMedium::Network] { -+ assert_eq!(PathMedium::from_str_opt(medium.as_str()), Some(medium)); ++ // ----- ranked multi-path slots ------------------------------------------- ++ ++ /// Every known slot for a destination, active first, then alternates in ++ /// rank order. Empty when the destination is unknown. ++ pub fn slots(&self, dest_hash: &[u8; 16]) -> Vec<&PathEntry> { ++ let mut slots = Vec::new(); ++ if let Some(active) = self.entries.get(dest_hash) { ++ slots.push(active); ++ } else { ++ return slots; + } -+ for preference in [ -+ PathMediumPreference::Lowest, -+ PathMediumPreference::Network, -+ PathMediumPreference::Rf, -+ ] { -+ assert_eq!( -+ PathMediumPreference::from_str_opt(preference.as_str()), -+ Some(preference) -+ ); ++ if let Some(backups) = self.backups.get(dest_hash) { ++ slots.extend(backups.iter()); + } -+ assert_eq!(PathMedium::from_str_opt(" RF "), Some(PathMedium::Rf)); -+ assert_eq!(PathMedium::from_str_opt("lora"), None); -+ assert_eq!(PathMediumPreference::from_str_opt("fastest"), None); ++ slots + } + -+ #[test] -+ fn effective_preference_prefers_the_pin() { -+ assert_eq!( -+ effective_path_medium_preference(PathMediumPreference::Lowest, Some(PathMedium::Rf)), -+ PathMediumPreference::Rf -+ ); -+ assert_eq!( -+ effective_path_medium_preference(PathMediumPreference::Rf, Some(PathMedium::Network)), -+ PathMediumPreference::Network -+ ); -+ assert_eq!( -+ effective_path_medium_preference(PathMediumPreference::Network, None), -+ PathMediumPreference::Network -+ ); -+ } -+ -+ /// The multi-TCP case: the same announce heard closer to the source on a -+ /// second interface takes over, and the longer route is kept as a backup. -+ #[test] -+ fn same_blob_with_fewer_hops_on_another_interface_takes_active_slot() { -+ let mut table = PathTable::new(); -+ let dest = [0xA0; 16]; -+ let blob = blob_for(0x11, 100); -+ table.insert(dest, slot(4, 1, PathMedium::Network, blob)); -+ -+ let candidate = announced(2, 2, PathMedium::Network, blob); -+ assert_eq!( -+ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest), -+ PathRank::Activate -+ ); -+ -+ assert_eq!(table.get(&dest).unwrap().interface_id, 2); -+ assert_eq!(table.hops_to(&dest), Some(2)); -+ assert_eq!(interface_ids(table.backups(&dest)), vec![1]); -+ assert_eq!(table.slots(&dest).len(), 2); -+ } -+ -+ #[test] -+ fn same_blob_with_more_hops_on_another_interface_is_backup_only() { -+ let mut table = PathTable::new(); -+ let dest = [0xA1; 16]; -+ let blob = blob_for(0x22, 100); -+ table.insert(dest, slot(2, 1, PathMedium::Network, blob)); -+ -+ let candidate = announced(5, 2, PathMedium::Network, blob); -+ assert_eq!( -+ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest), -+ PathRank::Backup -+ ); -+ -+ assert_eq!(table.get(&dest).unwrap().interface_id, 1); -+ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); -+ } -+ -+ /// Equal hop counts must not flap the route, even across interfaces. -+ #[test] -+ fn equal_hops_on_another_interface_does_not_flap_the_active_slot() { -+ let mut table = PathTable::new(); -+ let dest = [0xA2; 16]; -+ let blob = blob_for(0x33, 100); -+ table.insert(dest, slot(3, 1, PathMedium::Network, blob)); -+ -+ let same_blob = announced(3, 2, PathMedium::Network, blob); -+ assert_eq!( -+ learn(&mut table, dest, &same_blob, PathMediumPreference::Lowest), -+ PathRank::Backup -+ ); -+ assert_eq!(table.get(&dest).unwrap().interface_id, 1); -+ -+ // An *older* announce at the same hop count is equally powerless. -+ let older_blob = announced(3, 3, PathMedium::Network, blob_for(0x44, 99)); -+ assert_eq!( -+ learn(&mut table, dest, &older_blob, PathMediumPreference::Lowest), -+ PathRank::Backup -+ ); -+ assert_eq!(table.get(&dest).unwrap().interface_id, 1); -+ assert_eq!(table.hops_to(&dest), Some(3)); ++ /// Ranked alternates for a destination, best first. ++ pub fn backups(&self, dest_hash: &[u8; 16]) -> &[PathEntry] { ++ self.backups ++ .get(dest_hash) ++ .map(|backups| backups.as_slice()) ++ .unwrap_or(&[]) + } + -+ #[test] -+ fn a_fourth_route_evicts_the_worst_ranked_slot() { -+ let mut table = PathTable::new(); -+ let dest = [0xA3; 16]; -+ let blob = blob_for(0x55, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); -+ for (hops, interface_id) in [(2u8, 2u64), (5, 3)] { -+ let candidate = announced(hops, interface_id, PathMedium::Network, blob); -+ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest); ++ /// Decide where a freshly heard announce belongs. Pure read — apply the ++ /// result with [`PathTable::upsert_ranked`]. ++ /// ++ /// `preference` is the *effective* preference for this destination (a ++ /// per-destination pin already resolved against the global setting). ++ /// ++ /// Layering, outermost first: ++ /// 1. A suppressed interface is always rejected. ++ /// 2. A live path on the preferred medium is never displaced by the other ++ /// medium, and a preferred-medium candidate always reclaims the active ++ /// slot from the other medium. ++ /// 3. Within one medium rank, a strictly shorter route on a *different* ++ /// interface or next hop wins — this is what lets a second TCP hub or an ++ /// RF neighbour take over when it hears the same announce closer to the ++ /// source. ++ /// 4. Otherwise the pre-multipath announce rules decide (newer announce ++ /// timebase, unseen random blob, expired or unresponsive path), so equal ++ /// hop counts still cannot flap the route. ++ pub fn rank_announced( ++ &self, ++ dest_hash: &[u8; 16], ++ candidate: &AnnouncedPath, ++ preference: PathMediumPreference, ++ ) -> PathRank { ++ if candidate.suppressed { ++ return PathRank::Reject; + } -+ assert_eq!(interface_ids(table.backups(&dest)), vec![2, 3]); -+ assert_eq!(table.slots(&dest).len(), MAX_PATH_SLOTS); -+ -+ let fourth = announced(3, 4, PathMedium::Network, blob); -+ learn(&mut table, dest, &fourth, PathMediumPreference::Lowest); -+ -+ assert_eq!(table.slots(&dest).len(), MAX_PATH_SLOTS); -+ assert_eq!(table.get(&dest).unwrap().interface_id, 1); -+ assert_eq!( -+ interface_ids(table.backups(&dest)), -+ vec![2, 4], -+ "the 5-hop route should be the one evicted" -+ ); -+ } -+ -+ #[test] -+ fn suppressing_the_active_interface_promotes_a_backup() { -+ let mut table = PathTable::new(); -+ let dest = [0xA4; 16]; -+ let blob = blob_for(0x66, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); -+ let candidate = announced(4, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest); -+ table.set_state(dest, PathState::Responsive); -+ -+ assert!(table.suppress_interface(&dest, 1, PathMediumPreference::Lowest)); -+ -+ let active = table.get(&dest).expect("backup should have been promoted"); -+ assert_eq!(active.interface_id, 2); -+ assert_eq!(active.hops, 4); -+ assert!(table.backups(&dest).is_empty()); -+ assert_eq!( -+ table.get_state(&dest), -+ PathState::Unknown, -+ "a promoted route must be re-probed" -+ ); -+ } -+ -+ #[test] -+ fn suppressing_the_only_interface_leaves_the_destination_unrouted() { -+ let mut table = PathTable::new(); -+ let dest = [0xA5; 16]; -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob_for(0x77, 100))); -+ -+ assert!(table.suppress_interface(&dest, 1, PathMediumPreference::Lowest)); -+ assert!(!table.has_path(&dest)); -+ -+ assert!( -+ !table.suppress_interface(&dest, 1, PathMediumPreference::Lowest), -+ "an unknown destination cannot be rerouted" -+ ); -+ } -+ -+ #[test] -+ fn suppressing_a_backup_interface_leaves_the_active_route_alone() { -+ let mut table = PathTable::new(); -+ let dest = [0xA6; 16]; -+ let blob = blob_for(0x88, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); -+ let candidate = announced(4, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest); -+ -+ assert!(!table.suppress_interface(&dest, 2, PathMediumPreference::Lowest)); -+ assert_eq!(table.get(&dest).unwrap().interface_id, 1); -+ assert!(table.backups(&dest).is_empty()); -+ } -+ -+ #[test] -+ fn network_preference_keeps_a_shorter_rf_route_in_the_backups() { -+ let mut table = PathTable::new(); -+ let dest = [0xA7; 16]; -+ let blob = blob_for(0x99, 100); -+ table.insert(dest, slot(4, 1, PathMedium::Network, blob)); -+ -+ let rf = announced(1, 2, PathMedium::Rf, blob); -+ assert_eq!( -+ learn(&mut table, dest, &rf, PathMediumPreference::Network), -+ PathRank::Backup -+ ); -+ assert_eq!(table.get(&dest).unwrap().interface_id, 1); -+ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); -+ -+ // Without a medium preference the same announce would have won. -+ let mut lowest = PathTable::new(); -+ lowest.insert(dest, slot(4, 1, PathMedium::Network, blob)); -+ assert_eq!( -+ learn(&mut lowest, dest, &rf, PathMediumPreference::Lowest), -+ PathRank::Activate -+ ); -+ assert_eq!(lowest.get(&dest).unwrap().interface_id, 2); -+ } -+ -+ #[test] -+ fn rf_preference_reclaims_the_active_slot_from_a_shorter_network_route() { -+ let mut table = PathTable::new(); -+ let dest = [0xA8; 16]; -+ let blob = blob_for(0xAA, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); -+ -+ let rf = announced(6, 2, PathMedium::Rf, blob); -+ assert_eq!( -+ learn(&mut table, dest, &rf, PathMediumPreference::Rf), -+ PathRank::Activate -+ ); -+ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); -+ assert_eq!(interface_ids(table.backups(&dest)), vec![1]); -+ } -+ -+ /// Prefer-if-possible: an RF pin with no live RF slot falls back to the -+ /// network route, and the RF route reclaims the slot when heard again. -+ #[test] -+ fn rf_preference_falls_back_to_network_and_reclaims_later() { -+ let mut table = PathTable::new(); -+ let dest = [0xA9; 16]; -+ let blob = blob_for(0xBB, 100); -+ table.insert(dest, slot(6, 1, PathMedium::Rf, blob)); -+ let network = announced(2, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &network, PathMediumPreference::Rf); -+ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); -+ -+ assert!(table.suppress_interface(&dest, 1, PathMediumPreference::Rf)); -+ assert_eq!( -+ table.get(&dest).unwrap().medium, -+ PathMedium::Network, -+ "with no RF slot left the network route must carry traffic" -+ ); -+ -+ let rf_again = announced(6, 3, PathMedium::Rf, blob_for(0xCC, 101)); -+ assert_eq!( -+ learn(&mut table, dest, &rf_again, PathMediumPreference::Rf), -+ PathRank::Activate -+ ); -+ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); -+ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); -+ } -+ -+ #[test] -+ fn preference_change_reranks_without_a_new_announce() { -+ let mut table = PathTable::new(); -+ let dest = [0xB0; 16]; -+ let blob = blob_for(0xDD, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Rf, blob)); -+ let network = announced(4, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &network, PathMediumPreference::Lowest); -+ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); ++ let Some(active) = self.entries.get(dest_hash) else { ++ return PathRank::Activate; ++ }; + -+ table.set_preference(PathMediumPreference::Network); -+ assert!(table.rerank(&dest, PathMediumPreference::Network)); -+ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Network); -+ assert_eq!(interface_ids(table.backups(&dest)), vec![1]); -+ -+ // Re-running the same rerank is a no-op — no flap on repeated writes. -+ assert!(!table.rerank(&dest, PathMediumPreference::Network)); -+ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Network); -+ } -+ -+ #[test] -+ fn rerank_keeps_the_incumbent_on_ties() { -+ let mut table = PathTable::new(); -+ let dest = [0xB1; 16]; -+ let blob = blob_for(0xEE, 100); -+ table.insert(dest, slot(3, 1, PathMedium::Network, blob)); -+ let peer = announced(3, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); -+ -+ assert!(!table.rerank(&dest, PathMediumPreference::Lowest)); -+ assert_eq!(table.get(&dest).unwrap().interface_id, 1); -+ } -+ -+ #[test] -+ fn suppressed_interfaces_are_rejected_outright() { -+ let mut table = PathTable::new(); -+ let dest = [0xB2; 16]; -+ let blob = blob_for(0x0F, 100); -+ table.insert(dest, slot(4, 1, PathMedium::Network, blob)); -+ -+ let mut candidate = announced(1, 2, PathMedium::Network, blob); -+ candidate.suppressed = true; -+ assert_eq!( -+ table.rank_announced(&dest, &candidate, PathMediumPreference::Lowest), -+ PathRank::Reject -+ ); -+ assert_eq!( -+ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest), -+ PathRank::Reject -+ ); -+ assert_eq!(table.get(&dest).unwrap().interface_id, 1); -+ assert!(table.backups(&dest).is_empty()); -+ } -+ -+ #[test] -+ fn drop_all_via_clears_backups_and_promotes() { -+ let mut table = PathTable::new(); -+ let dest = [0xB3; 16]; -+ let blob = blob_for(0x1F, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); -+ let peer = announced(4, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); -+ -+ assert_eq!( -+ table.drop_all_via(2), -+ 0, -+ "no active path went through iface 2" -+ ); -+ assert!(table.backups(&dest).is_empty()); -+ assert_eq!(table.get(&dest).unwrap().interface_id, 1); -+ -+ let peer = announced(4, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); -+ assert_eq!(table.drop_all_via(1), 1); -+ assert_eq!( -+ table.get(&dest).unwrap().interface_id, -+ 2, -+ "the surviving backup should carry the destination" -+ ); -+ assert!(table.backups(&dest).is_empty()); -+ } -+ -+ #[test] -+ fn cull_expired_promotes_a_live_backup() { -+ let mut table = PathTable::new(); -+ let dest = [0xB4; 16]; -+ let blob = blob_for(0x2F, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); -+ let peer = announced(4, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); -+ table.get_mut(&dest).unwrap().expires = now_f64() - 1.0; -+ -+ assert_eq!(table.cull_expired(), 1); -+ assert_eq!(table.get(&dest).unwrap().interface_id, 2); -+ assert!(table.has_path(&dest)); -+ } -+ -+ #[test] -+ fn cull_expired_drops_expired_backups_before_promoting() { -+ let mut table = PathTable::new(); -+ let dest = [0xB5; 16]; -+ let blob = blob_for(0x3F, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); -+ let peer = announced(4, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); -+ table.get_mut(&dest).unwrap().expires = now_f64() - 1.0; -+ table.backups.get_mut(&DestHash::from(dest)).unwrap()[0].expires = now_f64() - 1.0; -+ -+ assert_eq!(table.cull_expired(), 1); -+ assert!(!table.has_path(&dest)); -+ assert!(table.slots(&dest).is_empty()); -+ } -+ -+ #[test] -+ fn expire_drops_every_slot_for_the_destination() { -+ let mut table = PathTable::new(); -+ let dest = [0xB6; 16]; -+ let blob = blob_for(0x4F, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); -+ let peer = announced(4, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); -+ -+ assert!(table.expire(&dest)); -+ assert!(!table.has_path(&dest)); -+ assert!(table.slots(&dest).is_empty()); -+ } -+ -+ #[test] -+ fn remove_and_insert_keep_the_backup_ring_consistent() { -+ let mut table = PathTable::new(); -+ let dest = [0xB7; 16]; -+ let blob = blob_for(0x5F, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); -+ let peer = announced(4, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); -+ -+ // Re-installing the backup's route as active must not leave a duplicate. -+ table.insert(dest, slot(4, 2, PathMedium::Network, blob)); -+ assert!(table.backups(&dest).is_empty()); -+ assert_eq!(table.slots(&dest).len(), 1); -+ -+ let peer = announced(6, 3, PathMedium::Network, blob); -+ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); -+ assert_eq!(table.slots(&dest).len(), 2); -+ table.remove(&dest); -+ assert!(table.slots(&dest).is_empty()); -+ } -+ -+ #[test] -+ fn backup_upsert_without_an_active_route_installs_directly() { -+ let mut table = PathTable::new(); -+ let dest = [0xB8; 16]; -+ let entry = slot(3, 1, PathMedium::Network, blob_for(0x6F, 100)); -+ -+ assert!(table.upsert_ranked(dest, entry, PathRank::Backup, PathMediumPreference::Lowest)); -+ assert_eq!(table.get(&dest).unwrap().interface_id, 1); -+ assert!(table.backups(&dest).is_empty()); -+ } -+ -+ #[test] -+ fn refreshing_a_backup_route_replaces_its_slot() { -+ let mut table = PathTable::new(); -+ let dest = [0xB9; 16]; -+ let blob = blob_for(0x7F, 100); -+ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); -+ let peer = announced(6, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); -+ assert_eq!(table.backups(&dest)[0].hops, 6); -+ -+ let closer = announced(4, 2, PathMedium::Network, blob); -+ learn(&mut table, dest, &closer, PathMediumPreference::Lowest); -+ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); -+ assert_eq!(table.backups(&dest)[0].hops, 4); -+ } -+ -+ #[test] -+ fn unknown_destination_has_no_slots() { -+ let table = PathTable::new(); -+ assert!(table.slots(&[0xFF; 16]).is_empty()); -+ assert!(table.backups(&[0xFF; 16]).is_empty()); -+ } -+ -+ #[test] - fn test_cull_dead_interfaces() { - let mut table = PathTable::new(); - let mut active = std::collections::HashSet::new(); ---- a/crates/rns-transport/src/messages.rs -+++ b/crates/rns-transport/src/messages.rs -@@ -664,7 +664,24 @@ - SuppressCurrentPathInterface { - dest: [u8; 16], - duration: f64, -+ }, -+ /// Set the global medium preference for the active path slot and re-rank -+ /// known destinations. Response: `IntResult(destinations_rerouted)`. -+ SetPathMediumPreference { -+ preference: crate::constants::PathMediumPreference, -+ }, -+ /// Pin `dest` to a medium (or clear the pin with `None`), overriding the -+ /// global preference for that destination. -+ /// Response: `BoolResult(active_route_moved)`. -+ SetPeerMediumPin { -+ dest: [u8; 16], -+ pin: Option, - }, -+ /// Ranked path slots for `dest` — the active route plus up to -+ /// `MAX_PATH_SLOTS - 1` alternates. Response: `PathSlots`. -+ GetPathSlots { -+ dest: [u8; 16], -+ }, - DropAnnounceQueues, - GetBlackholedIdentities, - BlackholeIdentity { -@@ -780,6 +797,7 @@ - #[derive(Debug)] - pub enum TransportQueryResponse { - PathTable(Vec), -+ PathSlots(PathSlotsRpcEntry), - InterfaceStats(Vec), - RateTable(Vec), - Announces(Vec), -@@ -813,9 +831,35 @@ - pub interface_id: InterfaceId, - pub interface_mode: InterfaceMode, - pub interface_role: InterfaceRole, -+} -+ -+/// Ranked path slots for one destination, active first. -+#[derive(Debug, Clone)] -+pub struct PathSlotsRpcEntry { -+ pub dest: [u8; 16], -+ /// Preference actually applied to this destination (pin resolved against -+ /// the global setting). -+ pub preference: crate::constants::PathMediumPreference, -+ /// Per-destination pin, when one is set. -+ pub pin: Option, -+ pub slots: Vec, - } - - #[derive(Debug, Clone)] -+pub struct PathSlotRpcEntry { -+ /// True for the route currently used for outbound traffic. -+ pub active: bool, -+ pub hops: u8, -+ pub via: Option<[u8; 16]>, -+ pub interface_id: InterfaceId, -+ pub interface: String, -+ pub medium: crate::constants::PathMedium, -+ pub timestamp: f64, -+ pub expires: f64, -+ pub expired: bool, -+} -+ -+#[derive(Debug, Clone)] - pub struct InterfaceStatRpcEntry { - pub id: InterfaceId, - pub name: String, ---- a/crates/rns-transport/src/actor/inbound.rs -+++ b/crates/rns-transport/src/actor/inbound.rs -@@ -375,25 +375,52 @@ - .unwrap_or_default(); - let suppressed = - self.is_path_interface_suppressed(header.destination_hash, interface_id, now_f64()); -- let should_add = if suppressed { -- false -- } else if let Some(existing) = self.path_table.get(&header.destination_hash) { -- let random_seen = existing.has_random_blob(&announce_random_hash); -- let path_timebase = path_timebase_from_random_blobs(existing.random_blobs.iter()); -- if header.hops <= existing.hops { -- !random_seen && announce_emitted > path_timebase -- } else if existing.is_expired() || announce_emitted > path_timebase { -- !random_seen -- } else if announce_emitted == path_timebase { -- self.path_table.get_state(&header.destination_hash) -- == crate::constants::PathState::Unresponsive -- } else { -- false -- } -- } else { -- true -+ let candidate = crate::path_table::AnnouncedPath { -+ interface_id, -+ next_hop: header.transport_id, -+ hops: header.hops, -+ medium: crate::path_table::path_medium(iface_mode), -+ random_blob: announce_random_hash, -+ emitted: announce_emitted, -+ suppressed, - }; -+ let preference = self.effective_path_medium_preference(&header.destination_hash); -+ let rank = self -+ .path_table -+ .rank_announced(&header.destination_hash, &candidate, preference); -+ if rank == crate::path_table::PathRank::Reject { -+ debug!( -+ dest = hex::encode(header.destination_hash), -+ hops = header.hops, -+ announce_emitted, -+ interface_id, -+ suppressed, -+ "ignoring replayed or stale announce" -+ ); -+ return; -+ } -+ let should_add = rank == crate::path_table::PathRank::Activate; - -+ // For Header2 announces via a transport node, next_hop is the relay's -+ // hash from transport_id; for Header1 announces the destination is -+ // directly reachable and next_hop stays None. -+ let mut entry = crate::path_table::PathEntry::new( -+ header.transport_id, -+ header.hops, -+ interface_id, -+ iface_mode, -+ ); -+ if !random_blobs.contains(&announce_random_hash) { -+ if random_blobs.len() >= MAX_RANDOM_BLOBS { -+ random_blobs.pop_front(); -+ } -+ random_blobs.push_back(announce_random_hash); -+ } -+ entry.random_blobs = random_blobs; -+ // Store the announce packet hash so a later CacheRequest for this -+ // destination can replay the exact announce bytes. -+ entry.packet_hash = Some(rns_wire::hash::packet_hash(raw, header.flags.header_type)); -+ - if should_add { - // Announce-table update MUST precede path-table update — the dedup - // check above compares against our own queued copy. Non-transport -@@ -434,26 +461,6 @@ - .insert(header.destination_hash, announce_entry); - } - -- // For Header2 announces via a transport node, next_hop is the relay's -- // hash from transport_id; for Header1 announces the destination is -- // directly reachable and next_hop stays None. -- let mut entry = crate::path_table::PathEntry::new( -- header.transport_id, -- header.hops, -- interface_id, -- iface_mode, -- ); -- if !random_blobs.contains(&announce_random_hash) { -- if random_blobs.len() >= MAX_RANDOM_BLOBS { -- random_blobs.pop_front(); -- } -- random_blobs.push_back(announce_random_hash); -- } -- entry.random_blobs = random_blobs; -- // Store the announce packet hash so a later CacheRequest for this -- // destination can replay the exact announce bytes. -- let announce_packet_hash = rns_wire::hash::packet_hash(raw, header.flags.header_type); -- entry.packet_hash = Some(announce_packet_hash); - let tunnel_path = crate::tunnel::TunnelPath { - timestamp: entry.timestamp, - next_hop: entry.next_hop, -@@ -462,7 +469,12 @@ - random_blobs: entry.random_blobs.iter().copied().collect(), - packet_hash: entry.packet_hash, - }; -- self.path_table.insert(header.destination_hash, entry); -+ self.path_table.upsert_ranked( -+ header.destination_hash, -+ entry, -+ crate::path_table::PathRank::Activate, -+ preference, -+ ); - if let Some(tunnel) = self.tunnel_table.get_mut_by_interface(interface_id) { - tunnel - .tunnel_paths -@@ -494,13 +506,24 @@ - "path learned from announce" - ); - } else { -+ // A route that loses the active slot can still be worth keeping: -+ // parking it in a backup slot lets a later probe or link failure -+ // reroute without a fresh announce. Everything else about the -+ // announce is still ignored — no rebroadcast, no handler dispatch, -+ // no announce-cache refresh — so replays stay inert. -+ let stored_hops = entry.hops; -+ self.path_table.upsert_ranked( -+ header.destination_hash, -+ entry, -+ crate::path_table::PathRank::Backup, -+ preference, -+ ); -+ self.state_dirty = true; - debug!( - dest = hex::encode(header.destination_hash), -- hops = header.hops, -- announce_emitted, -+ hops = stored_hops, - interface_id, -- suppressed, -- "ignoring replayed or stale announce" -+ "alternate path stored in backup slot" - ); - return; - } ---- a/crates/rns-transport/src/actor/mod.rs -+++ b/crates/rns-transport/src/actor/mod.rs -@@ -19,6 +19,7 @@ - InterfaceEntry, InterfaceId, InterfaceRole, TransportMessage, msg_variant_name, - }; - use crate::path_table::PathTable; -+pub(crate) use crate::path_table::{announce_timebase, path_timebase_from_random_blobs}; - use crate::rate_limit::RateTable; - use crate::reverse_table::ReverseTable; - use crate::traffic::TrafficCounter; -@@ -81,6 +82,11 @@ - /// Used when a Direct LinkRequest timed out on one route, so the next - /// path request can discover alternates instead of instantly reusing it. - pub path_interface_suppressions: HashMap<([u8; 16], InterfaceId), f64>, -+ /// Global medium preference for the active path slot. -+ pub path_medium_preference: PathMediumPreference, -+ /// Per-destination medium pins overriding `path_medium_preference`. Only -+ /// pinned destinations appear here; clearing a pin removes the entry. -+ pub peer_medium_pins: HashMap<[u8; 16], PathMedium>, - last_discovery_pr_tx: f64, - /// External interface waiting for a path response from a local shared client. - /// Python calls this `pending_local_path_requests`. -@@ -315,6 +321,8 @@ - discovery_pr_tags: HashMap::new(), - pending_discovery_prs: VecDeque::new(), - path_interface_suppressions: HashMap::new(), -+ path_medium_preference: PathMediumPreference::default(), -+ peer_medium_pins: HashMap::new(), - last_discovery_pr_tx: 0.0, - pending_local_path_requests: HashMap::new(), - path_states: HashMap::new(), -@@ -1237,13 +1245,90 @@ - let until = now_f64() + duration; - self.path_interface_suppressions - .insert((dest, interface_id), until); -+ // Reroute onto the best remaining slot now: rediscovery would otherwise -+ // run with the failed route still installed. -+ let preference = self.effective_path_medium_preference(&dest); -+ let rerouted = self -+ .path_table -+ .suppress_interface(&dest, interface_id, preference); -+ if rerouted { -+ self.state_dirty = true; ++ let random_seen = active.has_random_blob(&candidate.random_blob); ++ let path_timebase = path_timebase_from_random_blobs(active.random_blobs.iter()); ++ let timebase_allows = if candidate.hops <= active.hops { ++ !random_seen && candidate.emitted > path_timebase ++ } else if active.is_expired() || candidate.emitted > path_timebase { ++ !random_seen ++ } else if candidate.emitted == path_timebase { ++ self.get_state(dest_hash) == PathState::Unresponsive ++ } else { ++ false ++ }; ++ ++ let same_route = is_same_route(active, candidate.interface_id, candidate.next_hop); ++ let candidate_rank = medium_rank(candidate.medium, preference); ++ let active_rank = medium_rank(active.medium, preference); ++ ++ let activate = if active.is_expired() { ++ // Nothing live to protect: prefer-if-possible means the other ++ // medium may take over until the preferred one is heard again. ++ timebase_allows ++ } else if candidate_rank < active_rank { ++ true ++ } else if candidate_rank > active_rank { ++ false ++ } else if !same_route && candidate.hops < active.hops { ++ true ++ } else { ++ timebase_allows ++ }; ++ ++ if activate { ++ PathRank::Activate ++ } else if same_route { ++ PathRank::Reject ++ } else { ++ PathRank::Backup + } - debug!( - dest = %hex::encode(dest), - interface_id, - duration, -+ rerouted, - "temporarily suppressing path interface" - ); - true + } + -+ /// Preference in force for a destination: its pin if any, else the global -+ /// setting. -+ pub(super) fn effective_path_medium_preference(&self, dest: &[u8; 16]) -> PathMediumPreference { -+ crate::path_table::effective_path_medium_preference( -+ self.path_medium_preference, -+ self.peer_medium_pins.get(dest).copied(), -+ ) ++ /// Apply a [`PathRank`] decision. Returns `true` when the destination's ++ /// active route changed — a fresh install, or a different interface / ++ /// next hop than before — so callers know when to re-probe or notify. ++ /// ++ /// `Activate` demotes the previous active into the alternate ring; ++ /// `Backup` refreshes the matching alternate slot or inserts a new one, ++ /// evicting the worst-ranked slot once `MAX_PATH_SLOTS` is reached. ++ pub fn upsert_ranked( ++ &mut self, ++ dest_hash: impl Into, ++ entry: PathEntry, ++ rank: PathRank, ++ preference: PathMediumPreference, ++ ) -> bool { ++ let hash: DestHash = dest_hash.into(); ++ match rank { ++ PathRank::Reject => false, ++ PathRank::Activate => { ++ let interface_id = entry.interface_id; ++ let next_hop = entry.next_hop; ++ let previous = self.entries.insert(hash, entry); ++ self.states.remove(&hash); ++ self.drop_backup_slot(&hash, interface_id, next_hop); ++ let changed = match previous { ++ Some(previous) => { ++ let changed = !is_same_route(&previous, interface_id, next_hop); ++ if changed { ++ self.backups.entry(hash).or_default().push(previous); ++ } ++ changed ++ } ++ None => true, ++ }; ++ self.resort_backups(&hash, preference); ++ changed ++ } ++ PathRank::Backup => { ++ if !self.entries.contains_key(hash.as_bytes()) { ++ // No active route to back up — install directly so the ++ // alternate ring never holds the only known path. ++ self.states.remove(&hash); ++ self.entries.insert(hash, entry); ++ return true; ++ } ++ let backups = self.backups.entry(hash).or_default(); ++ match backups ++ .iter_mut() ++ .find(|slot| is_same_route(slot, entry.interface_id, entry.next_hop)) ++ { ++ Some(slot) => *slot = entry, ++ None => backups.push(entry), ++ } ++ self.resort_backups(&hash, preference); ++ false ++ } ++ } + } + -+ /// Set the global medium preference and re-rank every known destination so -+ /// the change applies without waiting for fresh announces. Returns the -+ /// number of destinations whose active route moved. -+ pub(super) fn set_path_medium_preference(&mut self, preference: PathMediumPreference) -> usize { -+ self.path_medium_preference = preference; -+ self.path_table.set_preference(preference); -+ let moved = self.rerank_all_paths(); -+ debug!( -+ preference = ?preference, -+ moved, "path medium preference updated" -+ ); -+ moved ++ /// Re-rank a destination's slots after a preference or pin change, without ++ /// waiting for a new announce. Returns `true` when the active route moved. ++ /// Ties keep the incumbent active so a preference write cannot flap a route. ++ pub fn rerank(&mut self, dest_hash: &[u8; 16], preference: PathMediumPreference) -> bool { ++ let hash: DestHash = (*dest_hash).into(); ++ if !self.backups.contains_key(&hash) { ++ return false; ++ } ++ let Some(active) = self.entries.remove(&hash) else { ++ self.backups.remove(&hash); ++ return false; ++ }; ++ let previous = (active.interface_id, active.next_hop); ++ let incumbent_rank = slot_rank_key(&active, preference); ++ let mut slots = vec![active]; ++ slots.extend(self.backups.remove(&hash).unwrap_or_default()); ++ sort_slots(&mut slots, preference); ++ if slot_rank_key(&slots[0], preference) >= incumbent_rank { ++ // Nothing strictly better — keep the incumbent to avoid a flap. ++ let position = slots ++ .iter() ++ .position(|entry| is_same_route(entry, previous.0, previous.1)) ++ .unwrap_or(0); ++ let incumbent = slots.remove(position); ++ slots.insert(0, incumbent); ++ } ++ let best = slots.remove(0); ++ let changed = !is_same_route(&best, previous.0, previous.1); ++ if changed { ++ self.states.remove(&hash); ++ } ++ self.entries.insert(hash, best); ++ if !slots.is_empty() { ++ self.backups.insert(hash, slots); ++ } ++ self.resort_backups(&hash, preference); ++ changed + } + -+ /// Pin or unpin a destination to a medium. Returns `true` when the pin -+ /// changed the destination's active route. -+ pub(super) fn set_peer_medium_pin(&mut self, dest: [u8; 16], pin: Option) -> bool { -+ match pin { -+ Some(medium) => { -+ self.peer_medium_pins.insert(dest, medium); -+ } -+ None => { -+ self.peer_medium_pins.remove(&dest); ++ /// Drop every slot for a destination that goes through `interface_id` and ++ /// promote the best remaining live alternate. Called when a probe or link ++ /// attempt over the active interface fails, so the reroute happens before ++ /// rediscovery rather than after it. ++ /// ++ /// Returns `true` when the active slot was owned by `interface_id` — it is ++ /// now either a promoted alternate or absent. ++ pub fn suppress_interface( ++ &mut self, ++ dest_hash: &[u8; 16], ++ interface_id: InterfaceId, ++ preference: PathMediumPreference, ++ ) -> bool { ++ let hash: DestHash = (*dest_hash).into(); ++ if let Some(backups) = self.backups.get_mut(&hash) { ++ backups.retain(|entry| entry.interface_id != interface_id); ++ if backups.is_empty() { ++ self.backups.remove(&hash); + } + } -+ let preference = self.effective_path_medium_preference(&dest); -+ let moved = self.path_table.rerank(&dest, preference); -+ if moved { -+ self.state_dirty = true; ++ let owns_active = self ++ .entries ++ .get(&hash) ++ .is_some_and(|entry| entry.interface_id == interface_id); ++ if !owns_active { ++ return false; + } -+ debug!( -+ dest = %hex::encode(dest), -+ pin = ?pin, -+ moved, -+ "peer medium pin updated" -+ ); -+ moved ++ self.entries.remove(&hash); ++ self.states.remove(&hash); ++ self.promote_best_backup(&hash, preference); ++ true ++ } ++ ++ /// Promote the best live alternate into an empty active slot. Returns ++ /// `true` when a route was promoted. ++ fn promote_best_backup(&mut self, hash: &DestHash, preference: PathMediumPreference) -> bool { ++ if self.entries.contains_key(hash.as_bytes()) { ++ return false; ++ } ++ let Some(mut backups) = self.backups.remove(hash) else { ++ return false; ++ }; ++ backups.retain(|entry| !entry.is_expired()); ++ sort_slots(&mut backups, preference); ++ if backups.is_empty() { ++ return false; ++ } ++ let promoted = backups.remove(0); ++ if !backups.is_empty() { ++ self.backups.insert(*hash, backups); ++ } ++ // A promoted route is not the one the old liveness reading described. ++ self.states.remove(hash); ++ self.entries.insert(*hash, promoted); ++ true ++ } ++ ++ fn resort_backups(&mut self, hash: &DestHash, preference: PathMediumPreference) { ++ let Some(backups) = self.backups.get_mut(hash) else { ++ return; ++ }; ++ sort_slots(backups, preference); ++ backups.truncate(MAX_PATH_SLOTS.saturating_sub(1)); ++ if backups.is_empty() { ++ self.backups.remove(hash); ++ } ++ } ++ ++ fn drop_backup_slot( ++ &mut self, ++ hash: &DestHash, ++ interface_id: InterfaceId, ++ next_hop: Option<[u8; 16]>, ++ ) { ++ let Some(backups) = self.backups.get_mut(hash) else { ++ return; ++ }; ++ backups.retain(|entry| !is_same_route(entry, interface_id, next_hop)); ++ if backups.is_empty() { ++ self.backups.remove(hash); ++ } ++ } ++ ++ fn retain_backups(&mut self, keep: impl Fn(&PathEntry) -> bool) { ++ self.backups.retain(|_, backups| { ++ backups.retain(&keep); ++ !backups.is_empty() ++ }); + } + -+ fn rerank_all_paths(&mut self) -> usize { -+ let dests: Vec<[u8; 16]> = self -+ .path_table ++ /// Remove every active entry matching `drop`, promoting the best live ++ /// alternate for each. Returns the number of active entries removed. ++ fn drop_active_where(&mut self, drop: impl Fn(&PathEntry) -> bool) -> usize { ++ let hashes: Vec = self ++ .entries + .iter() -+ .map(|(hash, _)| hash.into_bytes()) ++ .filter(|(_, entry)| drop(entry)) ++ .map(|(hash, _)| *hash) + .collect(); -+ let mut moved = 0usize; -+ for dest in dests { -+ let preference = self.effective_path_medium_preference(&dest); -+ if self.path_table.rerank(&dest, preference) { -+ moved += 1; -+ } -+ } -+ if moved > 0 { -+ self.state_dirty = true; ++ let preference = self.preference; ++ for hash in &hashes { ++ self.entries.remove(hash.as_bytes()); ++ self.states.remove(hash.as_bytes()); ++ self.promote_best_backup(hash, preference); + } -+ moved + self.states + .retain(|hash, _| self.entries.contains_key(hash)); +- before - self.entries.len() ++ hashes.len() } - fn is_path_interface_suppressed( -@@ -1697,16 +1782,6 @@ - .as_ref() - .map(|online| !online.load(std::sync::atomic::Ordering::SeqCst)) - .unwrap_or(false) --} -- --fn announce_timebase(random_blob: &[u8; 10]) -> u64 { -- let mut emitted = [0u8; 8]; -- emitted[3..].copy_from_slice(&random_blob[5..10]); -- u64::from_be_bytes(emitted) --} -- --fn path_timebase_from_random_blobs<'a>(random_blobs: impl Iterator) -> u64 { -- random_blobs.map(announce_timebase).max().unwrap_or(0) + pub fn len(&self) -> usize { +@@ -263,10 +641,153 @@ fn path_expiry(mode: InterfaceMode) -> u64 { + } } - /// Random jitter in `[0, PATHFINDER_RW)` for announce rebroadcast timing. -@@ -2011,6 +2086,14 @@ - (entry, rx) - } ++/// Classify an interface mode into its transport medium. ++/// ++/// RNodes and other radios are configured as access points (the RNode default) ++/// or roaming; every other mode is an IP-style network link. ++pub fn path_medium(mode: InterfaceMode) -> PathMedium { ++ match mode { ++ InterfaceMode::AccessPoint | InterfaceMode::Roaming => PathMedium::Rf, ++ _ => PathMedium::Network, ++ } ++} ++ ++/// Resolve a per-destination pin against the global preference. A pin always ++/// wins; without one the global setting applies. ++pub fn effective_path_medium_preference( ++ global: PathMediumPreference, ++ pin: Option, ++) -> PathMediumPreference { ++ match pin { ++ Some(PathMedium::Rf) => PathMediumPreference::Rf, ++ Some(PathMedium::Network) => PathMediumPreference::Network, ++ None => global, ++ } ++} ++ ++/// 0 for the preferred medium, 1 for the other. `Lowest` ranks both at 0 so ++/// hop count alone decides. ++fn medium_rank(medium: PathMedium, preference: PathMediumPreference) -> u8 { ++ let preferred = match preference { ++ PathMediumPreference::Lowest => return 0, ++ PathMediumPreference::Network => PathMedium::Network, ++ PathMediumPreference::Rf => PathMedium::Rf, ++ }; ++ u8::from(medium != preferred) ++} ++ ++/// Two slots describe the same route when they share both the learning ++/// interface and the next hop; a hub reachable over two interfaces, or two ++/// relays on one interface, are distinct slots. ++fn is_same_route(entry: &PathEntry, interface_id: InterfaceId, next_hop: Option<[u8; 16]>) -> bool { ++ entry.interface_id == interface_id && entry.next_hop == next_hop ++} ++ ++/// Routing quality of a slot: live before expired, preferred medium before the ++/// other, fewer hops before more. Deliberately excludes learn time so a ++/// re-rank cannot hand the active slot to an equally good route. ++fn slot_rank_key(entry: &PathEntry, preference: PathMediumPreference) -> (u8, u8, u8) { ++ ( ++ u8::from(entry.is_expired()), ++ medium_rank(entry.medium, preference), ++ entry.hops, ++ ) ++} ++ ++/// Rank plus newest-learn-time, used to order the backup ring and to decide ++/// which slot is evicted once `MAX_PATH_SLOTS` is reached. ++fn slot_sort_key(entry: &PathEntry, preference: PathMediumPreference) -> (u8, u8, u8, f64) { ++ let (expired, medium, hops) = slot_rank_key(entry, preference); ++ (expired, medium, hops, -entry.timestamp) ++} ++ ++/// Stable sort so equal-ranked slots keep their relative order — callers rely ++/// on that to avoid flapping the active route. ++fn sort_slots(slots: &mut [PathEntry], preference: PathMediumPreference) { ++ slots.sort_by(|a, b| { ++ slot_sort_key(a, preference) ++ .partial_cmp(&slot_sort_key(b, preference)) ++ .unwrap_or(std::cmp::Ordering::Equal) ++ }); ++} ++ ++/// Announce emission timebase encoded in the trailing 5 bytes of an announce ++/// random blob. Used to order announces without trusting the hop count. ++pub fn announce_timebase(random_blob: &[u8; 10]) -> u64 { ++ let mut emitted = [0u8; 8]; ++ emitted[3..].copy_from_slice(&random_blob[5..10]); ++ u64::from_be_bytes(emitted) ++} ++ ++/// Newest announce timebase recorded on a path. ++pub fn path_timebase_from_random_blobs<'a>( ++ random_blobs: impl Iterator, ++) -> u64 { ++ random_blobs.map(announce_timebase).max().unwrap_or(0) ++} ++ + #[cfg(test)] + mod tests { + use super::*; -+ /// Radio interfaces run in access-point mode, so paths learned here rank as -+ /// [`PathMedium::Rf`]. -+ fn make_rf_test_interface(name: &str) -> (InterfaceEntry, mpsc::Receiver) { -+ let (mut entry, rx) = make_test_interface(name); -+ entry.mode = InterfaceMode::AccessPoint; -+ (entry, rx) ++ /// Announce blob whose trailing bytes encode `emitted` — mirrors what the ++ /// wire carries so `announce_timebase` agrees with the test's intent. ++ fn blob_for(prefix: u8, emitted: u64) -> [u8; 10] { ++ let mut blob = [prefix; 10]; ++ let emitted = emitted.to_be_bytes(); ++ blob[5..].copy_from_slice(&emitted[3..8]); ++ blob ++ } ++ ++ fn announced( ++ hops: u8, ++ interface_id: InterfaceId, ++ medium: PathMedium, ++ blob: [u8; 10], ++ ) -> AnnouncedPath { ++ AnnouncedPath { ++ interface_id, ++ next_hop: None, ++ hops, ++ medium, ++ random_blob: blob, ++ emitted: announce_timebase(&blob), ++ suppressed: false, ++ } ++ } ++ ++ fn slot(hops: u8, interface_id: InterfaceId, medium: PathMedium, blob: [u8; 10]) -> PathEntry { ++ let mode = match medium { ++ PathMedium::Rf => InterfaceMode::AccessPoint, ++ PathMedium::Network => InterfaceMode::Gateway, ++ }; ++ let mut entry = PathEntry::new(None, hops, interface_id, mode); ++ entry.add_random_blob(blob); ++ entry ++ } ++ ++ /// Rank + apply in one step, the way the announce path does. ++ fn learn( ++ table: &mut PathTable, ++ dest: [u8; 16], ++ candidate: &AnnouncedPath, ++ preference: PathMediumPreference, ++ ) -> PathRank { ++ let rank = table.rank_announced(&dest, candidate, preference); ++ let entry = slot( ++ candidate.hops, ++ candidate.interface_id, ++ candidate.medium, ++ candidate.random_blob, ++ ); ++ table.upsert_ranked(dest, entry, rank, preference); ++ rank ++ } ++ ++ fn interface_ids(slots: &[PathEntry]) -> Vec { ++ slots.iter().map(|entry| entry.interface_id).collect() + } + #[test] - fn test_actor_creation() { - let (actor, _tx) = TransportActor::new(); -@@ -4242,6 +4325,9 @@ - assert!(actor.state_dirty); + fn test_path_table_basic() { + let mut table = PathTable::new(); +@@ -391,6 +912,448 @@ mod tests { + assert!(!result); } -+ /// A replay of the same announce on another interface that is *no closer* -+ /// to the source keeps its hands off the active route — it only earns a -+ /// backup slot, and none of the announce-driven side effects fire. - #[test] - fn test_replayed_announce_random_blob_does_not_replace_path() { - let (mut actor, _tx) = TransportActor::new(); -@@ -4254,9 +4340,9 @@ - let identity = rns_identity::identity::Identity::new(); - let blob = random_blob(0xA1, 100); - let (raw_first, dest_hash) = -- make_announce_for_with_random_blob(&identity, "test.replay.same", 3, blob); -- let (raw_replay, _) = - make_announce_for_with_random_blob(&identity, "test.replay.same", 1, blob); -+ let (raw_replay, _) = -+ make_announce_for_with_random_blob(&identity, "test.replay.same", 3, blob); - let (htx, mut hrx) = mpsc::channel(8); - actor.announce_handlers.push(AnnounceHandlerRegistration { - id: crate::messages::AnnounceHandlerId(0), -@@ -4274,7 +4360,7 @@ - q: None, - }); - let first_event = hrx.try_recv().expect("fresh announce should dispatch"); -- assert_eq!(first_event.hops, 4); -+ assert_eq!(first_event.hops, 2); - - actor.on_inbound(InboundPacket { - raw: raw_replay, -@@ -4285,13 +4371,19 @@ - }); - - let path = actor.path_table.get(&dest_hash).unwrap(); -- assert_eq!(path.hops, 4); -+ assert_eq!(path.hops, 2); - assert_eq!(path.interface_id, 1); - assert_eq!(path.random_blobs.len(), 1); - assert!(path.has_random_blob(&blob)); - assert_eq!( -+ actor.path_table.backups(&dest_hash).len(), -+ 1, -+ "the longer route is still worth keeping as an alternate" ++ #[test] ++ fn path_medium_maps_radio_modes_to_rf() { ++ assert_eq!(path_medium(InterfaceMode::AccessPoint), PathMedium::Rf); ++ assert_eq!(path_medium(InterfaceMode::Roaming), PathMedium::Rf); ++ for mode in [ ++ InterfaceMode::Full, ++ InterfaceMode::Gateway, ++ InterfaceMode::Boundary, ++ InterfaceMode::PointToPoint, ++ InterfaceMode::Internal, ++ ] { ++ assert_eq!(path_medium(mode), PathMedium::Network, "{mode:?}"); ++ } ++ } ++ ++ #[test] ++ fn medium_and_preference_round_trip_their_wire_spelling() { ++ for medium in [PathMedium::Rf, PathMedium::Network] { ++ assert_eq!(PathMedium::from_str_opt(medium.as_str()), Some(medium)); ++ } ++ for preference in [ ++ PathMediumPreference::Lowest, ++ PathMediumPreference::Network, ++ PathMediumPreference::Rf, ++ ] { ++ assert_eq!( ++ PathMediumPreference::from_str_opt(preference.as_str()), ++ Some(preference) ++ ); ++ } ++ assert_eq!(PathMedium::from_str_opt(" RF "), Some(PathMedium::Rf)); ++ assert_eq!(PathMedium::from_str_opt("lora"), None); ++ assert_eq!(PathMediumPreference::from_str_opt("fastest"), None); ++ } ++ ++ #[test] ++ fn effective_preference_prefers_the_pin() { ++ assert_eq!( ++ effective_path_medium_preference(PathMediumPreference::Lowest, Some(PathMedium::Rf)), ++ PathMediumPreference::Rf + ); -+ assert_eq!(actor.path_table.backups(&dest_hash)[0].interface_id, 2); + assert_eq!( - actor.recent_announces.get(&dest_hash).unwrap().hops, -- 4, -+ 2, - "replayed announces must not refresh recent announce state" - ); - assert!( -@@ -4309,7 +4401,335 @@ - ); - } - -+ /// Same announce, fewer hops, second interface: the shorter route wins even -+ /// though the random blob was already seen. This is the multi-TCP / RF+TCP -+ /// case that the pre-multipath replay guard used to block. - #[test] -+ fn replayed_announce_with_fewer_hops_takes_over_and_demotes_the_old_route() { -+ let (mut actor, _tx) = TransportActor::new(); -+ actor.is_transport_enabled = true; -+ let (entry1, _rx1) = make_test_interface("iface1"); -+ let (entry2, _rx2) = make_test_interface("iface2"); -+ actor.interfaces.insert(1, entry1); -+ actor.interfaces.insert(2, entry2); ++ effective_path_medium_preference(PathMediumPreference::Rf, Some(PathMedium::Network)), ++ PathMediumPreference::Network ++ ); ++ assert_eq!( ++ effective_path_medium_preference(PathMediumPreference::Network, None), ++ PathMediumPreference::Network ++ ); ++ } + -+ let identity = rns_identity::identity::Identity::new(); -+ let blob = random_blob(0xA5, 100); -+ let (raw_far, dest_hash) = -+ make_announce_for_with_random_blob(&identity, "test.replay.closer", 3, blob); -+ let (raw_close, _) = -+ make_announce_for_with_random_blob(&identity, "test.replay.closer", 1, blob); ++ /// The multi-TCP case: the same announce heard closer to the source on a ++ /// second interface takes over, and the longer route is kept as a backup. ++ #[test] ++ fn same_blob_with_fewer_hops_on_another_interface_takes_active_slot() { ++ let mut table = PathTable::new(); ++ let dest = [0xA0; 16]; ++ let blob = blob_for(0x11, 100); ++ table.insert(dest, slot(4, 1, PathMedium::Network, blob)); + -+ actor.on_inbound(InboundPacket { -+ raw: raw_far, -+ interface_id: 1, -+ rssi: None, -+ snr: None, -+ q: None, -+ }); -+ actor.on_inbound(InboundPacket { -+ raw: raw_close, -+ interface_id: 2, -+ rssi: None, -+ snr: None, -+ q: None, -+ }); ++ let candidate = announced(2, 2, PathMedium::Network, blob); ++ assert_eq!( ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest), ++ PathRank::Activate ++ ); + -+ let path = actor.path_table.get(&dest_hash).unwrap(); -+ assert_eq!(path.hops, 2); -+ assert_eq!(path.interface_id, 2); -+ let backups = actor.path_table.backups(&dest_hash); -+ assert_eq!(backups.len(), 1); -+ assert_eq!(backups[0].interface_id, 1); -+ assert_eq!(backups[0].hops, 4); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 2); ++ assert_eq!(table.hops_to(&dest), Some(2)); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![1]); ++ assert_eq!(table.slots(&dest).len(), 2); + } + -+ /// A failed probe or link attempt reroutes onto the backup immediately — -+ /// no path request, no fresh announce. + #[test] -+ fn suppressing_the_current_path_interface_promotes_the_backup_route() { -+ let (mut actor, _tx) = TransportActor::new(); -+ actor.is_transport_enabled = true; -+ let (entry1, _rx1) = make_test_interface("iface1"); -+ let (entry2, _rx2) = make_test_interface("iface2"); -+ actor.interfaces.insert(1, entry1); -+ actor.interfaces.insert(2, entry2); ++ fn same_blob_with_more_hops_on_another_interface_is_backup_only() { ++ let mut table = PathTable::new(); ++ let dest = [0xA1; 16]; ++ let blob = blob_for(0x22, 100); ++ table.insert(dest, slot(2, 1, PathMedium::Network, blob)); + -+ let identity = rns_identity::identity::Identity::new(); -+ let blob = random_blob(0xA6, 100); -+ let (raw_close, dest_hash) = -+ make_announce_for_with_random_blob(&identity, "test.multipath.failover", 1, blob); -+ let (raw_far, _) = -+ make_announce_for_with_random_blob(&identity, "test.multipath.failover", 4, blob); -+ for (raw, interface_id) in [(raw_close, 1u64), (raw_far, 2)] { -+ actor.on_inbound(InboundPacket { -+ raw, -+ interface_id, -+ rssi: None, -+ snr: None, -+ q: None, -+ }); -+ } -+ assert_eq!(actor.path_table.get(&dest_hash).unwrap().interface_id, 1); -+ assert_eq!(actor.path_table.backups(&dest_hash).len(), 1); ++ let candidate = announced(5, 2, PathMedium::Network, blob); ++ assert_eq!( ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest), ++ PathRank::Backup ++ ); + -+ match actor.handle_query(TransportQuery::SuppressCurrentPathInterface { -+ dest: dest_hash, -+ duration: 30.0, -+ }) { -+ TransportQueryResponse::BoolResult(true) => {} -+ other => panic!("expected current path-interface suppression, got {other:?}"), ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); ++ } ++ ++ /// Equal hop counts must not flap the route, even across interfaces. ++ #[test] ++ fn equal_hops_on_another_interface_does_not_flap_the_active_slot() { ++ let mut table = PathTable::new(); ++ let dest = [0xA2; 16]; ++ let blob = blob_for(0x33, 100); ++ table.insert(dest, slot(3, 1, PathMedium::Network, blob)); ++ ++ let same_blob = announced(3, 2, PathMedium::Network, blob); ++ assert_eq!( ++ learn(&mut table, dest, &same_blob, PathMediumPreference::Lowest), ++ PathRank::Backup ++ ); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ ++ // An *older* announce at the same hop count is equally powerless. ++ let older_blob = announced(3, 3, PathMedium::Network, blob_for(0x44, 99)); ++ assert_eq!( ++ learn(&mut table, dest, &older_blob, PathMediumPreference::Lowest), ++ PathRank::Backup ++ ); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert_eq!(table.hops_to(&dest), Some(3)); ++ } ++ ++ #[test] ++ fn a_fourth_route_evicts_the_worst_ranked_slot() { ++ let mut table = PathTable::new(); ++ let dest = [0xA3; 16]; ++ let blob = blob_for(0x55, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ for (hops, interface_id) in [(2u8, 2u64), (5, 3)] { ++ let candidate = announced(hops, interface_id, PathMedium::Network, blob); ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest); + } ++ assert_eq!(interface_ids(table.backups(&dest)), vec![2, 3]); ++ assert_eq!(table.slots(&dest).len(), MAX_PATH_SLOTS); + -+ let path = actor -+ .path_table -+ .get(&dest_hash) -+ .expect("the backup route should now carry the destination"); -+ assert_eq!(path.interface_id, 2); -+ assert_eq!(path.hops, 5); -+ assert!(actor.path_table.backups(&dest_hash).is_empty()); ++ let fourth = announced(3, 4, PathMedium::Network, blob); ++ learn(&mut table, dest, &fourth, PathMediumPreference::Lowest); ++ ++ assert_eq!(table.slots(&dest).len(), MAX_PATH_SLOTS); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert_eq!( ++ interface_ids(table.backups(&dest)), ++ vec![2, 4], ++ "the 5-hop route should be the one evicted" ++ ); + } + + #[test] -+ fn network_preference_keeps_an_rf_shortcut_out_of_the_active_slot() { -+ let (mut actor, _tx) = TransportActor::new(); -+ actor.is_transport_enabled = true; -+ let (tcp, _tcp_rx) = make_test_interface("tcp"); -+ let (rf, _rf_rx) = make_rf_test_interface("rnode"); -+ actor.interfaces.insert(1, tcp); -+ actor.interfaces.insert(2, rf); ++ fn suppressing_the_active_interface_promotes_a_backup() { ++ let mut table = PathTable::new(); ++ let dest = [0xA4; 16]; ++ let blob = blob_for(0x66, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let candidate = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest); ++ table.set_state(dest, PathState::Responsive); ++ ++ assert!(table.suppress_interface(&dest, 1, PathMediumPreference::Lowest)); ++ ++ let active = table.get(&dest).expect("backup should have been promoted"); ++ assert_eq!(active.interface_id, 2); ++ assert_eq!(active.hops, 4); ++ assert!(table.backups(&dest).is_empty()); + assert_eq!( -+ actor.set_path_medium_preference(PathMediumPreference::Network), -+ 0 ++ table.get_state(&dest), ++ PathState::Unknown, ++ "a promoted route must be re-probed" ++ ); ++ } ++ ++ #[test] ++ fn suppressing_the_only_interface_leaves_the_destination_unrouted() { ++ let mut table = PathTable::new(); ++ let dest = [0xA5; 16]; ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob_for(0x77, 100))); ++ ++ assert!(table.suppress_interface(&dest, 1, PathMediumPreference::Lowest)); ++ assert!(!table.has_path(&dest)); ++ ++ assert!( ++ !table.suppress_interface(&dest, 1, PathMediumPreference::Lowest), ++ "an unknown destination cannot be rerouted" + ); ++ } + -+ let identity = rns_identity::identity::Identity::new(); -+ let blob = random_blob(0xA7, 100); -+ let (raw_tcp, dest_hash) = -+ make_announce_for_with_random_blob(&identity, "test.multipath.netpref", 4, blob); -+ let (raw_rf, _) = -+ make_announce_for_with_random_blob(&identity, "test.multipath.netpref", 0, blob); -+ for (raw, interface_id) in [(raw_tcp, 1u64), (raw_rf, 2)] { -+ actor.on_inbound(InboundPacket { -+ raw, -+ interface_id, -+ rssi: None, -+ snr: None, -+ q: None, -+ }); -+ } ++ #[test] ++ fn suppressing_a_backup_interface_leaves_the_active_route_alone() { ++ let mut table = PathTable::new(); ++ let dest = [0xA6; 16]; ++ let blob = blob_for(0x88, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let candidate = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest); + -+ let path = actor.path_table.get(&dest_hash).unwrap(); -+ assert_eq!(path.medium, PathMedium::Network); -+ assert_eq!(path.interface_id, 1); -+ let backups = actor.path_table.backups(&dest_hash); -+ assert_eq!(backups.len(), 1); -+ assert_eq!(backups[0].medium, PathMedium::Rf); -+ assert_eq!(backups[0].hops, 1); ++ assert!(!table.suppress_interface(&dest, 2, PathMediumPreference::Lowest)); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert!(table.backups(&dest).is_empty()); + } + -+ /// A per-destination RF pin beats the global `Lowest` default, survives an -+ /// RF failure, and reclaims the route once RF is heard again. + #[test] -+ fn rf_pin_overrides_global_preference_and_survives_failover() { -+ let (mut actor, _tx) = TransportActor::new(); -+ actor.is_transport_enabled = true; -+ let (tcp, _tcp_rx) = make_test_interface("tcp"); -+ let (rf, _rf_rx) = make_rf_test_interface("rnode"); -+ actor.interfaces.insert(1, tcp); -+ actor.interfaces.insert(2, rf); ++ fn network_preference_keeps_a_shorter_rf_route_in_the_backups() { ++ let mut table = PathTable::new(); ++ let dest = [0xA7; 16]; ++ let blob = blob_for(0x99, 100); ++ table.insert(dest, slot(4, 1, PathMedium::Network, blob)); + -+ let identity = rns_identity::identity::Identity::new(); -+ let (raw_tcp, dest_hash) = make_announce_for_with_random_blob( -+ &identity, -+ "test.multipath.pin", -+ 0, -+ random_blob(0xA8, 100), -+ ); -+ let (raw_rf, _) = make_announce_for_with_random_blob( -+ &identity, -+ "test.multipath.pin", -+ 5, -+ random_blob(0xA9, 101), ++ let rf = announced(1, 2, PathMedium::Rf, blob); ++ assert_eq!( ++ learn(&mut table, dest, &rf, PathMediumPreference::Network), ++ PathRank::Backup + ); -+ assert!(!actor.set_peer_medium_pin(dest_hash, Some(PathMedium::Rf))); -+ for (raw, interface_id) in [(raw_tcp, 1u64), (raw_rf, 2)] { -+ actor.on_inbound(InboundPacket { -+ raw, -+ interface_id, -+ rssi: None, -+ snr: None, -+ q: None, -+ }); -+ } ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); + -+ // The 6-hop RF route wins over a 1-hop network route because of the pin. ++ // Without a medium preference the same announce would have won. ++ let mut lowest = PathTable::new(); ++ lowest.insert(dest, slot(4, 1, PathMedium::Network, blob)); + assert_eq!( -+ actor.path_table.get(&dest_hash).unwrap().medium, -+ PathMedium::Rf ++ learn(&mut lowest, dest, &rf, PathMediumPreference::Lowest), ++ PathRank::Activate + ); ++ assert_eq!(lowest.get(&dest).unwrap().interface_id, 2); ++ } + -+ match actor.handle_query(TransportQuery::SuppressCurrentPathInterface { -+ dest: dest_hash, -+ duration: 30.0, -+ }) { -+ TransportQueryResponse::BoolResult(true) => {} -+ other => panic!("expected suppression, got {other:?}"), -+ } ++ #[test] ++ fn rf_preference_reclaims_the_active_slot_from_a_shorter_network_route() { ++ let mut table = PathTable::new(); ++ let dest = [0xA8; 16]; ++ let blob = blob_for(0xAA, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ ++ let rf = announced(6, 2, PathMedium::Rf, blob); + assert_eq!( -+ actor.path_table.get(&dest_hash).unwrap().medium, -+ PathMedium::Network, -+ "with no live RF slot the network route must carry traffic" ++ learn(&mut table, dest, &rf, PathMediumPreference::Rf), ++ PathRank::Activate + ); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![1]); ++ } ++ ++ /// Prefer-if-possible: an RF pin with no live RF slot falls back to the ++ /// network route, and the RF route reclaims the slot when heard again. ++ #[test] ++ fn rf_preference_falls_back_to_network_and_reclaims_later() { ++ let mut table = PathTable::new(); ++ let dest = [0xA9; 16]; ++ let blob = blob_for(0xBB, 100); ++ table.insert(dest, slot(6, 1, PathMedium::Rf, blob)); ++ let network = announced(2, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &network, PathMediumPreference::Rf); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); ++ ++ assert!(table.suppress_interface(&dest, 1, PathMediumPreference::Rf)); + assert_eq!( -+ actor.peer_medium_pins.get(&dest_hash).copied(), -+ Some(PathMedium::Rf), -+ "failover must not clear the pin" ++ table.get(&dest).unwrap().medium, ++ PathMedium::Network, ++ "with no RF slot left the network route must carry traffic" + ); + -+ // Once suppression lapses, a fresh RF announce reclaims the route. -+ actor.path_interface_suppressions.clear(); -+ let (raw_rf_again, _) = make_announce_for_with_random_blob( -+ &identity, -+ "test.multipath.pin", -+ 5, -+ random_blob(0xAA, 102), -+ ); -+ actor.on_inbound(InboundPacket { -+ raw: raw_rf_again, -+ interface_id: 2, -+ rssi: None, -+ snr: None, -+ q: None, -+ }); ++ let rf_again = announced(6, 3, PathMedium::Rf, blob_for(0xCC, 101)); + assert_eq!( -+ actor.path_table.get(&dest_hash).unwrap().medium, -+ PathMedium::Rf ++ learn(&mut table, dest, &rf_again, PathMediumPreference::Rf), ++ PathRank::Activate + ); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); + } + + #[test] -+ fn changing_the_global_preference_reranks_known_destinations() { -+ let (mut actor, _tx) = TransportActor::new(); -+ actor.is_transport_enabled = true; -+ let (tcp, _tcp_rx) = make_test_interface("tcp"); -+ let (rf, _rf_rx) = make_rf_test_interface("rnode"); -+ actor.interfaces.insert(1, tcp); -+ actor.interfaces.insert(2, rf); ++ fn preference_change_reranks_without_a_new_announce() { ++ let mut table = PathTable::new(); ++ let dest = [0xB0; 16]; ++ let blob = blob_for(0xDD, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Rf, blob)); ++ let network = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &network, PathMediumPreference::Lowest); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Rf); + -+ let identity = rns_identity::identity::Identity::new(); -+ let blob = random_blob(0xAB, 100); -+ let (raw_rf, dest_hash) = -+ make_announce_for_with_random_blob(&identity, "test.multipath.rerank", 0, blob); -+ let (raw_tcp, _) = -+ make_announce_for_with_random_blob(&identity, "test.multipath.rerank", 4, blob); -+ for (raw, interface_id) in [(raw_rf, 2u64), (raw_tcp, 1)] { -+ actor.on_inbound(InboundPacket { -+ raw, -+ interface_id, -+ rssi: None, -+ snr: None, -+ q: None, -+ }); -+ } ++ table.set_preference(PathMediumPreference::Network); ++ assert!(table.rerank(&dest, PathMediumPreference::Network)); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Network); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![1]); ++ ++ // Re-running the same rerank is a no-op — no flap on repeated writes. ++ assert!(!table.rerank(&dest, PathMediumPreference::Network)); ++ assert_eq!(table.get(&dest).unwrap().medium, PathMedium::Network); ++ } ++ ++ #[test] ++ fn rerank_keeps_the_incumbent_on_ties() { ++ let mut table = PathTable::new(); ++ let dest = [0xB1; 16]; ++ let blob = blob_for(0xEE, 100); ++ table.insert(dest, slot(3, 1, PathMedium::Network, blob)); ++ let peer = announced(3, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ ++ assert!(!table.rerank(&dest, PathMediumPreference::Lowest)); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ } ++ ++ #[test] ++ fn suppressed_interfaces_are_rejected_outright() { ++ let mut table = PathTable::new(); ++ let dest = [0xB2; 16]; ++ let blob = blob_for(0x0F, 100); ++ table.insert(dest, slot(4, 1, PathMedium::Network, blob)); ++ ++ let mut candidate = announced(1, 2, PathMedium::Network, blob); ++ candidate.suppressed = true; + assert_eq!( -+ actor.path_table.get(&dest_hash).unwrap().medium, -+ PathMedium::Rf, -+ "Lowest preference should pick the 1-hop RF route" ++ table.rank_announced(&dest, &candidate, PathMediumPreference::Lowest), ++ PathRank::Reject + ); -+ + assert_eq!( -+ actor.set_path_medium_preference(PathMediumPreference::Network), -+ 1 ++ learn(&mut table, dest, &candidate, PathMediumPreference::Lowest), ++ PathRank::Reject + ); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert!(table.backups(&dest).is_empty()); ++ } ++ ++ #[test] ++ fn drop_all_via_clears_backups_and_promotes() { ++ let mut table = PathTable::new(); ++ let dest = [0xB3; 16]; ++ let blob = blob_for(0x1F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ + assert_eq!( -+ actor.path_table.get(&dest_hash).unwrap().medium, -+ PathMedium::Network ++ table.drop_all_via(2), ++ 0, ++ "no active path went through iface 2" + ); ++ assert!(table.backups(&dest).is_empty()); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ assert_eq!(table.drop_all_via(1), 1); + assert_eq!( -+ actor.path_table.backups(&dest_hash)[0].medium, -+ PathMedium::Rf ++ table.get(&dest).unwrap().interface_id, ++ 2, ++ "the surviving backup should carry the destination" + ); ++ assert!(table.backups(&dest).is_empty()); ++ } ++ ++ #[test] ++ fn cull_expired_promotes_a_live_backup() { ++ let mut table = PathTable::new(); ++ let dest = [0xB4; 16]; ++ let blob = blob_for(0x2F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ table.get_mut(&dest).unwrap().expires = now_f64() - 1.0; ++ ++ assert_eq!(table.cull_expired(), 1); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 2); ++ assert!(table.has_path(&dest)); ++ } ++ ++ #[test] ++ fn cull_expired_drops_expired_backups_before_promoting() { ++ let mut table = PathTable::new(); ++ let dest = [0xB5; 16]; ++ let blob = blob_for(0x3F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ table.get_mut(&dest).unwrap().expires = now_f64() - 1.0; ++ table.backups.get_mut(&DestHash::from(dest)).unwrap()[0].expires = now_f64() - 1.0; ++ ++ assert_eq!(table.cull_expired(), 1); ++ assert!(!table.has_path(&dest)); ++ assert!(table.slots(&dest).is_empty()); + } + + #[test] -+ fn get_path_slots_reports_ranked_slots_and_the_pin() { -+ let (mut actor, _tx) = TransportActor::new(); -+ actor.is_transport_enabled = true; -+ let (tcp, _tcp_rx) = make_test_interface("tcp"); -+ let (rf, _rf_rx) = make_rf_test_interface("rnode"); -+ actor.interfaces.insert(1, tcp); -+ actor.interfaces.insert(2, rf); ++ fn expire_drops_every_slot_for_the_destination() { ++ let mut table = PathTable::new(); ++ let dest = [0xB6; 16]; ++ let blob = blob_for(0x4F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); + -+ let identity = rns_identity::identity::Identity::new(); -+ let blob = random_blob(0xAC, 100); -+ let (raw_tcp, dest_hash) = -+ make_announce_for_with_random_blob(&identity, "test.multipath.slots", 1, blob); -+ let (raw_rf, _) = -+ make_announce_for_with_random_blob(&identity, "test.multipath.slots", 4, blob); -+ for (raw, interface_id) in [(raw_tcp, 1u64), (raw_rf, 2)] { -+ actor.on_inbound(InboundPacket { -+ raw, -+ interface_id, -+ rssi: None, -+ snr: None, -+ q: None, -+ }); -+ } -+ assert!(actor.set_peer_medium_pin(dest_hash, Some(PathMedium::Rf))); ++ assert!(table.expire(&dest)); ++ assert!(!table.has_path(&dest)); ++ assert!(table.slots(&dest).is_empty()); ++ } + -+ match actor.handle_query(TransportQuery::GetPathSlots { dest: dest_hash }) { -+ TransportQueryResponse::PathSlots(entry) => { -+ assert_eq!(entry.dest, dest_hash); -+ assert_eq!(entry.pin, Some(PathMedium::Rf)); -+ assert_eq!(entry.preference, PathMediumPreference::Rf); -+ assert_eq!(entry.slots.len(), 2); -+ assert!(entry.slots[0].active); -+ assert_eq!(entry.slots[0].medium, PathMedium::Rf); -+ assert_eq!(entry.slots[0].interface, "rnode"); -+ assert!(!entry.slots[0].expired); -+ assert!(!entry.slots[1].active); -+ assert_eq!(entry.slots[1].medium, PathMedium::Network); -+ assert_eq!(entry.slots[1].interface, "tcp"); -+ } -+ other => panic!("expected PathSlots, got {other:?}"), -+ } ++ #[test] ++ fn remove_and_insert_keep_the_backup_ring_consistent() { ++ let mut table = PathTable::new(); ++ let dest = [0xB7; 16]; ++ let blob = blob_for(0x5F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); + -+ match actor.handle_query(TransportQuery::GetPathSlots { dest: [0xFE; 16] }) { -+ TransportQueryResponse::PathSlots(entry) => { -+ assert!(entry.slots.is_empty()); -+ assert_eq!(entry.pin, None); -+ assert_eq!(entry.preference, PathMediumPreference::Lowest); -+ } -+ other => panic!("expected PathSlots, got {other:?}"), -+ } ++ // Re-installing the backup's route as active must not leave a duplicate. ++ table.insert(dest, slot(4, 2, PathMedium::Network, blob)); ++ assert!(table.backups(&dest).is_empty()); ++ assert_eq!(table.slots(&dest).len(), 1); ++ ++ let peer = announced(6, 3, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ assert_eq!(table.slots(&dest).len(), 2); ++ table.remove(&dest); ++ assert!(table.slots(&dest).is_empty()); + } + + #[test] -+ fn clearing_a_peer_medium_pin_restores_the_global_preference() { -+ let (mut actor, _tx) = TransportActor::new(); -+ let dest = [0xCD; 16]; -+ actor.set_peer_medium_pin(dest, Some(PathMedium::Network)); -+ assert_eq!( -+ actor.effective_path_medium_preference(&dest), -+ PathMediumPreference::Network -+ ); ++ fn backup_upsert_without_an_active_route_installs_directly() { ++ let mut table = PathTable::new(); ++ let dest = [0xB8; 16]; ++ let entry = slot(3, 1, PathMedium::Network, blob_for(0x6F, 100)); + -+ actor.set_peer_medium_pin(dest, None); -+ assert!(actor.peer_medium_pins.is_empty()); -+ assert_eq!( -+ actor.effective_path_medium_preference(&dest), -+ PathMediumPreference::Lowest -+ ); ++ assert!(table.upsert_ranked(dest, entry, PathRank::Backup, PathMediumPreference::Lowest)); ++ assert_eq!(table.get(&dest).unwrap().interface_id, 1); ++ assert!(table.backups(&dest).is_empty()); ++ } + -+ actor.set_path_medium_preference(PathMediumPreference::Rf); -+ assert_eq!( -+ actor.effective_path_medium_preference(&dest), -+ PathMediumPreference::Rf -+ ); ++ #[test] ++ fn refreshing_a_backup_route_replaces_its_slot() { ++ let mut table = PathTable::new(); ++ let dest = [0xB9; 16]; ++ let blob = blob_for(0x7F, 100); ++ table.insert(dest, slot(1, 1, PathMedium::Network, blob)); ++ let peer = announced(6, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &peer, PathMediumPreference::Lowest); ++ assert_eq!(table.backups(&dest)[0].hops, 6); ++ ++ let closer = announced(4, 2, PathMedium::Network, blob); ++ learn(&mut table, dest, &closer, PathMediumPreference::Lowest); ++ assert_eq!(interface_ids(table.backups(&dest)), vec![2]); ++ assert_eq!(table.backups(&dest)[0].hops, 4); + } + + #[test] - fn test_newer_equal_or_higher_hop_announce_replaces_path() { - let (mut actor, _tx) = TransportActor::new(); - let (entry1, _rx1) = make_test_interface("iface1"); -@@ -7232,6 +7652,7 @@ - expires: now - 1.0, - random_blobs: Default::default(), - interface_id: 7, -+ medium: crate::path_table::PathMedium::Network, - packet_hash: None, - }, - ); -@@ -10903,9 +11324,9 @@ - let dest_hash = [0xD1; 16]; - insert_announce_for(&mut actor, dest_hash, &identity); - -- let hit = actor.handle_query(crate::messages::TransportQuery::RecallDestinationPublicKey { -- dest: dest_hash, -- }); -+ let hit = actor.handle_query( -+ crate::messages::TransportQuery::RecallDestinationPublicKey { dest: dest_hash }, -+ ); - match hit { - crate::messages::TransportQueryResponse::PublicKeyResult(Some(pk)) => { - assert_eq!(pk, identity.get_public_key()); -@@ -10913,10 +11334,9 @@ - other => panic!("expected PublicKeyResult(Some(_)), got {other:?}"), - } - -- let miss = -- actor.handle_query(crate::messages::TransportQuery::RecallDestinationPublicKey { -- dest: [0xEE; 16], -- }); -+ let miss = actor.handle_query( -+ crate::messages::TransportQuery::RecallDestinationPublicKey { dest: [0xEE; 16] }, -+ ); - assert!(matches!( - miss, - crate::messages::TransportQueryResponse::PublicKeyResult(None) -@@ -10924,33 +11344,6 @@ - } - ++ fn unknown_destination_has_no_slots() { ++ let table = PathTable::new(); ++ assert!(table.slots(&[0xFF; 16]).is_empty()); ++ assert!(table.backups(&[0xFF; 16]).is_empty()); ++ } ++ #[test] -- fn deregister_announce_handler_none_only_sweeps_closed() { -- let (mut actor, _tx) = TransportActor::new(); -- let (live_tx, _live_rx) = tokio::sync::mpsc::channel(4); -- let (dead_tx, dead_rx) = tokio::sync::mpsc::channel(4); -- drop(dead_rx); -- -- actor.announce_handlers.push(AnnounceHandlerRegistration { -- aspect_filter: Some("nomadnetwork.node".into()), -- receive_path_responses: false, -- tx: live_tx, -- }); -- actor.announce_handlers.push(AnnounceHandlerRegistration { -- aspect_filter: Some("nomadnetwork.node".into()), -- receive_path_responses: true, -- tx: dead_tx, -- }); -- -- actor.handle_message(TransportMessage::DeregisterAnnounceHandler { aspect_filter: None }); -- assert_eq!(actor.announce_handlers.len(), 1); -- assert_eq!( -- actor.announce_handlers[0].aspect_filter.as_deref(), -- Some("nomadnetwork.node") -- ); -- assert!(!actor.announce_handlers[0].tx.is_closed()); -- } -- -- #[test] - fn filter_blackholed_dests_returns_only_blackholed() { - let (mut actor, _tx) = TransportActor::new(); - let blocked = rns_identity::identity::Identity::new(); ---- a/crates/rns-transport/src/actor/rpc.rs -+++ b/crates/rns-transport/src/actor/rpc.rs -@@ -261,7 +261,42 @@ - TransportQueryResponse::BoolResult(interface_id.is_some_and(|interface_id| { - self.suppress_path_interface(dest, interface_id, duration) - })) -+ } -+ TransportQuery::SetPathMediumPreference { preference } => { -+ TransportQueryResponse::IntResult(self.set_path_medium_preference(preference) as i64) -+ } -+ TransportQuery::SetPeerMediumPin { dest, pin } => { -+ TransportQueryResponse::BoolResult(self.set_peer_medium_pin(dest, pin)) - } -+ TransportQuery::GetPathSlots { dest } => { -+ let slots: Vec = self -+ .path_table -+ .slots(&dest) -+ .into_iter() -+ .enumerate() -+ .map(|(index, entry)| PathSlotRpcEntry { -+ active: index == 0, -+ hops: entry.hops, -+ via: entry.next_hop, -+ interface_id: entry.interface_id, -+ interface: self -+ .interfaces -+ .get(&entry.interface_id) -+ .map(|e| e.name.clone()) -+ .unwrap_or_else(|| format!("interface_{}", entry.interface_id)), -+ medium: entry.medium, -+ timestamp: entry.timestamp, -+ expires: entry.expires, -+ expired: entry.is_expired(), -+ }) -+ .collect(); -+ TransportQueryResponse::PathSlots(PathSlotsRpcEntry { -+ dest, -+ preference: self.effective_path_medium_preference(&dest), -+ pin: self.peer_medium_pins.get(&dest).copied(), -+ slots, -+ }) -+ } - TransportQuery::DropAnnounceQueues => { - for entry in self.interfaces.values_mut() { - entry.announce_queue.clear(); ---- a/crates/rns-transport/src/actor/outbound.rs -+++ b/crates/rns-transport/src/actor/outbound.rs -@@ -801,6 +801,12 @@ - expires: tunnel_path.expires, - random_blobs: tunnel_path.random_blobs.iter().copied().collect(), - interface_id, -+ medium: crate::path_table::path_medium( -+ self.interfaces -+ .get(&interface_id) -+ .map(|entry| entry.mode) -+ .unwrap_or(crate::constants::InterfaceMode::Full), -+ ), - packet_hash: tunnel_path.packet_hash, - }; - self.path_table.insert(*dest_hash, entry); ---- a/crates/rns-transport/src/actor/persistence.rs -+++ b/crates/rns-transport/src/actor/persistence.rs -@@ -1061,6 +1061,14 @@ - /// just-registered interface. Bound to `RegisterInterface` so each entry - /// rebinds to whatever `interface_id` the runtime allocated this boot. - pub(super) fn drain_pending_for_interface(&mut self, id: InterfaceId, name: &str) { -+ // Restored entries have no persisted medium — recover it from the -+ // interface that is registering now. -+ let medium = crate::path_table::path_medium( -+ self.interfaces -+ .get(&id) -+ .map(|entry| entry.mode) -+ .unwrap_or(crate::constants::InterfaceMode::Full), -+ ); - if !self.pending_path_entries.is_empty() { - let mut promoted = 0usize; - self.pending_path_entries.retain(|pe| { -@@ -1104,6 +1112,7 @@ - }) - .collect(), - interface_id: id, -+ medium, - packet_hash: pe.packet_hash.as_ref().and_then(|h| { - if h.len() == 32 { - let mut arr = [0u8; 32]; -@@ -1316,6 +1325,7 @@ - expires: crate::now_f64() + 600.0, - random_blobs: std::collections::VecDeque::new(), - interface_id: 7, -+ medium: crate::path_table::PathMedium::Network, - packet_hash: Some(path_hash), - }, - ); -@@ -1471,6 +1481,7 @@ - expires: crate::now_f64() + 600.0, - random_blobs: std::collections::VecDeque::new(), - interface_id: 7, -+ medium: crate::path_table::PathMedium::Network, - packet_hash: Some([0x33; 32]), - }, - ); -@@ -1587,6 +1598,7 @@ - expires: crate::now_f64() + 600.0, - random_blobs: std::collections::VecDeque::new(), - interface_id: 7, -+ medium: crate::path_table::PathMedium::Network, - packet_hash: Some([0x66; 32]), - }, - ); + fn test_cull_dead_interfaces() { + let mut table = PathTable::new(); diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index c97c8c499..f0d3280a7 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -143,6 +143,12 @@ enum InProcessDepositOutcome { Failed, } +fn enqueue_router(router: &mut LxmRouter, message: LxMessage) { + if let Err(error) = router.try_send(message) { + tracing::warn!(target: "lxmf-outbound", error = %error, "router.try_send failed"); + } +} + pub struct LxmfOutboundDriver { transport_tx: mpsc::Sender, link_delivery: LinkDeliveryManager, @@ -554,7 +560,7 @@ impl LxmfOutboundDriver { Some(prop_hex.clone()), ); } - router.send(message); + enqueue_router(router, message); return; } if let Some(hash) = message.hash.or(message.message_id) { @@ -628,7 +634,7 @@ impl LxmfOutboundDriver { if let Some(hash) = message.hash.or(message.message_id) { self.pending_pn_targets.insert(hash, prop_hash); } - router.send(message); + enqueue_router(router, message); return; } Err(InProcessDepositOutcome::Failed | InProcessDepositOutcome::Completed) => { @@ -699,7 +705,7 @@ impl LxmfOutboundDriver { self.pending_pn_deposits.remove(&hash); self.pending_pn_targets.insert(hash, prop_hash); } - router.send(message); + enqueue_router(router, message); return; } InProcessDepositOutcome::Failed => { @@ -947,7 +953,7 @@ impl LxmfOutboundDriver { match plan { DirectDeliveryPlan::WaitForReusableLink => { if !router_owned { - router.send(message); + enqueue_router(router, message); } } DirectDeliveryPlan::RequestPath { drop_existing } => { @@ -974,7 +980,7 @@ impl LxmfOutboundDriver { message.method = DeliveryMethod::Direct; message.last_delivery_attempt = now; message.next_delivery_attempt = now + f64::from(PATH_REQUEST_WAIT as u32); - router.send(message); + enqueue_router(router, message); } // router_owned: message remains in pending_outbound; cleared path // forces RequestPath on the next tick after Auto suppress. @@ -993,7 +999,7 @@ impl LxmfOutboundDriver { error = %err.error, "direct link delivery start failed" ); - router.send(*err.message); + enqueue_router(router, *err.message); } } } @@ -1049,7 +1055,7 @@ impl LxmfOutboundDriver { if try_queue_path_request(&self.transport_tx, request_hash, drop_existing, reason) { self.path_request_gate.record_send(request_hash, now); if !router_owned { - router.send(message); + enqueue_router(router, message); } } else { self.path_request_gate @@ -1062,13 +1068,13 @@ impl LxmfOutboundDriver { ); } if !router_owned { - router.send(message); + enqueue_router(router, message); } } } PathRequestDecision::Backoff => { if !router_owned { - router.send(message); + enqueue_router(router, message); } } PathRequestDecision::MaxAttempts => { @@ -1269,7 +1275,7 @@ impl LxmfOutboundDriver { is_local = pick.is_local(), "LXMF advancing PN cascade" ); - router.send(message); + enqueue_router(router, message); emit_outbound_status_with_via( event_tx, Some(serde_json::Value::String(hex::encode(msg_hash))), @@ -1542,7 +1548,7 @@ impl LxmfOutboundDriver { Some(tried), Some(rounds), ); - router.send(message); + enqueue_router(router, message); Ok(()) } @@ -1581,7 +1587,7 @@ impl LxmfOutboundDriver { Some(hex::encode(prop_hash)), ); } - router.send(message); + enqueue_router(router, message); } } diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 6125abf8d..d055d8855 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -333,14 +333,14 @@ impl StackHandle { return; } let started = std::time::Instant::now(); - match live::LiveBridge::spawn( + match Box::pin(live::LiveBridge::spawn( self.config_dir.clone(), self.storage_dir.clone(), self.event_tx.clone(), self.packet_log.clone(), self.inbound_lxmf.clone(), self.inner.clone(), - ) + )) .await { Ok(bridge) => { diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index 006862a1b..5b481c7e7 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -1115,8 +1115,20 @@ pub(crate) fn apply_peer_sync_terminal( let Some(peer) = router.peers.get_mut(&result.peer_hash) else { return; }; + // lxmd parity: apply link/sync accounting before terminal state (Ratspeak + // `lxmd.rs` peer_terminal_result). + if let Some(rate) = result.link_establishment_rate { + peer.link_establishment_rate = rate; + peer.heard(); + } match result.state { PeerSyncTerminalState::Complete => { + peer.offered = peer.offered.saturating_add(result.offered); + peer.outgoing = peer.outgoing.saturating_add(result.outgoing); + peer.tx_bytes = peer.tx_bytes.saturating_add(result.tx_bytes); + if let Some(rate) = result.sync_transfer_rate { + peer.sync_transfer_rate = rate; + } peer.sync_complete(); if result.generation_exhausted { if let Some(generation) = result.offer_generation { @@ -1652,10 +1664,20 @@ mod tests { state: PeerSyncTerminalState::Complete, offer_generation: Some(5), generation_exhausted: true, + offered: 4, + outgoing: 2, + tx_bytes: 128, + link_establishment_rate: Some(1234.0), + sync_transfer_rate: Some(56.0), }, ); let peer = router.peers.get(&peer_hash).expect("peer"); assert_eq!(peer.state, PeerState::Idle); + assert_eq!(peer.offered, 4); + assert_eq!(peer.outgoing, 2); + assert_eq!(peer.tx_bytes, 128); + assert!((peer.link_establishment_rate - 1234.0).abs() < 1e-9); + assert!((peer.sync_transfer_rate - 56.0).abs() < 1e-9); assert!( !peer.needs_offer_generation(5), "exhausted generation must not remain due" @@ -1705,10 +1727,19 @@ mod tests { state: PeerSyncTerminalState::Failed, offer_generation: Some(3), generation_exhausted: false, + offered: 9, + outgoing: 9, + tx_bytes: 9, + link_establishment_rate: None, + sync_transfer_rate: Some(99.0), }, ); let peer = router.peers.get(&peer_hash).expect("peer"); assert_eq!(peer.state, PeerState::Idle); + assert_eq!(peer.offered, 0, "failed sync must not count offered"); + assert_eq!(peer.outgoing, 0); + assert_eq!(peer.tx_bytes, 0); + assert!(peer.sync_transfer_rate.abs() < 1e-9); assert!( peer.needs_offer_generation(3), "failed sync must leave generation retryable" diff --git a/reticulum-sidecar/src/stack/rrc_link.rs b/reticulum-sidecar/src/stack/rrc_link.rs index 19826b79f..a7dcfd844 100644 --- a/reticulum-sidecar/src/stack/rrc_link.rs +++ b/reticulum-sidecar/src/stack/rrc_link.rs @@ -457,13 +457,17 @@ async fn recall_destination_public_key( send_msg( transport_tx, TransportMessage::Rpc { - query: TransportQuery::RecallDestinationPublicKey { dest: dest_hash }, + query: TransportQuery::RecallDestination { dest: dest_hash }, response_tx, }, ) .await?; match timeout(Duration::from_secs(5), response_rx).await { - Ok(Ok(TransportQueryResponse::PublicKeyResult(pk))) => Ok(pk), + Ok(Ok(TransportQueryResponse::RecalledDestination(Some(destination)))) + if destination.dest_hash == dest_hash => + { + Ok(Some(destination.public_key)) + } Ok(Ok(_)) => Ok(None), Ok(Err(_)) => Err(RrcLinkError::TransportUnavailable), Err(_) => Err(RrcLinkError::Timeout("pubkey recall")), @@ -636,4 +640,41 @@ mod tests { } assert!(rx.try_recv().is_err()); } + + #[tokio::test] + async fn recall_destination_public_key_uses_upstream_recall_destination() { + let dest_hash = [0xD4; 16]; + let public_key = [0x42u8; 64]; + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let responder = tokio::spawn(async move { + let Some(TransportMessage::Rpc { query, response_tx }) = transport_rx.recv().await + else { + panic!("expected destination recall"); + }; + assert!(matches!( + query, + TransportQuery::RecallDestination { dest } if dest == dest_hash + )); + response_tx + .send(TransportQueryResponse::RecalledDestination(Some( + rns_transport::messages::RecalledDestinationRpcEntry { + dest_hash, + public_key, + app_data: None, + ratchet: None, + hops: 1, + timestamp: 1.0, + }, + ))) + .unwrap(); + }); + + assert_eq!( + recall_destination_public_key(&transport_tx, dest_hash) + .await + .unwrap(), + Some(public_key) + ); + responder.await.unwrap(); + } } diff --git a/scripts/apply-rsReticulum-link-client-nomad.sh b/scripts/apply-rsReticulum-link-client-nomad.sh index e1f2a1c8c..edc499a42 100755 --- a/scripts/apply-rsReticulum-link-client-nomad.sh +++ b/scripts/apply-rsReticulum-link-client-nomad.sh @@ -8,7 +8,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch" RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" -MESSAGES_RS="${RNS_DIR}/crates/rns-transport/src/messages.rs" +LINK_CLIENT_RS="${RNS_DIR}/crates/rns-runtime/src/link_client.rs" if [[ ! -d "${RNS_DIR}/.git" ]]; then echo "error: rsReticulum not found at ${RNS_DIR}" >&2 @@ -21,8 +21,23 @@ if [[ ! -f "${PATCH_FILE}" ]]; then exit 1 fi +# Upstream has HasPath-gated RecallDestination (a945ba0) but still Deregisters +# announce handlers by aspect. This overlay is present when LinkClient recalls +# without HasPath, awaits path, and GCs handlers with aspect_filter: None. # Upstream PR: https://github.com/ratspeak/rsReticulum/pull/14 -if [[ -f "${MESSAGES_RS}" ]] && grep -q 'RecallDestinationPublicKey' "${MESSAGES_RS}"; then +overlay_already_present() { + [[ -f "${LINK_CLIENT_RS}" ]] || return 1 + grep -qE 'fn discover_remote_public_key\(' "${LINK_CLIENT_RS}" \ + && grep -qE 'fn gc_closed_announce_handlers\(' "${LINK_CLIENT_RS}" \ + && grep -qE 'const PATH_LOOKUP_TIMEOUT' "${LINK_CLIENT_RS}" +} + +if git -C "${RNS_DIR}" apply --reverse --check "${PATCH_FILE}" > /dev/null 2>&1; then + echo "link-client nomad overlay already present on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" + exit 0 +fi + +if overlay_already_present; then echo "link-client nomad overlay already present on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" exit 0 fi diff --git a/scripts/apply-rsReticulum-link-client-nomad.test.mjs b/scripts/apply-rsReticulum-link-client-nomad.test.mjs new file mode 100644 index 000000000..77d347afd --- /dev/null +++ b/scripts/apply-rsReticulum-link-client-nomad.test.mjs @@ -0,0 +1,141 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); +const APPLY_SCRIPT = path.join(SCRIPT_DIR, 'apply-rsReticulum-link-client-nomad.sh'); +const PATCH_FILE = path.join( + REPO_ROOT, + 'reticulum-sidecar/patches/rsReticulum-link-client-nomad.patch', +); +const PATH_MEDIUM_PATCH = path.join( + REPO_ROOT, + 'reticulum-sidecar/patches/rsReticulum-path-medium-slots.patch', +); +const LXMF_DEFERRED_PATCH = path.join( + REPO_ROOT, + 'reticulum-sidecar/patches/rsLXMF-propagation-node-deferred-messagestore-load.patch', +); +const LXMF_ABORT_PATCH = path.join( + REPO_ROOT, + 'reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch', +); + +const ALREADY_PRESENT = `impl LinkClient { + async fn discover_remote_public_key() {} + fn gc_closed_announce_handlers() {} +} +const PATH_LOOKUP_TIMEOUT: Duration = Duration::from_secs(15); +`; + +const INCOMPATIBLE = `impl LinkClient { + pub async fn query() {} +} +`; + +const temps = []; + +function makeFakeRsReticulum(linkClientSource) { + const root = mkdtempSync(path.join(os.tmpdir(), 'mesh-link-client-nomad-rns-')); + temps.push(root); + const linkClientPath = path.join(root, 'crates/rns-runtime/src/link_client.rs'); + mkdirSync(path.dirname(linkClientPath), { recursive: true }); + writeFileSync(linkClientPath, linkClientSource); + const gitInit = spawnSync('git', ['init'], { cwd: root, encoding: 'utf8' }); + expect(gitInit.status).toBe(0); + spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root }); + spawnSync('git', ['config', 'user.name', 'test'], { cwd: root }); + spawnSync('git', ['add', '.'], { cwd: root }); + const commit = spawnSync('git', ['commit', '-m', 'init'], { cwd: root, encoding: 'utf8' }); + expect(commit.status).toBe(0); + return root; +} + +function runApply(rnsDir) { + return spawnSync('bash', [APPLY_SCRIPT], { + cwd: REPO_ROOT, + encoding: 'utf8', + env: { ...process.env, RS_RETICULUM_DIR: rnsDir }, + }); +} + +afterEach(() => { + while (temps.length > 0) { + const dir = temps.pop(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('apply-rsReticulum-link-client-nomad.sh', () => { + it('keeps RRC LinkClient recall on upstream RecallDestination', () => { + const rrcLink = readFileSync( + path.join(REPO_ROOT, 'reticulum-sidecar/src/stack/rrc_link.rs'), + 'utf8', + ); + expect(rrcLink).toContain('TransportQuery::RecallDestination'); + expect(rrcLink).toContain('TransportQueryResponse::RecalledDestination'); + expect(rrcLink).not.toContain('RecallDestinationPublicKey'); + expect(rrcLink).not.toContain('PublicKeyResult'); + }); + + it('uses LinkClient markers, not the retired RecallDestinationPublicKey RPC', () => { + const applyScript = readFileSync(APPLY_SCRIPT, 'utf8'); + expect(applyScript).toContain('discover_remote_public_key'); + expect(applyScript).toContain('gc_closed_announce_handlers'); + expect(applyScript).toContain('PATH_LOOKUP_TIMEOUT'); + expect(applyScript).not.toContain('RecallDestinationPublicKey'); + expect(applyScript).not.toContain('MESSAGES_RS'); + }); + + it('rebases Nomad recall onto upstream RecallDestination + await_path', () => { + const patch = readFileSync(PATCH_FILE, 'utf8'); + expect(patch).toContain('discover_remote_public_key'); + expect(patch).toContain('gc_closed_announce_handlers'); + expect(patch).toContain('RecallDestination'); + expect(patch).toContain('await_path'); + expect(patch).toContain('PATH_LOOKUP_TIMEOUT'); + expect(patch).not.toContain('RecallDestinationPublicKey'); + expect(patch).not.toContain('PublicKeyResult'); + expect(patch).toMatch(/crates\/rns-runtime\/src\/link_client\.rs/); + expect(patch).not.toMatch(/crates\/rns-transport\/src\/messages\.rs/); + }); + + it('does not keep retired Nomad RPC tests in the path-medium-slots overlay', () => { + const patch = readFileSync(PATH_MEDIUM_PATCH, 'utf8'); + expect(patch).not.toContain('RecallDestinationPublicKey'); + expect(patch).toContain('MAX_PATH_SLOTS'); + }); + + it('rebases LXMF deferred messagestore load onto pending_write fields', () => { + const patch = readFileSync(LXMF_DEFERRED_PATCH, 'utf8'); + expect(patch).toContain('with_storage_unloaded'); + expect(patch).toContain('load_messagestore_from_disk'); + expect(patch).toContain('pending_write_ids'); + expect(patch).toContain('pending_write_bytes'); + }); + + it('inserts abort_transfer after acknowledge_transfer on current rsLXMF', () => { + const patch = readFileSync(LXMF_ABORT_PATCH, 'utf8'); + expect(patch).toContain('fn abort_transfer'); + expect(patch).toContain('start_download_with_limit'); + }); + + it('is a no-op when discover_remote_public_key + GC markers are already present', () => { + const rns = makeFakeRsReticulum(ALREADY_PRESENT); + const result = runApply(rns); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toMatch(/already present/); + }); + + it('fails with git diagnostic on incompatible checkouts', () => { + const rns = makeFakeRsReticulum(INCOMPATIBLE); + const result = runApply(rns); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/did not apply|regenerate overlay/); + }); +}); From d904d5d06224b1028b37ca6b70bfa253ebc47c88 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 13 Aug 2026 14:43:18 -0600 Subject: [PATCH 2/2] fix(reticulum): propagate LXMF try_send failures and harden Nomad overlay checks Retain LxmRouter::try_send errors with the failed message, clear cascade state, and emit outbound failed. Validate Nomad overlay reverse-apply plus recall/GC marker fallback, isolate git test config, and assert peer sync metrics accumulate or stay seeded. --- reticulum-sidecar/src/stack/lxmf_outbound.rs | 223 ++++++++++++++---- .../src/stack/propagation_bridge.rs | 25 +- .../apply-rsReticulum-link-client-nomad.sh | 12 +- ...ply-rsReticulum-link-client-nomad.test.mjs | 133 ++++++++++- 4 files changed, 327 insertions(+), 66 deletions(-) diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index f0d3280a7..ff859d2af 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -14,7 +14,7 @@ use lxmf_core::message::LxMessage; use lxmf_core::propagation_node::PropagationNode; use lxmf_core::router::{ DirectDeliveryPlan, DirectDeliveryPlanInput, DirectReusableLinkState, DirectRouteSnapshot, - LxmRouter, OutboundAction, plan_direct_delivery, + LxmRouter, OutboundAction, SendError, plan_direct_delivery, }; use lxmf_core::stamper; use rns_identity::identity::Identity; @@ -143,9 +143,14 @@ enum InProcessDepositOutcome { Failed, } -fn enqueue_router(router: &mut LxmRouter, message: LxMessage) { - if let Err(error) = router.try_send(message) { - tracing::warn!(target: "lxmf-outbound", error = %error, "router.try_send failed"); +fn enqueue_router(router: &mut LxmRouter, message: LxMessage) -> Result<(), SendError> { + router.try_send(message) +} + +fn take_send_error_message(error: SendError) -> Box { + match error { + SendError::MissingOutboundPropagationNode(message) + | SendError::TicketPreparation { message, .. } => message, } } @@ -385,6 +390,27 @@ impl LxmfOutboundDriver { .contains(&destination_hex.to_lowercase()) } + fn enqueue_or_fail( + &mut self, + router: &mut LxmRouter, + event_tx: &broadcast::Sender, + message: LxMessage, + ) -> bool { + match enqueue_router(router, message) { + Ok(()) => true, + Err(error) => { + tracing::warn!( + target: "lxmf-outbound", + error = %error, + "router.try_send failed" + ); + let failed = *take_send_error_message(error); + self.emit_outbound_failed(router, event_tx, failed); + false + } + } + } + pub fn identity_known_for(&self, destination_hex: &str) -> bool { let key = destination_hex.to_lowercase(); self.pinned_identities.contains_key(&key) || self.known_identities.contains_key(&key) @@ -560,7 +586,7 @@ impl LxmfOutboundDriver { Some(prop_hex.clone()), ); } - enqueue_router(router, message); + self.enqueue_or_fail(router, event_tx, message); return; } if let Some(hash) = message.hash.or(message.message_id) { @@ -634,7 +660,7 @@ impl LxmfOutboundDriver { if let Some(hash) = message.hash.or(message.message_id) { self.pending_pn_targets.insert(hash, prop_hash); } - enqueue_router(router, message); + self.enqueue_or_fail(router, event_tx, message); return; } Err(InProcessDepositOutcome::Failed | InProcessDepositOutcome::Completed) => { @@ -705,7 +731,7 @@ impl LxmfOutboundDriver { self.pending_pn_deposits.remove(&hash); self.pending_pn_targets.insert(hash, prop_hash); } - enqueue_router(router, message); + self.enqueue_or_fail(router, event_tx, message); return; } InProcessDepositOutcome::Failed => { @@ -953,7 +979,7 @@ impl LxmfOutboundDriver { match plan { DirectDeliveryPlan::WaitForReusableLink => { if !router_owned { - enqueue_router(router, message); + self.enqueue_or_fail(router, event_tx, message); } } DirectDeliveryPlan::RequestPath { drop_existing } => { @@ -980,7 +1006,7 @@ impl LxmfOutboundDriver { message.method = DeliveryMethod::Direct; message.last_delivery_attempt = now; message.next_delivery_attempt = now + f64::from(PATH_REQUEST_WAIT as u32); - enqueue_router(router, message); + self.enqueue_or_fail(router, event_tx, message); } // router_owned: message remains in pending_outbound; cleared path // forces RequestPath on the next tick after Auto suppress. @@ -999,7 +1025,7 @@ impl LxmfOutboundDriver { error = %err.error, "direct link delivery start failed" ); - enqueue_router(router, *err.message); + self.enqueue_or_fail(router, event_tx, *err.message); } } } @@ -1055,7 +1081,7 @@ impl LxmfOutboundDriver { if try_queue_path_request(&self.transport_tx, request_hash, drop_existing, reason) { self.path_request_gate.record_send(request_hash, now); if !router_owned { - enqueue_router(router, message); + self.enqueue_or_fail(router, event_tx, message); } } else { self.path_request_gate @@ -1068,13 +1094,13 @@ impl LxmfOutboundDriver { ); } if !router_owned { - enqueue_router(router, message); + self.enqueue_or_fail(router, event_tx, message); } } } PathRequestDecision::Backoff => { if !router_owned { - enqueue_router(router, message); + self.enqueue_or_fail(router, event_tx, message); } } PathRequestDecision::MaxAttempts => { @@ -1275,15 +1301,16 @@ impl LxmfOutboundDriver { is_local = pick.is_local(), "LXMF advancing PN cascade" ); - enqueue_router(router, message); - emit_outbound_status_with_via( - event_tx, - Some(serde_json::Value::String(hex::encode(msg_hash))), - None, - "sending", - Some(method_label), - Some(hex::encode(pn_hash)), - ); + if self.enqueue_or_fail(router, event_tx, message) { + emit_outbound_status_with_via( + event_tx, + Some(serde_json::Value::String(hex::encode(msg_hash))), + None, + "sending", + Some(method_label), + Some(hex::encode(pn_hash)), + ); + } Ok(()) } @@ -1538,17 +1565,18 @@ impl LxmfOutboundDriver { "Direct path failover: suppress/drop via + RequestPath; re-queuing Direct" ); let sent_via = iface.as_deref().map(classify_interface).map(str::to_string); - emit_outbound_status_detailed( - event_tx, - Some(serde_json::Value::String(hex::encode(msg_hash))), - Some(serde_json::Value::String(hex::encode(dest_hash))), - "sending", - Some("direct"), - sent_via, - Some(tried), - Some(rounds), - ); - enqueue_router(router, message); + if self.enqueue_or_fail(router, event_tx, message) { + emit_outbound_status_detailed( + event_tx, + Some(serde_json::Value::String(hex::encode(msg_hash))), + Some(serde_json::Value::String(hex::encode(dest_hash))), + "sending", + Some("direct"), + sent_via, + Some(tried), + Some(rounds), + ); + } Ok(()) } @@ -1577,17 +1605,20 @@ impl LxmfOutboundDriver { ); if let Some(hash) = msg_hash { self.pending_pn_targets.insert(hash, prop_hash); - // Keep chat UI in sending/propagated while PN rediscovery proceeds. - emit_outbound_status_with_via( - event_tx, - Some(serde_json::Value::String(hex::encode(hash))), - None, - "sending", - Some(self.cascade_wire_delivery_method(hash)), - Some(hex::encode(prop_hash)), - ); } - enqueue_router(router, message); + if self.enqueue_or_fail(router, event_tx, message) { + if let Some(hash) = msg_hash { + // Keep chat UI in sending/propagated while PN rediscovery proceeds. + emit_outbound_status_with_via( + event_tx, + Some(serde_json::Value::String(hex::encode(hash))), + None, + "sending", + Some(self.cascade_wire_delivery_method(hash)), + Some(hex::encode(prop_hash)), + ); + } + } } } @@ -1998,6 +2029,112 @@ mod tests { assert_eq!(gate.decide(dest(3), 101.0), PathRequestDecision::Send); } + #[test] + fn enqueue_router_missing_propagation_node_keeps_message_and_fails_outbound() { + use lxmf_core::constants::{DeliveryMethod, MessageState}; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig, SendError}; + use tokio::sync::broadcast; + + let identity = Identity::new(); + let (tx, _rx) = mpsc::channel(8); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + let dest_hash = dest(0xab); + let msg_hash = [0x42u8; 32]; + let pn_hash = dest(0x11); + driver + .pn_cascade_tried + .insert(msg_hash, HashSet::from([pn_hash])); + driver.pending_pn_targets.insert(msg_hash, pn_hash); + driver.pn_cascade_local.insert(msg_hash); + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, mut event_rx) = broadcast::channel(8); + + let mut msg = LxMessage::new(dest_hash, [1u8; 16], "", "hi", DeliveryMethod::Propagated); + msg.hash = Some(msg_hash); + + let typed = enqueue_router(&mut router, msg.clone()); + let Err(SendError::MissingOutboundPropagationNode(failed)) = typed else { + panic!("expected MissingOutboundPropagationNode, got {typed:?}"); + }; + assert_eq!(failed.hash, Some(msg_hash)); + assert_eq!(failed.state, MessageState::Failed); + assert!(router.pending_outbound.is_empty()); + + assert!(!driver.enqueue_or_fail(&mut router, &event_tx, msg)); + assert!(!driver.pn_cascade_tried.contains_key(&msg_hash)); + assert!(!driver.pending_pn_targets.contains_key(&msg_hash)); + assert!(!driver.pn_cascade_local.contains(&msg_hash)); + + let mut saw_failed = false; + while let Ok(frame) = event_rx.try_recv() { + if frame.contains("\"status\":\"failed\"") && frame.contains(&hex::encode(msg_hash)) { + saw_failed = true; + } + } + assert!(saw_failed, "missing PN must emit outbound failed status"); + } + + #[test] + fn enqueue_router_ticket_preparation_keeps_message_and_fails_outbound() { + use lxmf_core::constants::{DeliveryMethod, MessageState}; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig, SendError, TicketPreparationError}; + use tokio::sync::broadcast; + + let identity = Identity::new(); + let (tx, _rx) = mpsc::channel(8); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + let dest_hash = dest(0xcd); + let mut msg = LxMessage::new( + dest_hash, + [1u8; 16], + "ticket", + "late", + DeliveryMethod::Direct, + ); + msg.include_ticket = true; + msg.sign(&identity.get_signing_key().expect("sk")) + .expect("sign"); + let msg_hash = msg.hash.expect("hash after sign"); + driver + .pn_cascade_tried + .insert(msg_hash, HashSet::from([dest(0x22)])); + driver.pending_pn_targets.insert(msg_hash, dest(0x22)); + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, mut event_rx) = broadcast::channel(8); + + let typed = enqueue_router(&mut router, msg.clone()); + match typed { + Err(SendError::TicketPreparation { + message, + source: TicketPreparationError::AlreadySigned, + }) => { + assert_eq!(message.hash, Some(msg_hash)); + assert_eq!(message.state, MessageState::Failed); + } + other => panic!("expected TicketPreparation::AlreadySigned, got {other:?}"), + } + assert!(router.pending_outbound.is_empty()); + + assert!(!driver.enqueue_or_fail(&mut router, &event_tx, msg)); + assert!(!driver.pn_cascade_tried.contains_key(&msg_hash)); + assert!(!driver.pending_pn_targets.contains_key(&msg_hash)); + + let mut saw_failed = false; + while let Ok(frame) = event_rx.try_recv() { + if frame.contains("\"status\":\"failed\"") && frame.contains(&hex::encode(msg_hash)) { + saw_failed = true; + } + } + assert!( + saw_failed, + "ticket preparation failure must emit outbound failed status" + ); + } + #[test] fn clear_path_to_removes_stale_route_so_refresh_can_reinstall() { let identity = Identity::new(); diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index 5b481c7e7..ea338a950 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -1652,6 +1652,11 @@ mod tests { let peer_hash = [0x11u8; 16]; let mut router = LxmRouter::new(RouterConfig::default()); let mut peer = LxmPeer::new(peer_hash); + peer.offered = 10; + peer.outgoing = 3; + peer.tx_bytes = 50; + peer.link_establishment_rate = 100.0; + peer.sync_transfer_rate = 20.0; peer.begin_sync(); assert_ne!(peer.state, PeerState::Idle); router.peers.insert(peer_hash, peer); @@ -1673,9 +1678,9 @@ mod tests { ); let peer = router.peers.get(&peer_hash).expect("peer"); assert_eq!(peer.state, PeerState::Idle); - assert_eq!(peer.offered, 4); - assert_eq!(peer.outgoing, 2); - assert_eq!(peer.tx_bytes, 128); + assert_eq!(peer.offered, 14); + assert_eq!(peer.outgoing, 5); + assert_eq!(peer.tx_bytes, 178); assert!((peer.link_establishment_rate - 1234.0).abs() < 1e-9); assert!((peer.sync_transfer_rate - 56.0).abs() < 1e-9); assert!( @@ -1716,6 +1721,11 @@ mod tests { let peer_hash = [0x22u8; 16]; let mut router = LxmRouter::new(RouterConfig::default()); let mut peer = LxmPeer::new(peer_hash); + peer.offered = 7; + peer.outgoing = 3; + peer.tx_bytes = 11; + peer.link_establishment_rate = 88.0; + peer.sync_transfer_rate = 12.0; peer.begin_sync(); router.peers.insert(peer_hash, peer); @@ -1736,10 +1746,11 @@ mod tests { ); let peer = router.peers.get(&peer_hash).expect("peer"); assert_eq!(peer.state, PeerState::Idle); - assert_eq!(peer.offered, 0, "failed sync must not count offered"); - assert_eq!(peer.outgoing, 0); - assert_eq!(peer.tx_bytes, 0); - assert!(peer.sync_transfer_rate.abs() < 1e-9); + assert_eq!(peer.offered, 7, "failed sync must not count offered"); + assert_eq!(peer.outgoing, 3); + assert_eq!(peer.tx_bytes, 11); + assert!((peer.link_establishment_rate - 88.0).abs() < 1e-9); + assert!((peer.sync_transfer_rate - 12.0).abs() < 1e-9); assert!( peer.needs_offer_generation(3), "failed sync must leave generation retryable" diff --git a/scripts/apply-rsReticulum-link-client-nomad.sh b/scripts/apply-rsReticulum-link-client-nomad.sh index edc499a42..8d37d7fe6 100755 --- a/scripts/apply-rsReticulum-link-client-nomad.sh +++ b/scripts/apply-rsReticulum-link-client-nomad.sh @@ -22,14 +22,18 @@ if [[ ! -f "${PATCH_FILE}" ]]; then fi # Upstream has HasPath-gated RecallDestination (a945ba0) but still Deregisters -# announce handlers by aspect. This overlay is present when LinkClient recalls -# without HasPath, awaits path, and GCs handlers with aspect_filter: None. -# Upstream PR: https://github.com/ratspeak/rsReticulum/pull/14 +# announce handlers by aspect. Marker fallback (when reverse-apply misses) is +# the Nomad recall path: RecallDestination without HasPath, await_path, and GC +# with aspect_filter: None. Upstream PR: https://github.com/ratspeak/rsReticulum/pull/14 overlay_already_present() { [[ -f "${LINK_CLIENT_RS}" ]] || return 1 grep -qE 'fn discover_remote_public_key\(' "${LINK_CLIENT_RS}" \ && grep -qE 'fn gc_closed_announce_handlers\(' "${LINK_CLIENT_RS}" \ - && grep -qE 'const PATH_LOOKUP_TIMEOUT' "${LINK_CLIENT_RS}" + && grep -qE 'const PATH_LOOKUP_TIMEOUT' "${LINK_CLIENT_RS}" \ + && grep -qE 'TransportQuery::RecallDestination' "${LINK_CLIENT_RS}" \ + && grep -qE 'await_path\(' "${LINK_CLIENT_RS}" \ + && grep -qE 'aspect_filter: None' "${LINK_CLIENT_RS}" \ + && ! grep -qE 'TransportQuery::HasPath' "${LINK_CLIENT_RS}" } if git -C "${RNS_DIR}" apply --reverse --check "${PATCH_FILE}" > /dev/null 2>&1; then diff --git a/scripts/apply-rsReticulum-link-client-nomad.test.mjs b/scripts/apply-rsReticulum-link-client-nomad.test.mjs index 77d347afd..d44e75e47 100644 --- a/scripts/apply-rsReticulum-link-client-nomad.test.mjs +++ b/scripts/apply-rsReticulum-link-client-nomad.test.mjs @@ -26,9 +26,26 @@ const LXMF_ABORT_PATCH = path.join( 'reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch', ); -const ALREADY_PRESENT = `impl LinkClient { - async fn discover_remote_public_key() {} - fn gc_closed_announce_handlers() {} +const GIT_TEST_ENV = { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', +}; + +/** Marker fallback: recall + GC semantics without matching the exact patch hunks. */ +const MARKER_FALLBACK = `impl LinkClient { + async fn discover_remote_public_key() { + match self.transport_query(TransportQuery::RecallDestination { dest: dest_hash }).await? { + TransportQueryResponse::RecalledDestination(Some(destination)) => destination.public_key, + _ => return Err(LinkClientError::PubkeyNotDiscovered), + }; + await_path(&self.transport_tx, dest_hash, PATH_LOOKUP_TIMEOUT).await?; + } + fn gc_closed_announce_handlers() { + let _ = self.transport_tx.try_send(TransportMessage::DeregisterAnnounceHandler { + aspect_filter: None, + }); + } } const PATH_LOOKUP_TIMEOUT: Duration = Duration::from_secs(15); `; @@ -40,18 +57,93 @@ const INCOMPATIBLE = `impl LinkClient { const temps = []; +function git(cwd, args) { + return spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: GIT_TEST_ENV, + }); +} + +function parseUnifiedHunkHeader(line) { + if (!line.startsWith('@@ -')) { + return null; + } + const close = line.indexOf(' @@', 4); + if (close < 0) { + return null; + } + const [oldSpec, newSpec] = line.slice(4, close).split(' +'); + if (!oldSpec || !newSpec) { + return null; + } + const oldStart = Number(oldSpec.split(',')[0]); + const newStart = Number(newSpec.split(',')[0]); + if (!Number.isInteger(oldStart) || !Number.isInteger(newStart)) { + return null; + } + return { oldStart, newStart }; +} + +function materializeLinkClientFromPatch(patchText, side) { + const lines = []; + const patchLines = patchText.replace(/\n$/, '').split('\n'); + let i = 0; + while (i < patchLines.length && !patchLines[i].startsWith('@@ ')) { + i += 1; + } + while (i < patchLines.length) { + const hunk = parseUnifiedHunkHeader(patchLines[i]); + if (!hunk) { + i += 1; + continue; + } + const start = side === 'old' ? hunk.oldStart : hunk.newStart; + while (lines.length < start - 1) { + lines.push(`// overlay-fixture-pad ${lines.length + 1}`); + } + i += 1; + while (i < patchLines.length && !patchLines[i].startsWith('@@ ')) { + const line = patchLines[i]; + if (line.startsWith('\\')) { + i += 1; + continue; + } + if ( + line.startsWith('diff ') || + line.startsWith('index ') || + line.startsWith('--- ') || + line.startsWith('+++ ') + ) { + break; + } + const tag = line[0]; + const body = line.slice(1); + if (tag === ' ') { + lines.push(body); + } else if (tag === '-' && side === 'old') { + lines.push(body); + } else if (tag === '+' && side === 'new') { + lines.push(body); + } + i += 1; + } + } + return `${lines.join('\n')}\n`; +} + function makeFakeRsReticulum(linkClientSource) { const root = mkdtempSync(path.join(os.tmpdir(), 'mesh-link-client-nomad-rns-')); temps.push(root); const linkClientPath = path.join(root, 'crates/rns-runtime/src/link_client.rs'); mkdirSync(path.dirname(linkClientPath), { recursive: true }); writeFileSync(linkClientPath, linkClientSource); - const gitInit = spawnSync('git', ['init'], { cwd: root, encoding: 'utf8' }); + const gitInit = git(root, ['init']); expect(gitInit.status).toBe(0); - spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root }); - spawnSync('git', ['config', 'user.name', 'test'], { cwd: root }); - spawnSync('git', ['add', '.'], { cwd: root }); - const commit = spawnSync('git', ['commit', '-m', 'init'], { cwd: root, encoding: 'utf8' }); + git(root, ['config', 'user.email', 'test@example.com']); + git(root, ['config', 'user.name', 'test']); + git(root, ['add', '.']); + const commit = git(root, ['commit', '-m', 'init']); expect(commit.status).toBe(0); return root; } @@ -60,7 +152,7 @@ function runApply(rnsDir) { return spawnSync('bash', [APPLY_SCRIPT], { cwd: REPO_ROOT, encoding: 'utf8', - env: { ...process.env, RS_RETICULUM_DIR: rnsDir }, + env: { ...GIT_TEST_ENV, RS_RETICULUM_DIR: rnsDir }, }); } @@ -83,11 +175,15 @@ describe('apply-rsReticulum-link-client-nomad.sh', () => { expect(rrcLink).not.toContain('PublicKeyResult'); }); - it('uses LinkClient markers, not the retired RecallDestinationPublicKey RPC', () => { + it('uses LinkClient recall/GC semantics, not the retired RecallDestinationPublicKey RPC', () => { const applyScript = readFileSync(APPLY_SCRIPT, 'utf8'); expect(applyScript).toContain('discover_remote_public_key'); expect(applyScript).toContain('gc_closed_announce_handlers'); expect(applyScript).toContain('PATH_LOOKUP_TIMEOUT'); + expect(applyScript).toContain('TransportQuery::RecallDestination'); + expect(applyScript).toContain('await_path\\('); + expect(applyScript).toContain('aspect_filter: None'); + expect(applyScript).toContain('TransportQuery::HasPath'); expect(applyScript).not.toContain('RecallDestinationPublicKey'); expect(applyScript).not.toContain('MESSAGES_RS'); }); @@ -125,8 +221,21 @@ describe('apply-rsReticulum-link-client-nomad.sh', () => { expect(patch).toContain('start_download_with_limit'); }); - it('is a no-op when discover_remote_public_key + GC markers are already present', () => { - const rns = makeFakeRsReticulum(ALREADY_PRESENT); + it('is a no-op when the exact applied overlay reverse-checks', () => { + const patch = readFileSync(PATCH_FILE, 'utf8'); + const applied = materializeLinkClientFromPatch(patch, 'new'); + const rns = makeFakeRsReticulum(applied); + const reverse = git(rns, ['apply', '--reverse', '--check', PATCH_FILE]); + expect(reverse.status, reverse.stderr || reverse.stdout).toBe(0); + const result = runApply(rns); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toMatch(/already present/); + }); + + it('is a no-op when recall/GC markers are present but reverse-check misses', () => { + const rns = makeFakeRsReticulum(MARKER_FALLBACK); + const reverse = git(rns, ['apply', '--reverse', '--check', PATCH_FILE]); + expect(reverse.status).not.toBe(0); const result = runApply(rns); expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout).toMatch(/already present/);