use super::*;
#[test]
fn local_send_failures_own_peer_scoped_fast_dead_clear_and_expiry() {
let failed_peer = make_node_addr(0xA1);
let quiet_peer = make_node_addr(0xA2);
let now = std::time::Instant::now();
let dead_timeout = std::time::Duration::from_secs(30);
let fast_dead_timeout = std::time::Duration::from_secs(5);
let route_error = Err(crate::transport::TransportError::SendFailed(
"No route to host (os error 65)".to_string(),
));
let mut failures = LocalSendFailures::default();
failures.note_send_outcome(&failed_peer, &route_error, now);
assert!(failures.contains_key(&failed_peer));
assert!(!failures.contains_key(&quiet_peer));
assert_eq!(
failures.dead_timeout_for_peer(&failed_peer, now, dead_timeout, fast_dead_timeout),
fast_dead_timeout
);
assert_eq!(
failures.dead_timeout_for_peer(&quiet_peer, now, dead_timeout, fast_dead_timeout),
dead_timeout,
"local route failure must remain scoped to the peer whose send failed"
);
let non_local_error = Err(crate::transport::TransportError::SendFailed(
"connection refused".to_string(),
));
failures.note_send_outcome(&quiet_peer, &non_local_error, now);
assert!(
!failures.contains_key(&quiet_peer),
"non-local send errors must not create a fast-dead route signal"
);
failures.note_send_outcome(&failed_peer, &Ok(1), now);
assert!(
!failures.contains_key(&failed_peer),
"successful sends must clear that peer's local route failure signal"
);
failures.record_failure(failed_peer, now);
let later = now + std::time::Duration::from_secs(4);
failures.purge_expired(later);
assert!(!failures.contains_key(&failed_peer));
}
#[test]
fn session_direct_degradation_owns_hold_extension_expiry_and_clear() {
let dest = make_node_addr(0xB1);
let other = make_node_addr(0xB2);
let hold_ms = 20_000;
let mut degradation = SessionDirectDegradation::default();
assert!(degradation.mark_degraded(dest, 1_000, hold_ms));
assert!(degradation.is_degraded(&dest, 20_999));
assert!(
!degradation.mark_degraded(dest, 2_000, hold_ms),
"marking an already-degraded direct path should extend the hold without reporting a new transition"
);
assert!(degradation.is_degraded(&dest, 21_999));
assert!(
!degradation.is_degraded(&other, 21_999),
"direct degradation must remain scoped to the destination that produced bad session evidence"
);
assert!(
!degradation.is_degraded(&dest, 22_000),
"the owner must expire and remove stale degradation holds"
);
assert!(
!degradation.clear(&dest),
"expired degradation state should already be removed"
);
assert!(degradation.mark_degraded(dest, 30_000, hold_ms));
assert!(degradation.clear(&dest));
assert!(!degradation.is_degraded(&dest, 30_000));
}
#[test]
fn traversal_path_liveness_keeps_mobile_safe_floor() {
assert_eq!(
crate::node::handlers::traversal_path_liveness_timeout(
2,
std::time::Duration::from_secs(30),
std::time::Duration::from_secs(5),
),
std::time::Duration::from_secs(30),
"short-heartbeat traversal paths should not collapse to the 5s local-failure floor"
);
assert_eq!(
crate::node::handlers::traversal_path_liveness_timeout(
10,
std::time::Duration::from_secs(30),
std::time::Duration::from_secs(5),
),
std::time::Duration::from_secs(30),
"default FIPS heartbeat keeps a three-heartbeat traversal liveness window"
);
assert_eq!(
crate::node::handlers::traversal_path_liveness_timeout(
2,
std::time::Duration::from_secs(60),
std::time::Duration::from_secs(40),
),
std::time::Duration::from_secs(40),
"an explicitly higher fast floor should still be honored"
);
}
#[test]
fn traversal_path_quiet_refresh_uses_heartbeat_and_fast_dead_floor() {
assert_eq!(
crate::node::handlers::traversal_path_quiet_refresh_timeout(
2,
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(30),
),
std::time::Duration::from_secs(5),
"short-heartbeat products should refresh at the local-failure fast floor before the traversal liveness floor"
);
assert_eq!(
crate::node::handlers::traversal_path_quiet_refresh_timeout(
10,
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(30),
),
std::time::Duration::from_secs(10),
"default FIPS heartbeat should refresh after one missed heartbeat, before full link-dead"
);
assert_eq!(
crate::node::handlers::traversal_path_quiet_refresh_timeout(
10,
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(8),
),
std::time::Duration::from_secs(7),
"the proactive window must stay before the effective link-dead timeout"
);
}
#[tokio::test]
async fn authenticated_fmp_heartbeat_on_observed_tuple_keeps_idle_direct_link_fresh() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let current_addr = crate::transport::TransportAddr::from_string("203.0.113.9:2121");
let observed_addr = crate::transport::TransportAddr::from_string("198.51.100.20:61062");
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.peers.push(peer_config);
let session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: current_addr.clone(),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
active.touch(Node::now_ms().saturating_sub(31_000));
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(31),
);
node.record_authenticated_fmp_receive_facts(
crate::node::AuthenticatedFmpReceiveFacts {
source_peer: peer,
transport_id: TransportId::new(1),
remote_addr: &observed_addr,
packet_timestamp_ms: Node::now_ms(),
packet_len: 64,
fmp_counter: 2,
inner_timestamp_ms: 1_234,
fmp_flags: 0,
},
Some(&peer_addr),
);
assert_eq!(
node.get_peer(&peer_addr)
.and_then(|peer| peer.current_addr()),
Some(¤t_addr),
"liveness-only heartbeat should not bypass path-priority rotation rules"
);
assert!(
node.dataplane_fmp_link_metrics(&peer_addr, std::time::Instant::now())
.and_then(|metrics| metrics.last_recv_age_ms)
.is_some_and(|age_ms| age_ms < 1_000),
"authenticated same-peer heartbeat should refresh FMP receive liveness"
);
node.check_link_heartbeats().await;
assert!(
node.get_peer(&peer_addr).expect("direct peer").is_healthy(),
"authenticated same-peer heartbeat should keep an idle direct peer out of link-dead"
);
}
#[tokio::test]
async fn quiet_recent_endpoint_path_refresh_keeps_direct_payload_without_demoting_peer() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let transit_identity = Identity::generate();
let transit_peer = PeerIdentity::from_pubkey(transit_identity.pubkey());
let transit_addr = *transit_peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config.clone());
let session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(11),
);
node.peers.insert(
transit_addr,
ActivePeer::new(transit_peer, LinkId::new(9), 0),
);
node.learn_reverse_route(peer_addr, transit_addr);
node.check_link_heartbeats().await;
let direct = node.get_peer(&peer_addr).expect("direct peer retained");
assert!(
direct.is_healthy(),
"quiet pre-dead refresh should not mark the direct peer stale yet"
);
assert!(
node.retry_pending.contains_key(&peer_addr),
"one missed traversal heartbeat should queue direct-path refresh before the full mobile-safe link-dead window"
);
assert!(
!node.session_direct_path_is_degraded(&peer_addr, Node::now_ms()),
"a quiet pre-dead refresh is only a probe signal, not a hard link-dead mark"
);
assert!(
!node.pending_lookups.contains_key(&peer_addr),
"quiet pre-dead refresh should not start fallback discovery until link-dead or loss evidence arrives"
);
let direct = node.find_next_hop(&peer_addr).expect("direct route");
assert_eq!(
direct.node_addr(),
&peer_addr,
"background direct-path refresh alone should not move payload off a healthy direct peer"
);
}
#[tokio::test]
async fn active_endpoint_traffic_on_quiet_traversal_path_warms_fallback() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let transit_identity = Identity::generate();
let transit_peer = PeerIdentity::from_pubkey(transit_identity.pubkey());
let transit_addr = *transit_peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config.clone());
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &peer_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(11),
);
node.peers.insert(
transit_addr,
ActivePeer::new(transit_peer, LinkId::new(9), 0),
);
node.learn_reverse_route(peer_addr, transit_addr);
let session = crate::node::session::SessionEntry::new(
peer_addr,
peer_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
);
node.sessions.insert(peer_addr, session);
seed_dataplane_fsp_data_sent_for_test(&mut node, peer_addr, peer_addr, Node::now_ms());
node.check_link_heartbeats().await;
assert!(
node.get_peer(&peer_addr).expect("direct peer").is_healthy(),
"quiet-refresh evidence should not mark the direct peer link-dead"
);
assert!(
node.retry_pending.contains_key(&peer_addr),
"active blackhole evidence should still queue direct-path refresh"
);
assert!(
node.pending_lookups.contains_key(&peer_addr),
"active outbound traffic without authenticated return should warm fallback before full link-dead"
);
assert!(
!node.session_direct_path_is_degraded(&peer_addr, Node::now_ms()),
"quiet active-traffic evidence should de-prioritize exclusive direct trust without declaring the path dead"
);
let fallback = node.find_next_hop(&peer_addr).expect("fallback route");
assert_eq!(
fallback.node_addr(),
&transit_addr,
"known fallback should carry payload while quiet direct traversal refresh runs"
);
}
#[tokio::test]
async fn endpoint_session_traffic_keeps_traversal_liveness_fresh() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config.clone());
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &peer_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(11),
);
let session = crate::node::session::SessionEntry::new(
peer_addr,
peer_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
);
node.sessions.insert(peer_addr, session);
seed_dataplane_fsp_data_rx_for_test(&mut node, peer_addr, peer_addr, Node::now_ms());
node.check_link_heartbeats().await;
assert!(
!node.retry_pending.contains_key(&peer_addr),
"authenticated endpoint traffic should suppress proactive direct refresh"
);
assert!(
!node.session_direct_path_is_degraded(&peer_addr, Node::now_ms()),
"fresh endpoint traffic should keep direct payload eligible"
);
assert!(
!node.pending_lookups.contains_key(&peer_addr),
"fresh endpoint traffic should not trigger fallback discovery"
);
let direct = node.find_next_hop(&peer_addr).expect("direct route");
assert_eq!(
direct.node_addr(),
&peer_addr,
"direct payload route should remain selected while endpoint traffic is fresh"
);
}
#[tokio::test]
async fn endpoint_session_traffic_from_direct_peer_keeps_liveness_fresh_without_route_marker() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config);
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &peer_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(11),
);
let session = crate::node::session::SessionEntry::new(
peer_addr,
peer_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
);
node.sessions.insert(peer_addr, session);
seed_dataplane_fsp_data_rx_for_test(&mut node, peer_addr, peer_addr, Node::now_ms());
node.check_link_heartbeats().await;
assert!(
!node.retry_pending.contains_key(&peer_addr),
"authenticated direct endpoint data should keep the peer fresh even when next-hop bookkeeping is missing"
);
assert!(
!node.pending_lookups.contains_key(&peer_addr),
"fresh direct endpoint data should not start fallback discovery"
);
let direct = node.find_next_hop(&peer_addr).expect("direct route");
assert_eq!(
direct.node_addr(),
&peer_addr,
"direct payload route should stay eligible while authenticated direct endpoint data is fresh"
);
}
#[tokio::test]
async fn direct_endpoint_data_refreshes_static_peer_after_fallback_send() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let fallback_addr = *Identity::generate().node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config);
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &peer_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
active.touch(Node::now_ms().saturating_sub(31_000));
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(31),
);
let session = crate::node::session::SessionEntry::new(
peer_addr,
peer_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
);
node.sessions.insert(peer_addr, session);
let now_ms = Node::now_ms();
seed_dataplane_fsp_data_sent_for_test(&mut node, peer_addr, fallback_addr, now_ms);
seed_dataplane_fsp_data_rx_for_test(&mut node, peer_addr, peer_addr, now_ms);
node.check_link_heartbeats().await;
assert!(
node.get_peer(&peer_addr).expect("direct peer").is_healthy(),
"fresh direct endpoint data should keep a static direct peer from being marked stale even after a fallback send"
);
}
#[tokio::test]
async fn authenticated_control_return_does_not_keep_direct_payload_route_trusted() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let transit_identity = Identity::generate();
let transit_peer = PeerIdentity::from_pubkey(transit_identity.pubkey());
let transit_addr = *transit_peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config.clone());
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &peer_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(11),
);
node.peers.insert(
transit_addr,
ActivePeer::new(transit_peer, LinkId::new(9), 0),
);
node.learn_reverse_route(peer_addr, transit_addr);
let now_ms = Node::now_ms();
let session = crate::node::session::SessionEntry::new(
peer_addr,
peer_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
);
node.sessions.insert(peer_addr, session);
seed_dataplane_fsp_data_sent_for_test(&mut node, peer_addr, peer_addr, now_ms);
seed_dataplane_fsp_control_rx_for_test(&mut node, peer_addr, peer_addr, now_ms);
node.check_link_heartbeats().await;
assert!(
!node.retry_pending.contains_key(&peer_addr),
"fresh authenticated control/session return can stop direct-probe churn"
);
assert!(
!node.pending_lookups.contains_key(&peer_addr),
"a known learned fallback can carry payload without starting another lookup"
);
let fallback = node.find_next_hop(&peer_addr).expect("fallback route");
assert_eq!(
fallback.node_addr(),
&transit_addr,
"payload should use known fallback when recent direct endpoint sends lack authenticated data return"
);
}
#[tokio::test]
async fn fresh_control_with_unreturned_endpoint_data_warms_fallback_lookup() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let transit_identity = Identity::generate();
let transit_peer = PeerIdentity::from_pubkey(transit_identity.pubkey());
let transit_addr = *transit_peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config.clone());
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &peer_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
active.touch(Node::now_ms());
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(&mut node, peer_addr, std::time::Duration::ZERO);
node.peers.insert(
transit_addr,
ActivePeer::new(transit_peer, LinkId::new(9), 0),
);
let now_ms = Node::now_ms();
let session = crate::node::session::SessionEntry::new(
peer_addr,
peer_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
);
node.sessions.insert(peer_addr, session);
seed_dataplane_fsp_data_sent_for_test(&mut node, peer_addr, peer_addr, now_ms);
seed_dataplane_fsp_control_rx_for_test(&mut node, peer_addr, peer_addr, now_ms);
let mut retry = super::super::retry::RetryState::new(peer_config);
retry.reconnect = true;
retry.retry_after_ms = now_ms;
node.retry_pending.insert(peer_addr, retry);
node.check_link_heartbeats().await;
assert!(
node.get_peer(&peer_addr).expect("direct peer").is_healthy(),
"fresh control traffic should not mark the direct peer link-dead"
);
assert!(
node.session_direct_path_blocks_direct_payload(&peer_addr, Node::now_ms()),
"fresh control traffic must not keep unreturned endpoint data pinned to the suspect direct path"
);
assert!(
!node.retry_pending.contains_key(&peer_addr),
"fresh control traffic should clear direct-probe retry even while payload trust remains degraded"
);
assert!(
node.pending_lookups.contains_key(&peer_addr),
"active endpoint sends without authenticated endpoint return should warm fallback even when control is fresh"
);
assert!(
node.find_next_hop(&peer_addr).is_none(),
"without a learned fallback, payload should queue instead of continuing into the suspect direct tuple"
);
}
#[tokio::test]
async fn fresh_bootstrap_path_keeps_static_direct_refresh_pending() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config.clone());
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let bootstrap_transport = TransportId::new(77);
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: bootstrap_transport,
current_addr: crate::transport::TransportAddr::from_string("198.51.100.9:44444"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
let now_ms = Node::now_ms();
active.touch(now_ms);
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(&mut node, peer_addr, std::time::Duration::ZERO);
node.bootstrap_transports.mark(bootstrap_transport);
let mut retry = super::super::retry::RetryState::new(peer_config);
retry.reconnect = true;
retry.retry_after_ms = now_ms;
node.retry_pending.insert(peer_addr, retry);
node.check_link_heartbeats().await;
assert!(
node.retry_pending.contains_key(&peer_addr),
"a fresh adopted traversal path must not cancel refresh toward configured direct endpoints"
);
}
#[tokio::test]
async fn fresh_bootstrap_endpoint_data_clears_static_direct_refresh_pending() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let app_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let app_peer = PeerIdentity::from_pubkey_full(app_identity.pubkey_full());
let app_addr = *app_peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config.clone());
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &app_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let bootstrap_transport = TransportId::new(77);
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: bootstrap_transport,
current_addr: crate::transport::TransportAddr::from_string("198.51.100.9:44444"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
active.touch(Node::now_ms());
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(&mut node, peer_addr, std::time::Duration::ZERO);
node.bootstrap_transports.mark(bootstrap_transport);
let now_ms = Node::now_ms();
let session = crate::node::session::SessionEntry::new(
app_addr,
app_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
);
node.sessions.insert(app_addr, session);
seed_dataplane_fsp_data_sent_for_test(&mut node, app_addr, peer_addr, now_ms);
seed_dataplane_fsp_data_rx_for_test(&mut node, app_addr, peer_addr, now_ms);
assert!(
!node.active_peer_should_keep_direct_retry(&peer_addr, &peer_config),
"fresh endpoint data on an adopted path should quiet stale static-endpoint probing"
);
let mut retry = super::super::retry::RetryState::new(peer_config);
retry.reconnect = true;
retry.retry_after_ms = now_ms;
node.retry_pending.insert(peer_addr, retry);
node.check_link_heartbeats().await;
assert!(
!node.retry_pending.contains_key(&peer_addr),
"fresh authenticated endpoint return on a bootstrap path should clear direct-probe retry"
);
}
#[tokio::test]
async fn fresh_control_with_unreturned_endpoint_data_keeps_direct_without_fallback_peer() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config);
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &peer_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
active.touch(Node::now_ms());
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(&mut node, peer_addr, std::time::Duration::ZERO);
let now_ms = Node::now_ms();
let session = crate::node::session::SessionEntry::new(
peer_addr,
peer_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
);
node.sessions.insert(peer_addr, session);
seed_dataplane_fsp_data_sent_for_test(&mut node, peer_addr, peer_addr, now_ms);
seed_dataplane_fsp_control_rx_for_test(&mut node, peer_addr, peer_addr, now_ms);
let discovery_initiated = node.stats().discovery.req_initiated;
node.check_link_heartbeats().await;
let direct = node.get_peer(&peer_addr).expect("direct peer retained");
assert!(
direct.is_healthy() && direct.can_send(),
"control-fresh peer should stay connected and probeable"
);
assert!(
node.session_direct_path_blocks_direct_payload(&peer_addr, Node::now_ms()),
"the soft traversal trust signal should still be visible to fallback-capable meshes"
);
assert!(
!node.pending_lookups.contains_key(&peer_addr),
"a two-node direct path must not discover the peer through itself every maintenance tick"
);
assert_eq!(
node.stats().discovery.req_initiated,
discovery_initiated,
"path recovery without a fallback peer should not initiate discovery"
);
assert_eq!(
node.find_next_hop(&peer_addr)
.map(|next_hop| *next_hop.node_addr()),
Some(peer_addr),
"with no alternate carrier, the soft-suspect direct path remains the payload route"
);
}
#[tokio::test]
async fn endpoint_return_via_direct_next_hop_keeps_link_liveness_fresh() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let app_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let app_peer = PeerIdentity::from_pubkey_full(app_identity.pubkey_full());
let app_addr = *app_peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config.clone());
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &app_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(11),
);
let now_ms = Node::now_ms();
let session = crate::node::session::SessionEntry::new(
app_addr,
app_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
);
node.sessions.insert(app_addr, session);
seed_dataplane_fsp_data_sent_for_test(&mut node, app_addr, peer_addr, now_ms);
seed_dataplane_fsp_data_rx_for_test(&mut node, app_addr, peer_addr, now_ms);
node.check_link_heartbeats().await;
assert!(
!node.retry_pending.contains_key(&peer_addr),
"authenticated endpoint return through a direct first hop should keep that link out of direct-probe refresh"
);
let direct = node.find_next_hop(&peer_addr).expect("direct route");
assert_eq!(
direct.node_addr(),
&peer_addr,
"active endpoint traffic through this peer should keep the direct link eligible"
);
}
#[tokio::test]
async fn authenticated_endpoint_return_clears_static_retry_on_fresh_discovered_udp() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let app_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let app_peer = PeerIdentity::from_pubkey_full(app_identity.pubkey_full());
let app_addr = *app_peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config.clone());
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &app_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("198.51.100.20:61062"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
active.touch(Node::now_ms());
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(11),
);
assert!(
!node.active_peer_should_keep_direct_retry(&peer_addr, &peer_config),
"a healthy discovered UDP path should not keep retrying a mismatched static LAN hint"
);
let now_ms = Node::now_ms();
let session = crate::node::session::SessionEntry::new(
app_addr,
app_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
);
node.sessions.insert(app_addr, session);
seed_dataplane_fsp_data_sent_for_test(&mut node, app_addr, peer_addr, now_ms);
seed_dataplane_fsp_data_rx_for_test(&mut node, app_addr, peer_addr, now_ms);
let mut retry = super::super::retry::RetryState::new(peer_config);
retry.reconnect = true;
retry.retry_after_ms = now_ms;
node.retry_pending.insert(peer_addr, retry);
node.check_link_heartbeats().await;
assert!(
!node.retry_pending.contains_key(&peer_addr),
"fresh authenticated endpoint return through a discovered UDP first hop should clear stale static-endpoint retry state"
);
}
#[tokio::test]
async fn local_route_failure_for_one_peer_does_not_fast_dead_unrelated_direct_peer() {
let local_identity = Identity::generate();
let quiet_identity = Identity::generate();
let failed_identity = Identity::generate();
let quiet_config = crate::config::PeerConfig {
npub: quiet_identity.npub(),
alias: Some("quiet-lan-peer".to_string()),
addresses: vec![crate::config::PeerAddress::with_priority(
"udp",
"198.51.100.57:51820",
1,
)],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let quiet_peer = PeerIdentity::from_npub(&quiet_config.npub).expect("quiet peer identity");
let quiet_addr = *quiet_peer.node_addr();
let failed_peer =
PeerIdentity::from_pubkey(failed_identity.pubkey_full().x_only_public_key().0);
let failed_addr = *failed_peer.node_addr();
let mut config = Config::new();
config.peers.push(quiet_config);
let session = make_test_fmp_session(&local_identity, &quiet_identity, [1; 8], [2; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 2;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let quiet_active = ActivePeer::with_session(
quiet_peer,
LinkId::new(7),
0,
ActivePeerSession {
session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("198.51.100.57:51820"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
node.peers.insert(quiet_addr, quiet_active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
quiet_addr,
std::time::Duration::from_secs(6),
);
node.local_send_failures
.record_failure(failed_addr, std::time::Instant::now());
node.check_link_heartbeats().await;
assert!(
node.peers.contains_key(&quiet_addr),
"a local route failure for {} must not demote unrelated healthy direct peer {}",
failed_addr,
quiet_addr
);
assert!(
!node.retry_pending.contains_key(&quiet_addr),
"unrelated local route failures must not schedule direct reconnect for the quiet peer"
);
}
#[test]
fn stale_candidate_failure_does_not_fast_dead_active_current_path() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer = PeerIdentity::from_pubkey(peer_identity.pubkey_full().x_only_public_key().0);
let peer_addr = *peer.node_addr();
let mut node = Node::with_identity(local_identity, Config::new()).expect("node");
let transport_id = TransportId::new(1);
let current_addr = crate::transport::TransportAddr::from_string("198.51.100.57:51820");
let stale_candidate = crate::transport::TransportAddr::from_string("192.168.178.55:51821");
let route_error = Err(crate::transport::TransportError::SendFailed(
"No route to host (os error 65)".to_string(),
));
let now = std::time::Instant::now();
let dead_timeout = std::time::Duration::from_secs(30);
let fast_dead_timeout = std::time::Duration::from_secs(5);
let mut active = ActivePeer::new(peer, LinkId::new(7), 0);
active.set_current_addr(transport_id, ¤t_addr);
node.peers.insert(peer_addr, active);
node.note_candidate_send_outcome(&peer_addr, &stale_candidate, &route_error);
assert!(
!node.local_send_failures.contains_key(&peer_addr),
"a failed stale candidate probe must not poison liveness for the active current path"
);
assert_eq!(
node.local_send_failure_dead_timeout_for_peer(
&peer_addr,
now,
dead_timeout,
fast_dead_timeout,
),
dead_timeout
);
node.note_candidate_send_outcome(&peer_addr, ¤t_addr, &route_error);
assert!(
node.local_send_failures.contains_key(&peer_addr),
"a failed send to the active current path should still enable fast-dead recovery"
);
assert_eq!(
node.local_send_failure_dead_timeout_for_peer(
&peer_addr,
now,
dead_timeout,
fast_dead_timeout,
),
fast_dead_timeout
);
}
#[test]
fn fmp_bulk_classifier_detects_established_session_datagrams() {
let src = make_node_addr(1);
let dst = make_node_addr(2);
let fsp_payload = crate::node::session_wire::build_fsp_header(7, 0, 0).to_vec();
let datagram = crate::protocol::SessionDatagram::new(src, dst, fsp_payload);
assert!(
crate::node::endpoint_traffic::fmp_plaintext_is_bulk_session_datagram(&datagram.encode())
);
let coords_payload =
crate::node::session_wire::build_fsp_header(8, crate::node::session_wire::FSP_FLAG_CP, 0)
.to_vec();
let coords_datagram = crate::protocol::SessionDatagram::new(src, dst, coords_payload);
assert!(
!crate::node::endpoint_traffic::fmp_plaintext_is_bulk_session_datagram(
&coords_datagram.encode()
),
"coordinate-carrying session packets warm fallback routes and must stay in the control lane"
);
let heartbeat = [crate::protocol::LinkMessageType::Heartbeat.to_byte()];
assert!(!crate::node::endpoint_traffic::fmp_plaintext_is_bulk_session_datagram(&heartbeat));
let setup_prefix = crate::node::session_wire::build_fsp_handshake_prefix(
crate::node::session_wire::FSP_PHASE_MSG1,
0,
);
let setup_datagram = crate::protocol::SessionDatagram::new(src, dst, setup_prefix.to_vec());
assert!(
!crate::node::endpoint_traffic::fmp_plaintext_is_bulk_session_datagram(
&setup_datagram.encode()
)
);
}
#[tokio::test]
async fn link_dead_recent_endpoint_path_reprobes_without_traversal_cooldown() {
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.node.discovery.nostr.enabled = true;
config.peers.push(peer_config.clone());
let mut node = Node::new(config).expect("node");
let (packet_tx, packet_rx) = packet_channel(64);
node.packet_tx = Some(packet_tx.clone());
node.packet_rx = Some(packet_rx);
let transport_id = TransportId::new(1);
let mut udp = UdpTransport::new(
transport_id,
Some("main".to_string()),
crate::config::UdpConfig {
bind_addr: Some("127.0.0.1:0".to_string()),
..Default::default()
},
packet_tx,
);
udp.start_async().await.unwrap();
node.transports
.insert(transport_id, TransportHandle::Udp(udp));
let mut active = ActivePeer::new(peer, LinkId::new(7), 0);
active.set_current_addr(
transport_id,
&crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
);
node.peers.insert(peer_addr, active);
let bootstrap = Arc::new(NostrDiscovery::new_for_test());
node.nostr_discovery = Some(bootstrap.clone());
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let recent_path_timeout = node
.traversal_path_link_dead_timeout(
&peer_addr,
std::time::Duration::from_secs(node.config.node.link_dead_timeout_secs),
std::time::Duration::from_secs(node.config.node.fast_link_dead_timeout_secs),
)
.expect("recent endpoint path should get bounded liveness timeout");
assert_eq!(recent_path_timeout, std::time::Duration::from_secs(30));
node.record_link_dead_path_failure(&peer_addr, 1_000).await;
assert!(
bootstrap.cooldown_until(&peer_config.npub, 1_000).is_none(),
"one transient link-dead event should not suppress direct traversal"
);
node.schedule_link_dead_reprobe(peer_addr, 1_000);
let state = node
.retry_pending
.get(&peer_addr)
.expect("link-dead reconnect should seed retry state");
assert!(state.reconnect);
assert_eq!(state.peer_config.npub, peer_config.npub);
assert_eq!(state.retry_count, 0);
assert!(
(1_500..=2_500).contains(&state.retry_after_ms),
"link-dead retry should stay quick but jittered, got {}",
state.retry_after_ms
);
for now_ms in [2_000, 3_000, 4_000, 5_000] {
node.record_link_dead_path_failure(&peer_addr, now_ms).await;
}
assert!(
bootstrap.cooldown_until(&peer_config.npub, 5_000).is_none(),
"repeated link-dead endpoint paths should not install peer traversal cooldown"
);
let state = node
.retry_pending
.get(&peer_addr)
.expect("threshold link-dead penalty should preserve retry state");
let first_retry_after_ms = state.retry_after_ms;
assert!(
(1_500..=2_500).contains(&first_retry_after_ms),
"link-dead diagnostics must not push retry behind traversal cooldown"
);
node.schedule_link_dead_reprobe(peer_addr, 5_000);
let state = node
.retry_pending
.get(&peer_addr)
.expect("reconnect should preserve cooled-down retry state");
assert!(
(5_500..=6_500).contains(&state.retry_after_ms),
"each link-dead removal should make direct probing eligible again quickly"
);
assert_eq!(state.retry_count, 0);
for transport in node.transports.values_mut() {
transport.stop().await.ok();
}
}
#[tokio::test]
async fn quiet_recent_endpoint_path_stays_alive_within_mobile_window() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.peers.push(peer_config);
let session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(29),
);
node.check_link_heartbeats().await;
assert!(
node.peers.contains_key(&peer_addr),
"link-dead should keep the authenticated peer identity"
);
assert!(
node.get_peer(&peer_addr).expect("peer").is_healthy(),
"a proven traversal/recent path at 29s silence should refresh but not flap before the mobile-safe liveness window"
);
assert!(
node.retry_pending.contains_key(&peer_addr),
"quiet traversal liveness should schedule direct reprobe before link-dead"
);
assert!(
node.get_peer(&peer_addr).expect("peer").can_send(),
"quiet direct paths remain sendable while the reprobe runs"
);
let configured_peer = node.configured_peer(&peer_addr).expect("configured peer");
assert!(
node.active_peer_uses_recent_endpoint_path(&peer_addr, configured_peer),
"test fixture should still classify the stale path as a recent endpoint path"
);
assert_eq!(
node.find_next_hop(&peer_addr).map(|peer| *peer.node_addr()),
Some(peer_addr),
"without a fallback route, a soft-stale traversal path should remain the last-resort payload route while reprobe runs"
);
}
#[test]
fn degraded_recent_endpoint_path_without_fallback_queues_payload() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.peers.push(peer_config);
let session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
active.mark_stale();
node.peers.insert(peer_addr, active);
node.mark_session_direct_path_degraded(peer_addr, Node::now_ms());
let configured_peer = node.configured_peer(&peer_addr).expect("configured peer");
assert!(
node.active_peer_uses_recent_endpoint_path(&peer_addr, configured_peer),
"test fixture should still classify the stale path as a recent endpoint path"
);
assert!(
node.get_peer(&peer_addr).expect("peer").can_send(),
"degraded direct paths stay sendable for reprobes"
);
assert!(
node.find_next_hop(&peer_addr).is_none(),
"a degraded stale traversal path must wait for fallback discovery instead of blackholing payload"
);
}
#[tokio::test]
async fn local_route_payload_failure_degrades_direct_and_warms_retry() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config);
let session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
active.mark_stale();
node.peers.insert(peer_addr, active);
assert_eq!(
node.find_next_hop(&peer_addr).map(|peer| *peer.node_addr()),
Some(peer_addr),
"soft-stale traversal paths remain last-resort routes until a hard send failure arrives"
);
node.recover_direct_payload_send_failure(
peer_addr,
peer_addr,
&crate::node::NodeError::LocalRouteUnavailable(
"send failed: Network is unreachable (os error 51)".to_string(),
),
);
assert!(
node.retry_pending.contains_key(&peer_addr),
"local route failures should schedule a short direct-path retry"
);
assert!(
node.find_next_hop(&peer_addr).is_none(),
"after a local route payload failure, stale direct must stop blackholing payload while fallback warms"
);
}
#[tokio::test]
async fn local_route_failure_does_not_collapse_recent_endpoint_liveness_window() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.peers.push(peer_config);
let session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(6),
);
node.local_send_failures
.record_failure(peer_addr, std::time::Instant::now());
node.check_link_heartbeats().await;
assert!(
node.get_peer(&peer_addr).expect("peer").is_healthy(),
"a transient local route error must not shrink a recent endpoint path from the traversal liveness window to fast-dead"
);
assert!(
!node.retry_pending.contains_key(&peer_addr),
"recent endpoint path should not be reprobed until its traversal liveness window expires"
);
}
#[tokio::test]
async fn recent_authenticated_fmp_receive_prevents_traversal_link_dead() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.peers.push(peer_config);
let session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
active.touch(Node::now_ms());
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(23),
);
node.check_link_heartbeats().await;
assert!(
node.get_peer(&peer_addr).expect("peer").is_healthy(),
"recent authenticated FMP traffic must keep traversal liveness warm even if MMP is stale"
);
assert!(
!node.retry_pending.contains_key(&peer_addr),
"a live authenticated path should not schedule link-dead direct reprobe"
);
}
#[tokio::test]
async fn outbound_fmp_send_does_not_refresh_direct_path_liveness() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.peers.push(peer_config);
let session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 10;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
node.peers.insert(peer_addr, active);
assert!(node.sync_dataplane_fmp_owner(&peer_addr));
assert!(
node.dataplane
.record_authenticated_fmp_mmp_receive(
crate::dataplane::DataplaneAuthenticatedFmpMmpReceive::new(
peer_addr,
1,
100,
64,
false,
false,
std::time::Instant::now() - std::time::Duration::from_secs(23),
),
)
.is_ok(),
"dataplane FMP MMP receive bookkeeping recorded"
);
assert!(
node.peers.record_fmp_send_bookkeeping(&peer_addr, 64),
"send bookkeeping recorded"
);
node.check_link_heartbeats().await;
assert!(
node.retry_pending.contains_key(&peer_addr),
"outbound FMP send bookkeeping must not keep a quiet direct path trusted"
);
}
#[tokio::test]
async fn link_dead_after_rx_loop_timeout_does_not_cool_down_traversal_path() {
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![
crate::config::PeerAddress::with_priority("udp", "203.0.113.9:2121", 1)
.with_seen_at_ms(10),
],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.peers.push(peer_config.clone());
let mut node = Node::new(config).expect("node");
node.config.node.link_dead_timeout_secs = 30;
let mut active = ActivePeer::new(peer, LinkId::new(7), 0);
active.set_current_addr(
TransportId::new(1),
&crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
);
node.peers.insert(peer_addr, active);
let bootstrap = Arc::new(NostrDiscovery::new_for_test());
node.nostr_discovery = Some(bootstrap.clone());
node.mark_rx_loop_maintenance_timeout();
for now_ms in [1_000, 2_000, 3_000, 4_000, 5_000] {
node.record_link_dead_path_failure(&peer_addr, now_ms).await;
}
assert!(
bootstrap.cooldown_until(&peer_config.npub, 5_000).is_none(),
"local rx-loop stalls must not be counted as repeated bad traversal paths"
);
assert!(
!node.retry_pending.contains_key(&peer_addr),
"skipping traversal penalty must not seed cooldown retry state"
);
}
#[tokio::test]
async fn link_dead_marks_direct_path_stale_and_preserves_queued_packets() {
let local_identity = Identity::generate();
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: vec![crate::config::PeerAddress::with_priority(
"udp",
"203.0.113.9:2121",
1,
)],
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let transit_identity = Identity::generate();
let transit_peer = PeerIdentity::from_pubkey(transit_identity.pubkey());
let transit_addr = *transit_peer.node_addr();
let mut config = Config::new();
config.node.routing.mode = crate::config::RoutingMode::ReplyLearned;
config.peers.push(peer_config.clone());
let link_session = make_test_fmp_session(&local_identity, &peer_identity, [1; 8], [2; 8]);
let endpoint_session = make_test_fmp_session(&local_identity, &peer_identity, [3; 8], [4; 8]);
let mut node = Node::with_identity(local_identity, config).expect("node");
node.config.node.heartbeat_interval_secs = 2;
node.config.node.link_dead_timeout_secs = 30;
node.config.node.fast_link_dead_timeout_secs = 5;
let mut active = ActivePeer::with_session(
peer,
LinkId::new(7),
0,
ActivePeerSession {
session: link_session,
our_index: crate::utils::index::SessionIndex::new(11),
their_index: crate::utils::index::SessionIndex::new(12),
transport_id: TransportId::new(1),
current_addr: crate::transport::TransportAddr::from_string("203.0.113.9:2121"),
link_stats: crate::transport::LinkStats::new(),
is_initiator: true,
remote_epoch: None,
},
);
active.set_handshake_msg2(vec![0x02, 0x03, 0x04]);
node.peers.insert(peer_addr, active);
super::super::seed_dataplane_fmp_rx_for_test(
&mut node,
peer_addr,
std::time::Duration::from_secs(31),
);
node.peers.insert(
transit_addr,
ActivePeer::new(transit_peer, LinkId::new(9), 0),
);
node.learn_reverse_route(peer_addr, transit_addr);
node.sessions.insert(
peer_addr,
crate::node::session::SessionEntry::new(
peer_addr,
peer_identity.pubkey_full(),
crate::node::session::EndToEndState::Established(endpoint_session),
1_000,
true,
),
);
node.pending_session_traffic
.push_tun_packet(peer_addr, vec![1, 2, 3], usize::MAX, usize::MAX);
node.pending_session_traffic
.push_endpoint_data_batch_with_enqueued_at_ms(
peer_addr,
vec![
crate::node::EndpointDataPayload::from_packet_payload(vec![4, 5, 6])
.expect("test endpoint payload"),
],
usize::MAX,
usize::MAX,
crate::time::now_ms(),
);
node.check_link_heartbeats().await;
assert!(
node.peers.contains_key(&peer_addr),
"link-dead should keep the authenticated peer identity"
);
assert!(
node.get_peer(&peer_addr).expect("peer").can_send(),
"link-dead should keep the stale direct path sendable for probes and late recovery"
);
assert!(
!node.get_peer(&peer_addr).expect("peer").is_healthy(),
"link-dead should remove the dead direct path from healthy-direct routing"
);
assert!(
node.get_peer(&peer_addr)
.expect("peer")
.handshake_msg2()
.is_none(),
"link-dead recovery must not answer fresh retry msg1 with stale msg2"
);
assert!(
node.sessions
.get(&peer_addr)
.is_some_and(|entry| entry.is_established()),
"link-dead should preserve the established FSP session so fallback can carry traffic immediately"
);
assert_eq!(
node.pending_session_traffic
.tun_packets_for(&peer_addr)
.map(|queue| queue.len()),
Some(1),
"queued TUN packets should survive direct link teardown"
);
assert_eq!(
node.pending_session_traffic
.endpoint_data_for(&peer_addr)
.map(|queue| queue.len()),
Some(1),
"queued endpoint data should survive direct link teardown"
);
assert!(
node.retry_pending.contains_key(&peer_addr),
"direct reprobe should still be scheduled"
);
assert!(
node.pending_lookups.contains_key(&peer_addr),
"fallback lookup should start while queued packets are preserved"
);
assert!(
node.session_direct_path_is_degraded(&peer_addr, Node::now_ms()),
"link-dead should mark payload routing away from the suspect direct path"
);
let fallback = node.find_next_hop(&peer_addr).expect("fallback route");
assert_eq!(
fallback.node_addr(),
&transit_addr,
"fallback route should carry payload traffic while direct remains probeable"
);
let first_retry_after = node
.retry_pending
.get(&peer_addr)
.expect("direct reprobe should stay scheduled")
.retry_after_ms;
node.check_link_heartbeats().await;
assert!(
node.get_peer(&peer_addr).expect("peer").can_send(),
"a stale path should remain probeable instead of flapping to reconnecting"
);
assert_eq!(
node.retry_pending
.get(&peer_addr)
.expect("direct reprobe should stay scheduled")
.retry_after_ms,
first_retry_after,
"stale direct paths should not be repeatedly link-dead demoted every maintenance tick"
);
}
#[test]
fn reconnecting_auto_connect_peer_is_eligible_for_graph_session_warmup() {
let peer_identity = Identity::generate();
let peer_config = crate::config::PeerConfig {
npub: peer_identity.npub(),
alias: None,
addresses: Vec::new(),
connect_policy: crate::config::ConnectPolicy::AutoConnect,
auto_reconnect: true,
discovery_fallback_transit: true,
};
let peer = PeerIdentity::from_npub(&peer_config.npub).expect("peer identity");
let peer_addr = *peer.node_addr();
let mut config = Config::new();
config.node.discovery.nostr.enabled = true;
config.peers.push(peer_config);
let mut node = Node::new(config).expect("node");
let mut active = ActivePeer::new(peer, LinkId::new(7), 0);
active.mark_reconnecting();
node.peers.insert(peer_addr, active);
assert!(
node.should_warm_auto_connect_session(&peer_addr),
"a reconnecting direct peer should still warm an end-to-end fallback session"
);
assert!(
node.find_next_hop(&peer_addr).is_none(),
"a reconnecting direct peer must not be selected as a data next-hop"
);
}