mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-11 09:07:44 +00:00
Fix stale session cleanup and identity cache pre-seeding
Cherry-picked from v0l PR #6 (issue #5): 1. remove_active_peer() now removes the end-to-end session from self.sessions when evicting a peer. The stale Established entry caused initiate_session() to silently return Ok(()) via the is_established() guard, preventing session re-establishment after link-layer reconnection. 2. initiate_peer_connections() pre-seeds the identity cache from configured peer npubs at startup, so TUN packets can be dispatched immediately without waiting for handshake completion. 3. schedule_reconnect() preserves accumulated backoff when a retry entry already exists, preventing exponential backoff reset on repeated link-dead cycles. Includes regression tests for all three fixes.
This commit is contained in:
@@ -228,6 +228,94 @@ async fn test_disconnect_chain_partition() {
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// Removing a peer via disconnect must also remove the associated end-to-end session.
|
||||
///
|
||||
/// Regression test for issue #5: `remove_active_peer` previously left the
|
||||
/// `SessionEntry` alive in `self.sessions` after evicting the peer from
|
||||
/// `self.peers`. This caused:
|
||||
/// 1. Stale "MMP session metrics" logs with frozen counters until
|
||||
/// `purge_idle_sessions` eventually fired (up to idle_timeout_secs later).
|
||||
/// 2. `initiate_session` silently returning `Ok(())` on the stale Established
|
||||
/// entry's guard check, preventing a new session from being created even
|
||||
/// after the link layer reconnected successfully.
|
||||
#[tokio::test]
|
||||
async fn test_disconnect_clears_session() {
|
||||
use crate::identity::Identity;
|
||||
use crate::node::session::{EndToEndState, SessionEntry};
|
||||
use crate::noise::HandshakeState;
|
||||
|
||||
// Two-node topology: 0 -- 1.
|
||||
let edges = vec![(0, 1)];
|
||||
let mut nodes = run_tree_test(2, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
|
||||
let node0_addr = *nodes[0].node.node_addr();
|
||||
let node1_addr = *nodes[1].node.node_addr();
|
||||
|
||||
// Inject a synthetic Established session entry into node 1's session table
|
||||
// to simulate the state after a completed XK handshake with node 0.
|
||||
let remote_identity = Identity::generate();
|
||||
{
|
||||
let our_identity = nodes[1].node.identity().clone();
|
||||
|
||||
let mut initiator = HandshakeState::new_initiator(
|
||||
our_identity.keypair(),
|
||||
remote_identity.pubkey_full(),
|
||||
);
|
||||
let mut responder = HandshakeState::new_responder(remote_identity.keypair());
|
||||
let mut init_epoch = [0u8; 8];
|
||||
rand::Rng::fill_bytes(&mut rand::rng(), &mut init_epoch);
|
||||
initiator.set_local_epoch(init_epoch);
|
||||
let mut resp_epoch = [0u8; 8];
|
||||
rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch);
|
||||
responder.set_local_epoch(resp_epoch);
|
||||
let msg1 = initiator.write_message_1().unwrap();
|
||||
responder.read_message_1(&msg1).unwrap();
|
||||
let msg2 = responder.write_message_2().unwrap();
|
||||
initiator.read_message_2(&msg2).unwrap();
|
||||
let session = initiator.into_session().unwrap();
|
||||
|
||||
let entry = SessionEntry::new(
|
||||
node0_addr,
|
||||
remote_identity.pubkey_full(),
|
||||
EndToEndState::Established(session),
|
||||
1_000,
|
||||
true,
|
||||
);
|
||||
nodes[1].node.sessions.insert(node0_addr, entry);
|
||||
}
|
||||
|
||||
assert_eq!(nodes[1].node.session_count(), 1, "Session should exist before disconnect");
|
||||
assert_eq!(nodes[1].node.peer_count(), 1, "Peer should exist before disconnect");
|
||||
|
||||
// Node 0 sends Disconnect to node 1.
|
||||
let disconnect = crate::protocol::Disconnect::new(DisconnectReason::Shutdown);
|
||||
nodes[0]
|
||||
.node
|
||||
.send_encrypted_link_message(&node1_addr, &disconnect.encode())
|
||||
.await
|
||||
.expect("Failed to send disconnect");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
process_available_packets(&mut nodes).await;
|
||||
|
||||
// Peer must be gone.
|
||||
assert_eq!(
|
||||
nodes[1].node.peer_count(), 0,
|
||||
"Peer should be removed after disconnect"
|
||||
);
|
||||
|
||||
// Session must also be gone — core regression check for issue #5.
|
||||
// Before the fix, session_count() would still be 1 here because
|
||||
// remove_active_peer didn't remove self.sessions[node0_addr].
|
||||
assert_eq!(
|
||||
nodes[1].node.session_count(), 0,
|
||||
"Session must be cleaned up when peer is removed (regression: issue #5)"
|
||||
);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// Verify that different disconnect reasons are handled correctly.
|
||||
///
|
||||
/// Sends each reason code and verifies the peer is removed regardless.
|
||||
|
||||
@@ -702,6 +702,90 @@ fn test_schedule_retry_skips_connected_peer() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that schedule_reconnect preserves accumulated backoff across link-dead cycles.
|
||||
///
|
||||
/// Regression test for issue #5: previously `schedule_reconnect` always created a
|
||||
/// fresh `RetryState` with `retry_count=0`, discarding any backoff accumulated by
|
||||
/// prior failed handshake attempts. On repeated link-dead evictions the node would
|
||||
/// restart exponential backoff from the base interval every time instead of
|
||||
/// continuing to back off.
|
||||
#[test]
|
||||
fn test_schedule_reconnect_preserves_backoff() {
|
||||
let peer_identity = Identity::generate();
|
||||
let peer_npub = peer_identity.npub();
|
||||
let peer_node_addr = *PeerIdentity::from_npub(&peer_npub).unwrap().node_addr();
|
||||
|
||||
let mut config = Config::new();
|
||||
config.peers.push(crate::config::PeerConfig::new(
|
||||
peer_npub,
|
||||
"udp",
|
||||
"10.0.0.2:2121",
|
||||
));
|
||||
|
||||
let mut node = Node::new(config).unwrap();
|
||||
|
||||
// Simulate two stale handshake timeouts incrementing the retry count.
|
||||
node.schedule_retry(peer_node_addr, 1_000); // count=1, delay=10s
|
||||
node.schedule_retry(peer_node_addr, 11_000); // count=2, delay=20s
|
||||
{
|
||||
let state = node.retry_pending.get(&peer_node_addr).unwrap();
|
||||
assert_eq!(state.retry_count, 2, "Two failures should yield count=2");
|
||||
}
|
||||
|
||||
// Now simulate a link-dead removal triggering schedule_reconnect.
|
||||
// The existing retry entry (count=2) should be preserved and bumped to 3,
|
||||
// NOT reset to 0 as it was before the fix.
|
||||
node.schedule_reconnect(peer_node_addr, 31_000);
|
||||
|
||||
let state = node.retry_pending.get(&peer_node_addr).unwrap();
|
||||
assert!(
|
||||
state.reconnect,
|
||||
"Entry should be marked as reconnect"
|
||||
);
|
||||
assert_eq!(
|
||||
state.retry_count, 3,
|
||||
"schedule_reconnect should increment existing count (was 2), not reset to 0 (regression: issue #5)"
|
||||
);
|
||||
|
||||
// With count=3, backoff should be 5s * 2^3 = 40s.
|
||||
let base_ms = node.config.node.retry.base_interval_secs * 1000;
|
||||
let max_ms = node.config.node.retry.max_backoff_secs * 1000;
|
||||
let expected_delay = state.backoff_ms(base_ms, max_ms);
|
||||
assert_eq!(
|
||||
state.retry_after_ms, 31_000 + expected_delay,
|
||||
"retry_after_ms should reflect count=3 backoff"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that schedule_reconnect on a fresh peer (no prior retry entry) starts at count=0.
|
||||
#[test]
|
||||
fn test_schedule_reconnect_fresh_state() {
|
||||
let peer_identity = Identity::generate();
|
||||
let peer_npub = peer_identity.npub();
|
||||
let peer_node_addr = *PeerIdentity::from_npub(&peer_npub).unwrap().node_addr();
|
||||
|
||||
let mut config = Config::new();
|
||||
config.peers.push(crate::config::PeerConfig::new(
|
||||
peer_npub,
|
||||
"udp",
|
||||
"10.0.0.2:2121",
|
||||
));
|
||||
|
||||
let mut node = Node::new(config).unwrap();
|
||||
|
||||
// No prior retry entry — first reconnect should use base delay.
|
||||
node.schedule_reconnect(peer_node_addr, 1_000);
|
||||
|
||||
let state = node.retry_pending.get(&peer_node_addr).unwrap();
|
||||
assert!(state.reconnect, "Entry should be marked as reconnect");
|
||||
assert_eq!(state.retry_count, 0, "Fresh reconnect should start at count=0");
|
||||
// Base delay: 5s * 2^0 = 5s
|
||||
let base_ms = node.config.node.retry.base_interval_secs * 1000;
|
||||
let max_ms = node.config.node.retry.max_backoff_secs * 1000;
|
||||
let expected_delay = state.backoff_ms(base_ms, max_ms);
|
||||
assert_eq!(state.retry_after_ms, 1_000 + expected_delay);
|
||||
}
|
||||
|
||||
/// Test that promote_connection clears retry_pending.
|
||||
#[test]
|
||||
fn test_promote_clears_retry_pending() {
|
||||
|
||||
Reference in New Issue
Block a user