mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Add six forwarding counters that partition transit-forwarded packets by their tree relationship to the chosen next hop: tree-up (peer is our ancestor), tree-down (peer is our descendant and the destination is within its subtree), tree-down-cross (peer is our descendant but the destination is outside its subtree), cross-link descend (lateral peer, destination within its subtree), cross-link ascend (lateral peer, destination outside its subtree), and direct-peer. The six classes sum to forwarded_packets, asserted by a unit test. Classification is computed from tree coordinates at the transit chokepoint, so the error-signal routing callers are excluded. The two "outside the chosen peer's subtree" classes are both up-and-over forwards but differ in what they depend on. Tree-down-cross is the dive-to-tree-child cut-through: we forward down to our own child for a destination not beneath it, which is only possible because the child advertised cross-link reach upward to us, beyond its own subtree. Its count measures how much forwarding depends on that upward advertisement, i.e. what would change if cross-link advertisements were narrowed to subtree-entry only. Cross-link ascend, by contrast, uses the node's own lateral cross-link learned from a peer's split-horizon advertisement, so it does not depend on any upward advertisement. Surface the counters through the forwarding stats snapshot (control socket, show_routing and show_status) and reorganize the fipstop routing tab so its two columns separate own/endpoint traffic (received, delivered, originated) from forwarded/transit traffic (the route-class breakdown and drop reasons), with the tree-down-cross line visually flagged.
1336 lines
46 KiB
Rust
1336 lines
46 KiB
Rust
//! Routing integration tests.
|
|
//!
|
|
//! Tests the full Node::find_next_hop() routing logic including bloom
|
|
//! filter priority, greedy tree routing, and tie-breaking.
|
|
|
|
use super::*;
|
|
use crate::bloom::BloomFilter;
|
|
use crate::tree::{ParentDeclaration, TreeCoordinate};
|
|
use spanning_tree::{
|
|
TestNode, cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake,
|
|
lock_large_network_test, make_test_node, run_tree_test, verify_tree_convergence,
|
|
};
|
|
use std::collections::HashSet;
|
|
|
|
// === Local delivery ===
|
|
|
|
#[test]
|
|
fn test_routing_local_delivery() {
|
|
let mut node = make_node();
|
|
let my_addr = *node.node_addr();
|
|
assert!(node.find_next_hop(&my_addr).is_none());
|
|
}
|
|
|
|
// === Direct peer ===
|
|
|
|
#[test]
|
|
fn test_routing_direct_peer() {
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
let link_id = LinkId::new(1);
|
|
|
|
let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1000);
|
|
let peer_addr = *identity.node_addr();
|
|
node.add_connection(conn).unwrap();
|
|
node.promote_connection(link_id, identity, 2000).unwrap();
|
|
|
|
let result = node.find_next_hop(&peer_addr);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap().node_addr(), &peer_addr);
|
|
}
|
|
|
|
// === No route ===
|
|
|
|
#[test]
|
|
fn test_routing_unknown_destination() {
|
|
let mut node = make_node();
|
|
let unknown = make_node_addr(99);
|
|
assert!(node.find_next_hop(&unknown).is_none());
|
|
}
|
|
|
|
// === Bloom filter priority ===
|
|
|
|
#[test]
|
|
fn test_routing_bloom_filter_hit() {
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
let my_addr = *node.node_addr();
|
|
|
|
// Create two peers
|
|
let link_id1 = LinkId::new(1);
|
|
let (conn1, id1) = make_completed_connection(&mut node, link_id1, transport_id, 1000);
|
|
let peer1_addr = *id1.node_addr();
|
|
node.add_connection(conn1).unwrap();
|
|
node.promote_connection(link_id1, id1, 2000).unwrap();
|
|
|
|
let link_id2 = LinkId::new(2);
|
|
let (conn2, id2) = make_completed_connection(&mut node, link_id2, transport_id, 1000);
|
|
let peer2_addr = *id2.node_addr();
|
|
node.add_connection(conn2).unwrap();
|
|
node.promote_connection(link_id2, id2, 2000).unwrap();
|
|
|
|
// Set up tree: we are root, both peers are our children
|
|
let peer1_coords = TreeCoordinate::from_addrs(vec![peer1_addr, my_addr]).unwrap();
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(peer1_addr, my_addr, 1, 1000),
|
|
peer1_coords,
|
|
);
|
|
let peer2_coords = TreeCoordinate::from_addrs(vec![peer2_addr, my_addr]).unwrap();
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(peer2_addr, my_addr, 1, 1000),
|
|
peer2_coords,
|
|
);
|
|
|
|
// Destination not directly connected — placed under peer1 in the tree
|
|
let dest = make_node_addr(99);
|
|
let dest_coords = TreeCoordinate::from_addrs(vec![dest, peer1_addr, my_addr]).unwrap();
|
|
let now_ms = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
node.coord_cache_mut().insert(dest, dest_coords, now_ms);
|
|
|
|
// Add dest to peer1's bloom filter only
|
|
let peer1 = node.get_peer_mut(&peer1_addr).unwrap();
|
|
let mut filter = BloomFilter::new();
|
|
filter.insert(&dest);
|
|
peer1.update_filter(filter, 1, 3000);
|
|
|
|
// Should route through peer1 (bloom filter hit, closer to dest)
|
|
let result = node.find_next_hop(&dest);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap().node_addr(), &peer1_addr);
|
|
|
|
// Peer2 should NOT be selected (no filter hit)
|
|
assert_ne!(result.unwrap().node_addr(), &peer2_addr);
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_bloom_filter_multiple_hits_tiebreak() {
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
let my_addr = *node.node_addr();
|
|
|
|
// Create three peers
|
|
let mut peer_addrs = Vec::new();
|
|
for i in 1..=3 {
|
|
let link_id = LinkId::new(i);
|
|
let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000);
|
|
let addr = *id.node_addr();
|
|
peer_addrs.push(addr);
|
|
node.add_connection(conn).unwrap();
|
|
node.promote_connection(link_id, id, 2000).unwrap();
|
|
}
|
|
|
|
// Set up tree: we are root, all peers are our children (equidistant)
|
|
for &addr in &peer_addrs {
|
|
let coords = TreeCoordinate::from_addrs(vec![addr, my_addr]).unwrap();
|
|
node.tree_state_mut()
|
|
.update_peer(ParentDeclaration::new(addr, my_addr, 1, 1000), coords);
|
|
}
|
|
|
|
// Destination placed under the first peer (arbitrary — all peers are
|
|
// equidistant from dest since dest is 2 hops from root via any child)
|
|
let dest = make_node_addr(99);
|
|
let dest_coords = TreeCoordinate::from_addrs(vec![dest, peer_addrs[0], my_addr]).unwrap();
|
|
let now_ms = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
node.coord_cache_mut().insert(dest, dest_coords, now_ms);
|
|
|
|
// Add dest to ALL peers' bloom filters
|
|
for &addr in &peer_addrs {
|
|
let peer = node.get_peer_mut(&addr).unwrap();
|
|
let mut filter = BloomFilter::new();
|
|
filter.insert(&dest);
|
|
peer.update_filter(filter, 1, 3000);
|
|
}
|
|
|
|
// All peers have equal link_cost (1.0). peer_addrs[0] is closest to dest
|
|
// (distance 1 vs distance 3 for the others). Self-distance check filters
|
|
// peers that aren't strictly closer than us (our distance = 2).
|
|
// peer_addrs[0] has distance 1 (passes), others have distance 3 (filtered).
|
|
let result = node.find_next_hop(&dest);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap().node_addr(), &peer_addrs[0]);
|
|
}
|
|
|
|
// === Greedy tree routing ===
|
|
|
|
#[test]
|
|
fn test_routing_tree_fallback() {
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
let my_addr = *node.node_addr();
|
|
|
|
// Create a peer
|
|
let link_id = LinkId::new(1);
|
|
let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000);
|
|
let peer_addr = *id.node_addr();
|
|
node.add_connection(conn).unwrap();
|
|
node.promote_connection(link_id, id, 2000).unwrap();
|
|
|
|
// Set up tree state through the public API.
|
|
// We're root, peer is our child. The peer has a subtree below it.
|
|
// TreeState::new() already makes us the root with coords [my_addr].
|
|
// Add peer as child of us.
|
|
let peer_coords = TreeCoordinate::from_addrs(vec![peer_addr, my_addr]).unwrap();
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(peer_addr, my_addr, 1, 1000),
|
|
peer_coords,
|
|
);
|
|
|
|
// Destination: a node under our peer in the tree
|
|
let dest = make_node_addr(99);
|
|
let dest_coords = TreeCoordinate::from_addrs(vec![dest, peer_addr, my_addr]).unwrap();
|
|
|
|
// Put dest coords in the cache
|
|
let now_ms = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
node.coord_cache_mut().insert(dest, dest_coords, now_ms);
|
|
|
|
// No bloom filter hit — should fall back to tree routing.
|
|
// Our distance to dest: 2 (root → peer → dest)
|
|
// Peer's distance to dest: 1 (peer → dest)
|
|
// Peer is closer, so it's the next hop.
|
|
let result = node.find_next_hop(&dest);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap().node_addr(), &peer_addr);
|
|
}
|
|
|
|
/// Regression: bloom hit on a peer that is NOT strictly closer to dest
|
|
/// than we are must fall through to greedy tree routing rather than
|
|
/// returning None. Pinned by commit a859da7.
|
|
///
|
|
/// Pre-fix behavior: bloom candidates exist but `select_best_candidate`
|
|
/// rejects them all under the self-distance check (peer dist >= my dist),
|
|
/// and `find_next_hop` returned None — a NoRoute failure even though the
|
|
/// tree had a valid greedy next hop.
|
|
///
|
|
/// Post-fix behavior: same scenario falls through to greedy tree routing
|
|
/// and returns the tree-routing-selected next hop.
|
|
#[test]
|
|
fn test_routing_bloom_hit_not_closer_falls_through_to_tree() {
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
let my_addr = *node.node_addr();
|
|
|
|
// tree_peer: child of self, on the path to dest (greedy tree pick).
|
|
let tree_link = LinkId::new(1);
|
|
let (tree_conn, tree_id) = make_completed_connection(&mut node, tree_link, transport_id, 1000);
|
|
let tree_peer_addr = *tree_id.node_addr();
|
|
node.add_connection(tree_conn).unwrap();
|
|
node.promote_connection(tree_link, tree_id, 2000).unwrap();
|
|
|
|
// bloom_peer: also a child of self, but with a stale/false-positive
|
|
// bloom hit for dest. Its tree distance to dest is NOT closer than
|
|
// ours, so the self-distance check in select_best_candidate excludes
|
|
// it — leaving zero viable bloom candidates.
|
|
let bloom_link = LinkId::new(2);
|
|
let (bloom_conn, bloom_id) =
|
|
make_completed_connection(&mut node, bloom_link, transport_id, 1000);
|
|
let bloom_peer_addr = *bloom_id.node_addr();
|
|
node.add_connection(bloom_conn).unwrap();
|
|
node.promote_connection(bloom_link, bloom_id, 2000).unwrap();
|
|
|
|
// Tree topology (we are root):
|
|
// self ── tree_peer ── dest
|
|
// └──── bloom_peer
|
|
//
|
|
// Distances to dest:
|
|
// self : 2 (root → tree_peer → dest)
|
|
// tree_peer : 1 (tree_peer → dest) ← greedy winner
|
|
// bloom_peer : 3 (bloom_peer → root → tree_peer → dest) ← NOT closer than self
|
|
let tree_peer_coords = TreeCoordinate::from_addrs(vec![tree_peer_addr, my_addr]).unwrap();
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(tree_peer_addr, my_addr, 1, 1000),
|
|
tree_peer_coords,
|
|
);
|
|
let bloom_peer_coords = TreeCoordinate::from_addrs(vec![bloom_peer_addr, my_addr]).unwrap();
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(bloom_peer_addr, my_addr, 1, 1000),
|
|
bloom_peer_coords,
|
|
);
|
|
|
|
// Destination is a child of tree_peer in the tree.
|
|
let dest = make_node_addr(99);
|
|
let dest_coords = TreeCoordinate::from_addrs(vec![dest, tree_peer_addr, my_addr]).unwrap();
|
|
let now_ms = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
node.coord_cache_mut().insert(dest, dest_coords, now_ms);
|
|
|
|
// dest is in bloom_peer's filter only (the "bloom hit" candidate),
|
|
// but bloom_peer's tree distance (3) is NOT strictly less than our
|
|
// distance (2), so select_best_candidate yields no winner.
|
|
// tree_peer has NO bloom entry for dest.
|
|
let bloom_peer = node.get_peer_mut(&bloom_peer_addr).unwrap();
|
|
let mut filter = BloomFilter::new();
|
|
filter.insert(&dest);
|
|
bloom_peer.update_filter(filter, 1, 3000);
|
|
|
|
// Pre-fix this returned None. Post-fix it falls through to greedy
|
|
// tree routing and picks tree_peer (distance 1 < self distance 2).
|
|
let result = node.find_next_hop(&dest);
|
|
assert!(
|
|
result.is_some(),
|
|
"find_next_hop must fall through to tree routing when bloom \
|
|
candidates exist but none are strictly closer than self"
|
|
);
|
|
let next_hop = result.unwrap().node_addr();
|
|
assert_eq!(
|
|
next_hop, &tree_peer_addr,
|
|
"tree-routing winner expected (tree_peer), got {:?}",
|
|
next_hop,
|
|
);
|
|
assert_ne!(
|
|
next_hop, &bloom_peer_addr,
|
|
"bloom_peer must be excluded by the self-distance check",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_routing_tree_no_coords_in_cache() {
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
|
|
// Create a peer
|
|
let link_id = LinkId::new(1);
|
|
let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000);
|
|
node.add_connection(conn).unwrap();
|
|
node.promote_connection(link_id, id, 2000).unwrap();
|
|
|
|
// Destination not in bloom filters and not in coord cache
|
|
let dest = make_node_addr(99);
|
|
assert!(node.find_next_hop(&dest).is_none());
|
|
}
|
|
|
|
// === Active routing refreshes coord_cache TTL ===
|
|
|
|
#[test]
|
|
fn test_routing_refreshes_coord_cache_ttl() {
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
let my_addr = *node.node_addr();
|
|
|
|
// Create a peer
|
|
let link_id = LinkId::new(1);
|
|
let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000);
|
|
let peer_addr = *id.node_addr();
|
|
node.add_connection(conn).unwrap();
|
|
node.promote_connection(link_id, id, 2000).unwrap();
|
|
|
|
// Set up tree coordinates
|
|
let dest = make_node_addr(99);
|
|
let dest_coords = TreeCoordinate::from_addrs(vec![dest, peer_addr, my_addr]).unwrap();
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(peer_addr, my_addr, 1, 1000),
|
|
TreeCoordinate::from_addrs(vec![peer_addr, my_addr]).unwrap(),
|
|
);
|
|
|
|
// Insert with a short TTL (10s) — enough to survive until find_next_hop runs
|
|
let now_ms = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
let short_ttl = 10_000; // 10 seconds
|
|
node.coord_cache_mut()
|
|
.insert_with_ttl(dest, dest_coords, now_ms, short_ttl);
|
|
let original_expiry = node.coord_cache().get_entry(&dest).unwrap().expires_at();
|
|
|
|
// find_next_hop should succeed and refresh TTL to now + default_ttl (300s)
|
|
assert!(node.find_next_hop(&dest).is_some());
|
|
|
|
// The refresh should have extended expires_at beyond the original
|
|
let new_expiry = node.coord_cache().get_entry(&dest).unwrap().expires_at();
|
|
assert!(
|
|
new_expiry > original_expiry,
|
|
"find_next_hop should refresh the coord_cache TTL: original={}, new={}",
|
|
original_expiry,
|
|
new_expiry,
|
|
);
|
|
}
|
|
|
|
// === Bloom filter without coords → no route (loop prevention) ===
|
|
|
|
#[test]
|
|
fn test_routing_bloom_hit_without_coords_returns_none() {
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
|
|
// Create two peers
|
|
let link_id1 = LinkId::new(1);
|
|
let (conn1, id1) = make_completed_connection(&mut node, link_id1, transport_id, 1000);
|
|
let peer1_addr = *id1.node_addr();
|
|
node.add_connection(conn1).unwrap();
|
|
node.promote_connection(link_id1, id1, 2000).unwrap();
|
|
|
|
let link_id2 = LinkId::new(2);
|
|
let (conn2, id2) = make_completed_connection(&mut node, link_id2, transport_id, 1000);
|
|
let peer2_addr = *id2.node_addr();
|
|
node.add_connection(conn2).unwrap();
|
|
node.promote_connection(link_id2, id2, 2000).unwrap();
|
|
|
|
let dest = make_node_addr(99);
|
|
|
|
// Add dest to BOTH peers' bloom filters
|
|
for &addr in &[peer1_addr, peer2_addr] {
|
|
let peer = node.get_peer_mut(&addr).unwrap();
|
|
let mut filter = BloomFilter::new();
|
|
filter.insert(&dest);
|
|
peer.update_filter(filter, 1, 3000);
|
|
}
|
|
|
|
// Bloom filter candidates exist, but dest coords are NOT cached.
|
|
// find_next_hop must return None to prevent routing loops.
|
|
// The caller should signal CoordsRequired back to the source.
|
|
assert!(node.find_next_hop(&dest).is_none());
|
|
}
|
|
|
|
// === Discovery-populated coord_cache ===
|
|
|
|
#[test]
|
|
fn test_routing_discovery_coord_cache() {
|
|
// Verify that find_next_hop() uses coord_cache entries populated by
|
|
// discovery. initiate_lookup() populates coord_cache, and
|
|
// find_next_hop() consults it.
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
let my_addr = *node.node_addr();
|
|
|
|
// Create a peer
|
|
let link_id = LinkId::new(1);
|
|
let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000);
|
|
let peer_addr = *id.node_addr();
|
|
node.add_connection(conn).unwrap();
|
|
node.promote_connection(link_id, id, 2000).unwrap();
|
|
|
|
// Set up tree: we are root, peer is our child
|
|
let peer_coords = TreeCoordinate::from_addrs(vec![peer_addr, my_addr]).unwrap();
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(peer_addr, my_addr, 1, 1000),
|
|
peer_coords,
|
|
);
|
|
|
|
// Create a destination "behind" the peer in the tree
|
|
let dest = make_node_addr(99);
|
|
let dest_coords = TreeCoordinate::from_addrs(vec![dest, peer_addr, my_addr]).unwrap();
|
|
|
|
// Put dest in peer's bloom filter so there's a candidate
|
|
let peer = node.get_peer_mut(&peer_addr).unwrap();
|
|
let mut filter = BloomFilter::new();
|
|
filter.insert(&dest);
|
|
peer.update_filter(filter, 1, 3000);
|
|
|
|
// Verify: coord_cache has nothing for dest
|
|
let now_ms = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
assert!(node.coord_cache().get(&dest, now_ms).is_none());
|
|
|
|
// Without coord_cache entry, should return None
|
|
assert!(node.find_next_hop(&dest).is_none());
|
|
|
|
// Now populate coord_cache (as discovery would do)
|
|
node.coord_cache_mut().insert(dest, dest_coords, now_ms);
|
|
|
|
// find_next_hop should succeed via coord_cache
|
|
let result = node.find_next_hop(&dest);
|
|
assert!(result.is_some(), "Should route via coord_cache");
|
|
assert_eq!(
|
|
result.unwrap().node_addr(),
|
|
&peer_addr,
|
|
"Should pick peer with bloom filter hit"
|
|
);
|
|
}
|
|
|
|
// === Integration: converged network ===
|
|
|
|
#[tokio::test]
|
|
async fn test_routing_chain_topology() {
|
|
// Build a 4-node chain: 0 -- 1 -- 2 -- 3
|
|
let mut nodes = vec![
|
|
make_test_node().await,
|
|
make_test_node().await,
|
|
make_test_node().await,
|
|
make_test_node().await,
|
|
];
|
|
|
|
// Connect the chain
|
|
initiate_handshake(&mut nodes, 0, 1).await;
|
|
initiate_handshake(&mut nodes, 1, 2).await;
|
|
initiate_handshake(&mut nodes, 2, 3).await;
|
|
|
|
// Converge tree and bloom filters
|
|
drain_all_packets(&mut nodes, false).await;
|
|
|
|
// Verify tree convergence
|
|
let root = nodes.iter().map(|n| *n.node.node_addr()).min().unwrap();
|
|
for tn in &nodes {
|
|
assert_eq!(*tn.node.tree_state().root(), root, "Tree not converged");
|
|
}
|
|
|
|
// Populate coord caches: each node caches the far-end node's coords
|
|
let now_ms = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
|
|
let node3_addr = *nodes[3].node.node_addr();
|
|
let node3_coords = nodes[3].node.tree_state().my_coords().clone();
|
|
nodes[0]
|
|
.node
|
|
.coord_cache_mut()
|
|
.insert(node3_addr, node3_coords, now_ms);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node0_coords = nodes[0].node.tree_state().my_coords().clone();
|
|
nodes[3]
|
|
.node
|
|
.coord_cache_mut()
|
|
.insert(node0_addr, node0_coords, now_ms);
|
|
|
|
// Node 0 should be able to route toward node 3.
|
|
// The next hop should be node 1 (only peer of node 0).
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node2_addr = *nodes[2].node.node_addr();
|
|
let hop = nodes[0].node.find_next_hop(&node3_addr);
|
|
assert!(hop.is_some(), "Node 0 should find route to node 3");
|
|
assert_eq!(
|
|
hop.unwrap().node_addr(),
|
|
&node1_addr,
|
|
"Node 0's next hop to node 3 should be node 1"
|
|
);
|
|
|
|
// Node 3 should route toward node 0 via node 2.
|
|
let hop = nodes[3].node.find_next_hop(&node0_addr);
|
|
assert!(hop.is_some(), "Node 3 should find route to node 0");
|
|
assert_eq!(
|
|
hop.unwrap().node_addr(),
|
|
&node2_addr,
|
|
"Node 3's next hop to node 0 should be node 2"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_routing_bloom_preferred_over_tree() {
|
|
// Build a 3-node triangle: 0 -- 1, 0 -- 2, 1 -- 2
|
|
let mut nodes = vec![
|
|
make_test_node().await,
|
|
make_test_node().await,
|
|
make_test_node().await,
|
|
];
|
|
|
|
initiate_handshake(&mut nodes, 0, 1).await;
|
|
initiate_handshake(&mut nodes, 0, 2).await;
|
|
initiate_handshake(&mut nodes, 1, 2).await;
|
|
|
|
drain_all_packets(&mut nodes, false).await;
|
|
|
|
// Create a destination beyond the network and cache its coords.
|
|
// Place dest as a child of peer2 in the converged tree so bloom
|
|
// filter routing selects peer2 (strictly closer to dest than us).
|
|
let dest = make_node_addr(99);
|
|
let peer2_addr = *nodes[2].node.node_addr();
|
|
let mut dest_path: Vec<NodeAddr> = nodes[2]
|
|
.node
|
|
.tree_state()
|
|
.my_coords()
|
|
.node_addrs()
|
|
.copied()
|
|
.collect();
|
|
dest_path.insert(0, dest);
|
|
let dest_coords = TreeCoordinate::from_addrs(dest_path).unwrap();
|
|
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(dest, dest_coords, now_ms);
|
|
|
|
// Add dest to peer 2's bloom filter (from node 0's perspective)
|
|
let peer2 = nodes[0].node.get_peer_mut(&peer2_addr).unwrap();
|
|
let mut filter = BloomFilter::new();
|
|
filter.insert(&dest);
|
|
peer2.update_filter(filter, 100, 50000);
|
|
|
|
// Bloom filter hit with cached coords should route via peer 2.
|
|
let hop = nodes[0].node.find_next_hop(&dest);
|
|
assert!(hop.is_some(), "Should route via bloom filter");
|
|
assert_eq!(
|
|
hop.unwrap().node_addr(),
|
|
&peer2_addr,
|
|
"Should pick peer with bloom filter hit"
|
|
);
|
|
}
|
|
|
|
// === Multi-hop forwarding simulation ===
|
|
|
|
/// Result of simulating multi-hop packet forwarding.
|
|
#[derive(Debug)]
|
|
enum ForwardResult {
|
|
/// Packet reached the destination in the given number of hops.
|
|
Delivered(usize),
|
|
/// Routing returned None at the given node index (no route).
|
|
NoRoute { at_node: usize, hops: usize },
|
|
/// Routing loop detected (visited the same node twice).
|
|
Loop { at_node: usize, hops: usize },
|
|
}
|
|
|
|
/// Build a NodeAddr → node index lookup table.
|
|
fn build_addr_index(nodes: &[TestNode]) -> std::collections::HashMap<NodeAddr, usize> {
|
|
nodes
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, tn)| (*tn.node.node_addr(), i))
|
|
.collect()
|
|
}
|
|
|
|
/// Simulate multi-hop forwarding from source to destination.
|
|
///
|
|
/// At each hop, calls `find_next_hop` on the current node and follows
|
|
/// the result to the next node. Terminates on delivery, routing failure,
|
|
/// or loop detection.
|
|
fn simulate_forwarding(
|
|
nodes: &mut [TestNode],
|
|
addr_index: &std::collections::HashMap<NodeAddr, usize>,
|
|
src: usize,
|
|
dst: usize,
|
|
) -> ForwardResult {
|
|
let dest_addr = *nodes[dst].node.node_addr();
|
|
let max_hops = nodes.len(); // can't take more hops than nodes
|
|
|
|
let mut current = src;
|
|
let mut visited = HashSet::new();
|
|
visited.insert(current);
|
|
|
|
for hop in 0..max_hops {
|
|
let next = nodes[current].node.find_next_hop(&dest_addr);
|
|
|
|
match next {
|
|
None => {
|
|
// find_next_hop returns None for local delivery (dest == self)
|
|
if *nodes[current].node.node_addr() == dest_addr {
|
|
return ForwardResult::Delivered(hop);
|
|
}
|
|
return ForwardResult::NoRoute {
|
|
at_node: current,
|
|
hops: hop,
|
|
};
|
|
}
|
|
Some(peer) => {
|
|
let next_addr = *peer.node_addr();
|
|
|
|
// Is next hop the destination?
|
|
if next_addr == dest_addr {
|
|
return ForwardResult::Delivered(hop + 1);
|
|
}
|
|
|
|
// Find the node index for the next hop
|
|
let next_idx = match addr_index.get(&next_addr) {
|
|
Some(&idx) => idx,
|
|
None => {
|
|
return ForwardResult::NoRoute {
|
|
at_node: current,
|
|
hops: hop,
|
|
};
|
|
}
|
|
};
|
|
|
|
// Loop detection
|
|
if visited.contains(&next_idx) {
|
|
return ForwardResult::Loop {
|
|
at_node: next_idx,
|
|
hops: hop + 1,
|
|
};
|
|
}
|
|
|
|
visited.insert(next_idx);
|
|
current = next_idx;
|
|
}
|
|
}
|
|
}
|
|
|
|
ForwardResult::NoRoute {
|
|
at_node: current,
|
|
hops: max_hops,
|
|
}
|
|
}
|
|
|
|
/// 100-node random graph: verify all-pairs routing reachability.
|
|
///
|
|
/// After tree and bloom filter convergence, simulates multi-hop packet
|
|
/// forwarding between every pair of nodes. Every packet must be delivered
|
|
/// without loops.
|
|
#[tokio::test]
|
|
async fn test_routing_reachability_100_nodes() {
|
|
let _guard = lock_large_network_test().await;
|
|
|
|
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);
|
|
|
|
// Populate coord caches: every node learns every other node's coordinates.
|
|
// In production this happens via SessionSetup/LookupResponse; here we
|
|
// inject them directly. Bloom filter routing requires cached dest_coords
|
|
// for loop-free forwarding — without coords, find_next_hop returns None.
|
|
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 (addr, coords) pairs first to avoid borrow issues
|
|
let all_coords: Vec<(NodeAddr, TreeCoordinate)> = nodes
|
|
.iter()
|
|
.map(|tn| {
|
|
(
|
|
*tn.node.node_addr(),
|
|
tn.node.tree_state().my_coords().clone(),
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
for node in &mut nodes {
|
|
for (addr, coords) in &all_coords {
|
|
if addr != node.node.node_addr() {
|
|
node.node
|
|
.coord_cache_mut()
|
|
.insert(*addr, coords.clone(), now_ms);
|
|
}
|
|
}
|
|
}
|
|
|
|
let addr_index = build_addr_index(&nodes);
|
|
|
|
let mut total_pairs = 0;
|
|
let mut total_hops = 0usize;
|
|
let mut max_hops = 0usize;
|
|
let mut failures = Vec::new();
|
|
let mut loops = Vec::new();
|
|
|
|
// Test all pairs
|
|
for src in 0..NUM_NODES {
|
|
for dst in 0..NUM_NODES {
|
|
if src == dst {
|
|
continue;
|
|
}
|
|
|
|
total_pairs += 1;
|
|
|
|
match simulate_forwarding(&mut nodes, &addr_index, src, dst) {
|
|
ForwardResult::Delivered(hops) => {
|
|
total_hops += hops;
|
|
if hops > max_hops {
|
|
max_hops = hops;
|
|
}
|
|
}
|
|
ForwardResult::NoRoute { at_node, hops } => {
|
|
failures.push((src, dst, at_node, hops));
|
|
}
|
|
ForwardResult::Loop { at_node, hops } => {
|
|
loops.push((src, dst, at_node, hops));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let delivered = total_pairs - failures.len() - loops.len();
|
|
let avg_hops = if delivered > 0 {
|
|
total_hops as f64 / delivered as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
eprintln!("\n === Routing Reachability ({} nodes) ===", NUM_NODES);
|
|
eprintln!(
|
|
" Pairs tested: {} | Delivered: {} | Failed: {} | Loops: {}",
|
|
total_pairs,
|
|
delivered,
|
|
failures.len(),
|
|
loops.len()
|
|
);
|
|
eprintln!(" Hops: avg={:.1} max={}", avg_hops, max_hops);
|
|
|
|
if !failures.is_empty() {
|
|
let show = failures.len().min(10);
|
|
eprintln!(" First {} failures:", show);
|
|
for &(src, dst, at_node, hops) in &failures[..show] {
|
|
eprintln!(
|
|
" {} -> {}: stuck at node {} after {} hops",
|
|
src, dst, at_node, hops
|
|
);
|
|
}
|
|
}
|
|
|
|
if !loops.is_empty() {
|
|
let show = loops.len().min(10);
|
|
eprintln!(" First {} loops:", show);
|
|
for &(src, dst, at_node, hops) in &loops[..show] {
|
|
eprintln!(
|
|
" {} -> {}: loop at node {} after {} hops",
|
|
src, dst, at_node, hops
|
|
);
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
loops.is_empty(),
|
|
"Detected {} routing loops out of {} pairs",
|
|
loops.len(),
|
|
total_pairs
|
|
);
|
|
assert!(
|
|
failures.is_empty(),
|
|
"Detected {} routing failures out of {} pairs",
|
|
failures.len(),
|
|
total_pairs
|
|
);
|
|
|
|
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 (addr, 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(&mut 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(&mut 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(&mut 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() {
|
|
let _guard = lock_large_network_test().await;
|
|
|
|
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(&mut 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 (addr, 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(&mut 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;
|
|
}
|
|
|
|
// === Route-class classification (transit-forward partition) ===
|
|
|
|
use crate::node::metrics::{ForwardingMetrics, RouteClass};
|
|
|
|
/// Current epoch millis, matching the cache-insert idiom used above.
|
|
fn now_ms() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_forward_tree_up() {
|
|
// my_coords = [me, parent, root]; the chosen peer is our parent (an
|
|
// ancestor in our path) → tree-up.
|
|
let mut node = make_node();
|
|
let me = *node.node_addr();
|
|
let parent = make_node_addr(10);
|
|
let root = make_node_addr(1);
|
|
node.tree_state_mut()
|
|
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, parent, root]).unwrap());
|
|
|
|
// Destination somewhere above us; routed via the parent.
|
|
let dest = make_node_addr(50);
|
|
node.coord_cache_mut().insert(
|
|
dest,
|
|
TreeCoordinate::from_addrs(vec![dest, root]).unwrap(),
|
|
now_ms(),
|
|
);
|
|
|
|
assert_eq!(
|
|
node.classify_forward(&dest, &parent),
|
|
RouteClass::TreeUp,
|
|
"chosen peer is our ancestor"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_forward_tree_down() {
|
|
// Chosen peer is our descendant: its coords name us as an ancestor.
|
|
let mut node = make_node();
|
|
let me = *node.node_addr();
|
|
let root = make_node_addr(1);
|
|
node.tree_state_mut()
|
|
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
|
|
|
|
let child = make_node_addr(20);
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(child, me, 1, 1000),
|
|
TreeCoordinate::from_addrs(vec![child, me, root]).unwrap(),
|
|
);
|
|
|
|
// Destination below the child; routed down to it.
|
|
let dest = make_node_addr(60);
|
|
node.coord_cache_mut().insert(
|
|
dest,
|
|
TreeCoordinate::from_addrs(vec![dest, child, me, root]).unwrap(),
|
|
now_ms(),
|
|
);
|
|
|
|
assert_eq!(
|
|
node.classify_forward(&dest, &child),
|
|
RouteClass::TreeDown,
|
|
"chosen peer is our descendant, dest in its subtree"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_forward_tree_down_cross() {
|
|
// Chosen peer is our descendant (a tree child), but the destination is NOT
|
|
// in that child's subtree: we are diving down to the child only because it
|
|
// advertised cross-link reach upward, beyond its own subtree. This is the
|
|
// dive-to-tree-child cut-through.
|
|
let mut node = make_node();
|
|
let me = *node.node_addr();
|
|
let root = make_node_addr(1);
|
|
node.tree_state_mut()
|
|
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
|
|
|
|
let child = make_node_addr(20);
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(child, me, 1, 1000),
|
|
TreeCoordinate::from_addrs(vec![child, me, root]).unwrap(),
|
|
);
|
|
|
|
// Destination lives elsewhere (directly under root), NOT under the child;
|
|
// reachable from the child only via a cross-link.
|
|
let dest = make_node_addr(60);
|
|
node.coord_cache_mut().insert(
|
|
dest,
|
|
TreeCoordinate::from_addrs(vec![dest, root]).unwrap(),
|
|
now_ms(),
|
|
);
|
|
|
|
assert_eq!(
|
|
node.classify_forward(&dest, &child),
|
|
RouteClass::TreeDownCross,
|
|
"descendant peer, dest not in its subtree (dive-to-tree-child cut-through)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_forward_crosslink_descend() {
|
|
// Chosen peer is lateral (not in our path, we are not in its path) and the
|
|
// destination is inside the peer's subtree → cross-link descend.
|
|
let mut node = make_node();
|
|
let me = *node.node_addr();
|
|
let root = make_node_addr(1);
|
|
let sibling_parent = make_node_addr(2);
|
|
node.tree_state_mut()
|
|
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
|
|
|
|
let peer = make_node_addr(30);
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(peer, sibling_parent, 1, 1000),
|
|
TreeCoordinate::from_addrs(vec![peer, sibling_parent, root]).unwrap(),
|
|
);
|
|
|
|
// Destination is under the cross-link peer.
|
|
let dest = make_node_addr(70);
|
|
node.coord_cache_mut().insert(
|
|
dest,
|
|
TreeCoordinate::from_addrs(vec![dest, peer, sibling_parent, root]).unwrap(),
|
|
now_ms(),
|
|
);
|
|
|
|
assert_eq!(
|
|
node.classify_forward(&dest, &peer),
|
|
RouteClass::CrosslinkDescend,
|
|
"lateral peer, dest in its subtree"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_forward_crosslink_ascend() {
|
|
// Chosen peer is lateral and the destination is NOT in its subtree → the
|
|
// up-and-over case (the Bloom v2 behavior delta).
|
|
let mut node = make_node();
|
|
let me = *node.node_addr();
|
|
let root = make_node_addr(1);
|
|
let sibling_parent = make_node_addr(2);
|
|
node.tree_state_mut()
|
|
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
|
|
|
|
let peer = make_node_addr(40);
|
|
node.tree_state_mut().update_peer(
|
|
ParentDeclaration::new(peer, sibling_parent, 1, 1000),
|
|
TreeCoordinate::from_addrs(vec![peer, sibling_parent, root]).unwrap(),
|
|
);
|
|
|
|
// Destination lives elsewhere (under root directly), NOT under the peer.
|
|
let dest = make_node_addr(80);
|
|
node.coord_cache_mut().insert(
|
|
dest,
|
|
TreeCoordinate::from_addrs(vec![dest, root]).unwrap(),
|
|
now_ms(),
|
|
);
|
|
|
|
assert_eq!(
|
|
node.classify_forward(&dest, &peer),
|
|
RouteClass::CrosslinkAscend,
|
|
"lateral peer, dest not in its subtree"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_forward_direct_peer() {
|
|
// Degenerate case: the next hop is the destination itself.
|
|
let mut node = make_node();
|
|
let me = *node.node_addr();
|
|
let root = make_node_addr(1);
|
|
node.tree_state_mut()
|
|
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
|
|
|
|
let dest = make_node_addr(90);
|
|
assert_eq!(
|
|
node.classify_forward(&dest, &dest),
|
|
RouteClass::DirectPeer,
|
|
"next hop is the destination"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_route_class_partition_sums_to_forwarded() {
|
|
// The six route classes partition forwarded_packets: bumping
|
|
// record_forwarded once per record_route_class keeps the sum of the class
|
|
// counters equal to forwarded_packets.
|
|
let m = ForwardingMetrics::default();
|
|
let classes = [
|
|
RouteClass::TreeUp,
|
|
RouteClass::TreeUp,
|
|
RouteClass::TreeDown,
|
|
RouteClass::TreeDownCross,
|
|
RouteClass::TreeDownCross,
|
|
RouteClass::CrosslinkDescend,
|
|
RouteClass::CrosslinkAscend,
|
|
RouteClass::CrosslinkAscend,
|
|
RouteClass::CrosslinkAscend,
|
|
RouteClass::DirectPeer,
|
|
];
|
|
for &c in &classes {
|
|
m.record_forwarded(100);
|
|
m.record_route_class(c);
|
|
}
|
|
|
|
let snap = m.snapshot();
|
|
let class_sum = snap.route_tree_up
|
|
+ snap.route_tree_down
|
|
+ snap.route_tree_down_cross
|
|
+ snap.route_crosslink_descend
|
|
+ snap.route_crosslink_ascend
|
|
+ snap.route_direct_peer;
|
|
assert_eq!(
|
|
class_sum, snap.forwarded_packets,
|
|
"route classes must partition forwarded_packets"
|
|
);
|
|
assert_eq!(snap.route_tree_up, 2);
|
|
assert_eq!(snap.route_tree_down_cross, 2);
|
|
assert_eq!(snap.route_crosslink_ascend, 3);
|
|
assert_eq!(snap.route_direct_peer, 1);
|
|
}
|