Session message encode/decode, disconnect and routing integration tests

Add encode/decode for all session-layer message types (SessionSetup,
SessionAck, DataPacket, CoordsRequired, PathBroken) and SessionDatagram
link-layer envelope. Wire format follows design doc §8.0-8.6 with
address-only coordinate serialization (16 bytes/entry).

Struct additions: handshake_payload on SessionSetup/SessionAck, flags
on SessionAck, optional src_coords/dest_coords on DataPacket for route
cache warming (COORDS_PRESENT flag).

New disconnect integration tests verify cascading cleanup in multi-node
networks: chain peer removal, star hub departure, chain partition with
bloom filter update verification, all reason codes.

New routing tests validate bloom-filter-only transit behavior and peer
removal effects: confirm transit nodes without cached coords return None
(loop prevention), routing stops after partition, source-only coords
insufficient for multi-hop delivery.

361 tests pass (up from 335), zero warnings.
This commit is contained in:
Johnathan Corgan
2026-02-12 13:01:29 +00:00
parent d41009b778
commit 62ce267677
6 changed files with 1287 additions and 15 deletions
+271
View File
@@ -0,0 +1,271 @@
//! Disconnect and peer removal integration tests.
//!
//! Tests that graceful disconnect messages propagate correctly through
//! multi-node networks and trigger proper cascading cleanup: peer removal,
//! tree reconvergence, and bloom filter recomputation.
use super::spanning_tree::*;
use super::*;
use crate::protocol::{Disconnect, DisconnectReason};
/// 3-node chain: middle node disconnects one peer.
///
/// Chain: 0 -- 1 -- 2. Node 1 sends Disconnect to node 0.
/// Verifies:
/// - Node 0 removes node 1 from its peer table
/// - Node 0's tree reconverges (becomes its own root since isolated)
/// - Node 1 still has node 2 as a peer
#[tokio::test]
async fn test_disconnect_chain_peer_removal() {
// Build 3-node chain: 0 -- 1 -- 2
let edges = vec![(0, 1), (1, 2)];
let mut nodes = run_tree_test(3, &edges, false).await;
verify_tree_convergence(&nodes);
let node0_addr = *nodes[0].node.node_addr();
let node1_addr = *nodes[1].node.node_addr();
let node2_addr = *nodes[2].node.node_addr();
// Verify initial state: node 0 has 1 peer (node 1)
assert_eq!(nodes[0].node.peer_count(), 1);
assert!(nodes[0].node.get_peer(&node1_addr).is_some());
// Node 1 sends Disconnect(Shutdown) to node 0
let disconnect = Disconnect::new(DisconnectReason::Shutdown);
let plaintext = disconnect.encode();
nodes[1]
.node
.send_encrypted_link_message(&node0_addr, &plaintext)
.await
.expect("Failed to send disconnect");
// Process the disconnect at node 0
tokio::time::sleep(Duration::from_millis(50)).await;
process_available_packets(&mut nodes).await;
// Node 0 should have removed node 1
assert_eq!(
nodes[0].node.peer_count(),
0,
"Node 0 should have no peers after disconnect"
);
assert!(
nodes[0].node.get_peer(&node1_addr).is_none(),
"Node 0 should not have node 1 as a peer"
);
// Node 0 becomes its own root (isolated)
assert!(
nodes[0].node.tree_state().is_root(),
"Isolated node 0 should be root"
);
// Node 1 still has node 2 as a peer (disconnect was only to node 0)
assert!(
nodes[1].node.get_peer(&node2_addr).is_some(),
"Node 1 should still have node 2"
);
cleanup_nodes(&mut nodes).await;
}
/// 4-node star: hub disconnects, spokes reconverge.
///
/// Star: 0 is hub, connected to 1, 2, 3. Hub sends Disconnect to all.
/// Verifies:
/// - All spokes remove hub from their peer tables
/// - Each spoke becomes its own root (since there are no spoke-spoke links)
#[tokio::test]
async fn test_disconnect_star_hub_departs() {
let edges = vec![(0, 1), (0, 2), (0, 3)];
let mut nodes = run_tree_test(4, &edges, false).await;
verify_tree_convergence(&nodes);
let hub_addr = *nodes[0].node.node_addr();
// Hub sends Disconnect(Shutdown) to all spokes
let disconnect = Disconnect::new(DisconnectReason::Shutdown);
let plaintext = disconnect.encode();
for spoke_idx in 1..4 {
let spoke_addr = *nodes[spoke_idx].node.node_addr();
nodes[0]
.node
.send_encrypted_link_message(&spoke_addr, &plaintext)
.await
.expect("Failed to send disconnect");
}
// Process disconnects at all nodes
tokio::time::sleep(Duration::from_millis(50)).await;
process_available_packets(&mut nodes).await;
// All spokes should have removed the hub
for spoke_idx in 1..4 {
assert!(
nodes[spoke_idx].node.get_peer(&hub_addr).is_none(),
"Spoke {} should have removed hub",
spoke_idx
);
assert_eq!(
nodes[spoke_idx].node.peer_count(),
0,
"Spoke {} should have no peers (no spoke-spoke links)",
spoke_idx
);
assert!(
nodes[spoke_idx].node.tree_state().is_root(),
"Isolated spoke {} should become root",
spoke_idx
);
}
cleanup_nodes(&mut nodes).await;
}
/// 5-node chain: interior node departs, network splits into two components.
///
/// Chain: 0 -- 1 -- 2 -- 3 -- 4. Node 2 sends Disconnect to nodes 1 and 3.
/// Verifies:
/// - Peers removed correctly on both sides
/// - Bloom filters update so routing no longer bridges the partition
///
/// Note: Tree root reconvergence after partition is not tested here because
/// the tree protocol detects parent loss but not root unreachability. Nodes
/// whose parent is still connected may retain a stale root belief until the
/// root refresh timer fires. This is a known limitation of the current tree
/// protocol — bloom filter routing is the primary mechanism and it updates
/// immediately on peer removal.
#[tokio::test]
async fn test_disconnect_chain_partition() {
let edges = vec![(0, 1), (1, 2), (2, 3), (3, 4)];
let mut nodes = run_tree_test(5, &edges, false).await;
verify_tree_convergence(&nodes);
let node2_addr = *nodes[2].node.node_addr();
let node1_addr = *nodes[1].node.node_addr();
let node3_addr = *nodes[3].node.node_addr();
// Node 2 sends Disconnect to nodes 1 and 3
let disconnect = Disconnect::new(DisconnectReason::Shutdown);
let plaintext = disconnect.encode();
nodes[2]
.node
.send_encrypted_link_message(&node1_addr, &plaintext)
.await
.expect("Failed to send disconnect to node 1");
nodes[2]
.node
.send_encrypted_link_message(&node3_addr, &plaintext)
.await
.expect("Failed to send disconnect to node 3");
// Process disconnects and let filters reconverge
drain_all_packets(&mut nodes, false).await;
// Nodes 1 and 3 should have removed node 2
assert!(
nodes[1].node.get_peer(&node2_addr).is_none(),
"Node 1 should not have node 2 as peer"
);
assert!(
nodes[3].node.get_peer(&node2_addr).is_none(),
"Node 3 should not have node 2 as peer"
);
// Within each component, peers are still connected
let node0_addr = *nodes[0].node.node_addr();
let node4_addr = *nodes[4].node.node_addr();
assert!(
nodes[0].node.get_peer(&node1_addr).is_some(),
"Node 0 should still have node 1 as peer"
);
assert!(
nodes[3].node.get_peer(&node4_addr).is_some(),
"Node 3 should still have node 4 as peer"
);
// Bloom filter check: node 0 should NOT see node 4 as reachable
// (bloom filters update immediately on peer removal via split-horizon recomputation)
let node0_reaches_node4 = nodes[0]
.node
.peers()
.any(|peer| peer.may_reach(&node4_addr));
assert!(
!node0_reaches_node4,
"Node 0 should not see node 4 as reachable after partition"
);
// And vice versa
let node4_reaches_node0 = nodes[4]
.node
.peers()
.any(|peer| peer.may_reach(&node0_addr));
assert!(
!node4_reaches_node0,
"Node 4 should not see node 0 as reachable after partition"
);
// Nodes within the same component should still see each other
let node0_reaches_node1 = nodes[0]
.node
.peers()
.any(|peer| peer.may_reach(&node1_addr));
assert!(
node0_reaches_node1,
"Node 0 should still see node 1 as reachable"
);
let node4_reaches_node3 = nodes[4]
.node
.peers()
.any(|peer| peer.may_reach(&node3_addr));
assert!(
node4_reaches_node3,
"Node 4 should still see node 3 as reachable"
);
cleanup_nodes(&mut nodes).await;
}
/// Verify that different disconnect reasons are handled correctly.
///
/// Sends each reason code and verifies the peer is removed regardless.
#[tokio::test]
async fn test_disconnect_all_reason_codes() {
let reasons = vec![
DisconnectReason::Shutdown,
DisconnectReason::Restart,
DisconnectReason::ProtocolError,
DisconnectReason::TransportFailure,
DisconnectReason::ResourceExhaustion,
];
for reason in reasons {
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();
// Node 0 sends disconnect with this reason
let disconnect = Disconnect::new(reason);
let plaintext = disconnect.encode();
nodes[0]
.node
.send_encrypted_link_message(&node1_addr, &plaintext)
.await
.expect("Failed to send disconnect");
tokio::time::sleep(Duration::from_millis(50)).await;
process_available_packets(&mut nodes).await;
assert!(
nodes[1].node.get_peer(&node0_addr).is_none(),
"Node 1 should remove peer for reason {:?}",
reason
);
cleanup_nodes(&mut nodes).await;
}
}
+1
View File
@@ -5,6 +5,7 @@ use crate::PeerIdentity;
use std::time::Duration;
mod bloom;
mod disconnect;
mod handshake;
mod routing;
mod spanning_tree;
+289
View File
@@ -605,3 +605,292 @@ async fn test_routing_reachability_100_nodes() {
cleanup_nodes(&mut nodes).await;
}
// === Peer removal stops routing through removed peer ===
/// After removing a peer from a converged chain, routing to destinations
/// previously reachable through that peer should fail.
///
/// Chain: 0 -- 1 -- 2 -- 3. Remove node 2 from node 1's perspective.
/// Node 0 should no longer be able to route to node 3.
#[tokio::test]
async fn test_routing_stops_after_peer_removal() {
use crate::protocol::{Disconnect, DisconnectReason};
let edges = vec![(0, 1), (1, 2), (2, 3)];
let mut nodes = run_tree_test(4, &edges, false).await;
verify_tree_convergence(&nodes);
let _node0_addr = *nodes[0].node.node_addr();
let node1_addr = *nodes[1].node.node_addr();
let node2_addr = *nodes[2].node.node_addr();
let node3_addr = *nodes[3].node.node_addr();
// Inject coordinates so routing works before removal
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let all_coords: Vec<(NodeAddr, crate::tree::TreeCoordinate)> = nodes
.iter()
.map(|tn| (*tn.node.node_addr(), tn.node.tree_state().my_coords().clone()))
.collect();
for node in &mut nodes {
for &(ref addr, ref coords) in &all_coords {
if addr != node.node.node_addr() {
node.node.coord_cache_mut().insert(*addr, coords.clone(), now_ms);
}
}
}
// Verify routing works before removal: node 0 → node 3
let addr_index = build_addr_index(&nodes);
match simulate_forwarding(&nodes, &addr_index, 0, 3) {
ForwardResult::Delivered(_) => {}
other => panic!("Expected delivery before removal, got {:?}", other),
}
// Node 2 sends Disconnect to node 1
let disconnect = Disconnect::new(DisconnectReason::Shutdown);
let plaintext = disconnect.encode();
nodes[2]
.node
.send_encrypted_link_message(&node1_addr, &plaintext)
.await
.expect("Failed to send disconnect");
// Process disconnect and let bloom filters reconverge
drain_all_packets(&mut nodes, false).await;
// Verify node 1 removed node 2
assert!(
nodes[1].node.get_peer(&node2_addr).is_none(),
"Node 1 should have removed node 2"
);
// Bloom filter check: node 0's peer (node 1) should no longer
// advertise node 3 as reachable
let node0_reaches_node3 = nodes[0]
.node
.peers()
.any(|peer| peer.may_reach(&node3_addr));
assert!(
!node0_reaches_node3,
"Node 0 should not see node 3 as reachable after partition"
);
// Routing from node 0 to node 3 should now fail: no bloom filter hit.
// Greedy tree routing may still have stale coords cached, but without
// bloom filter hits, routing should stop at node 1 (which lost its
// peer to the other side). If stale coords exist, greedy routing could
// still attempt forwarding — but the self-distance check prevents loops.
// Either NoRoute or Loop-with-stale-coords is acceptable here; what
// matters is that delivery does NOT succeed.
match simulate_forwarding(&nodes, &addr_index, 0, 3) {
ForwardResult::NoRoute { .. } => {} // Expected: can't reach node 3
ForwardResult::Loop { .. } => {} // Also acceptable: stale coords cause loop detection
ForwardResult::Delivered(hops) => {
panic!("Should NOT deliver after partition, but got delivery in {} hops", hops);
}
}
// But routing within the same component still works: node 2 → node 3
match simulate_forwarding(&nodes, &addr_index, 2, 3) {
ForwardResult::Delivered(_) => {}
other => panic!("Expected delivery within component, got {:?}", other),
}
cleanup_nodes(&mut nodes).await;
}
// === Bloom-filter-only transit routing (no globally injected coords) ===
/// Verify that transit routers can forward using bloom filters alone.
///
/// In a converged network, only the SOURCE has the destination's coords
/// in its cache (simulating a real first-contact scenario where only the
/// source ran discovery). Transit routers have no cached coords for the
/// destination. Routing should still work because transit routers use
/// bloom filter hits to select next hops.
///
/// Chain: 0 -- 1 -- 2 -- 3. Only node 0 has node 3's coords cached.
/// Nodes 1 and 2 route using bloom filters only.
#[tokio::test]
async fn test_routing_bloom_only_transit() {
let edges = vec![(0, 1), (1, 2), (2, 3)];
let mut nodes = run_tree_test(4, &edges, false).await;
verify_tree_convergence(&nodes);
let node3_addr = *nodes[3].node.node_addr();
let node3_coords = nodes[3].node.tree_state().my_coords().clone();
// Only inject node 3's coords at node 0 (the source).
// Transit nodes (1, 2) have NO coords for node 3 in their caches.
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
nodes[0]
.node
.coord_cache_mut()
.insert(node3_addr, node3_coords, now_ms);
// Node 0 should find a next hop (bloom filter hit at peer node 1,
// with coords available for tie-breaking at the source)
let hop = nodes[0].node.find_next_hop(&node3_addr);
assert!(hop.is_some(), "Node 0 should route to node 3 (has coords)");
// Node 1 should also find a next hop using bloom filter alone.
// But wait — find_next_hop requires dest_coords to be cached when
// bloom filter hits exist (loop prevention). Node 1 has no coords
// for node 3, so it should return None.
let hop_at_1 = nodes[1].node.find_next_hop(&node3_addr);
// This is the key insight: bloom-filter-only transit routing does NOT
// work in the current implementation because find_next_hop gates bloom
// filter candidate selection on having cached dest_coords. Transit
// routers without coords return None, which is the correct behavior
// (prevents loops) but means the SessionSetup must carry coords to
// warm transit router caches before data packets can flow.
assert!(
hop_at_1.is_none(),
"Node 1 should NOT route without cached coords (loop prevention)"
);
// However, node 1 IS a direct peer of node 2, and node 2 IS a direct
// peer of node 3. The "direct peer" priority (step 2 in find_next_hop)
// would handle adjacency. Let's verify node 2 can route to its direct
// peer node 3.
let hop_at_2 = nodes[2].node.find_next_hop(&node3_addr);
assert!(
hop_at_2.is_some(),
"Node 2 should route to node 3 (direct peer)"
);
assert_eq!(
hop_at_2.unwrap().node_addr(),
&node3_addr,
"Node 2's next hop to node 3 should be node 3 itself"
);
cleanup_nodes(&mut nodes).await;
}
/// 100-node routing: verify that with coords cached ONLY at the source,
/// multi-hop forwarding still works because each transit node either has
/// the destination as a direct peer OR needs coords to break bloom filter
/// ties.
///
/// This test reveals the boundary: in a converged network, bloom filter
/// routing needs dest_coords at each hop for loop-free forwarding through
/// non-adjacent nodes. Direct peer adjacency handles the last hop.
#[tokio::test]
async fn test_routing_source_only_coords_100_nodes() {
const NUM_NODES: usize = 100;
const TARGET_EDGES: usize = 250;
const SEED: u64 = 42;
let edges = generate_random_edges(NUM_NODES, TARGET_EDGES, SEED);
let mut nodes = run_tree_test(NUM_NODES, &edges, false).await;
verify_tree_convergence(&nodes);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
// Collect all coords for injection
let all_coords: Vec<(NodeAddr, crate::tree::TreeCoordinate)> = nodes
.iter()
.map(|tn| (*tn.node.node_addr(), tn.node.tree_state().my_coords().clone()))
.collect();
let addr_index = build_addr_index(&nodes);
// Test: for each pair, inject dest coords ONLY at the source.
// Count how many pairs can be delivered vs fail.
let mut source_only_delivered = 0usize;
let mut source_only_failed = 0usize;
let mut total_pairs = 0usize;
// Test a sample of pairs (all pairs would be expensive)
let sample_pairs: Vec<(usize, usize)> = (0..NUM_NODES)
.step_by(10)
.flat_map(|s| {
(0..NUM_NODES)
.step_by(10)
.filter(move |&d| d != s)
.map(move |d| (s, d))
})
.collect();
for &(src, dst) in &sample_pairs {
total_pairs += 1;
// Clear ALL coord caches
for node in &mut nodes {
node.node.coord_cache_mut().clear();
}
// Inject dest coords ONLY at the source
let (dest_addr, dest_coords) = &all_coords[dst];
nodes[src]
.node
.coord_cache_mut()
.insert(*dest_addr, dest_coords.clone(), now_ms);
match simulate_forwarding(&nodes, &addr_index, src, dst) {
ForwardResult::Delivered(_) => source_only_delivered += 1,
ForwardResult::NoRoute { .. } => source_only_failed += 1,
ForwardResult::Loop { .. } => {
panic!("Routing loop detected with source-only coords: {} -> {}", src, dst);
}
}
}
eprintln!(
"\n === Source-Only Coords Routing ({} nodes) ===",
NUM_NODES
);
eprintln!(
" Pairs: {} | Delivered: {} | Failed: {} | Delivery rate: {:.1}%",
total_pairs,
source_only_delivered,
source_only_failed,
source_only_delivered as f64 / total_pairs as f64 * 100.0
);
// With source-only coords, only single-hop (direct peer) destinations
// are guaranteed to be delivered. Multi-hop destinations fail at the
// first transit node that doesn't have dest_coords cached. This
// confirms the protocol's design: SessionSetup MUST carry coords
// to warm transit router caches for multi-hop delivery.
assert!(
source_only_delivered > 0,
"At least some direct-peer pairs should be delivered"
);
// Now compare: inject coords at ALL nodes (full cache) and verify 100%
for node in &mut nodes {
for &(ref addr, ref coords) in &all_coords {
if addr != node.node.node_addr() {
node.node.coord_cache_mut().insert(*addr, coords.clone(), now_ms);
}
}
}
let mut full_cache_failures = 0usize;
for &(src, dst) in &sample_pairs {
match simulate_forwarding(&nodes, &addr_index, src, dst) {
ForwardResult::Delivered(_) => {}
_ => full_cache_failures += 1,
}
}
assert_eq!(
full_cache_failures, 0,
"With full coord caches, all pairs should be delivered"
);
cleanup_nodes(&mut nodes).await;
}