diff --git a/src/bin/fipstop/ui/routing.rs b/src/bin/fipstop/ui/routing.rs index 2ab57de..d03cbab 100644 --- a/src/bin/fipstop/ui/routing.rs +++ b/src/bin/fipstop/ui/routing.rs @@ -83,6 +83,35 @@ fn fwd_value(data: &serde_json::Value, pkt_key: &str, byte_key: &str) -> String format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)) } +/// Read a raw forwarding counter as a u64 (0 if missing), for arithmetic +/// (percentages, derived totals) that the string-returning helpers can't do. +fn fwd_count(data: &serde_json::Value, key: &str) -> u64 { + data.get("forwarding") + .and_then(|f| f.get(key)) + .and_then(|v| v.as_u64()) + .unwrap_or(0) +} + +/// Total mesh egress = locally-originated + transit-forwarded, formatted as +/// "N pkts (B)". There is no single daemon counter for everything this node +/// transmits to peers, so it is derived from its two contributors. +fn mesh_tx_value(data: &serde_json::Value) -> String { + let pkts = fwd_count(data, "originated_packets") + fwd_count(data, "forwarded_packets"); + let bytes = fwd_count(data, "originated_bytes") + fwd_count(data, "forwarded_bytes"); + format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)) +} + +/// Format a route-class count as "N (xx.x%)" where the percentage is the class's +/// share of total forwarded (transit) packets. Zero forwarded yields "0.0%". +fn route_class_value(count: u64, total_forwarded: u64) -> String { + let pct = if total_forwarded > 0 { + count as f64 / total_forwarded as f64 * 100.0 + } else { + 0.0 + }; + format!("{count} ({pct:.1}%)") +} + /// Build a section: a styled header line followed by the kv pairs rendered /// through the group helper so the section's values share a left edge. fn section(title: &str, pairs: &[(&str, String)]) -> Vec> { @@ -110,49 +139,39 @@ fn draw_routing_stats( let err = |key: &str| helpers::nested_u64(data, "error_signals", key); let cong = |key: &str| helpers::nested_u64(data, "congestion", key); - // Left column: Forwarding + Discovery. Each section's values share a left - // edge via the kv_lines group helper. + // The node is an interface adapter between the local host stack and the + // mesh; the left column reads each side as a Transmitted/Received pair. + // + // Local Stack — traffic crossing the TUN / local-origination boundary: + // Transmitted is what the host injects into the mesh (originated), Received + // is what the mesh hands up to the host (delivered). let mut left = section( - "Forwarding", + "Local Stack", &[ + ( + "Transmitted", + fwd_value(data, "originated_packets", "originated_bytes"), + ), + ( + "Received", + fwd_value(data, "delivered_packets", "delivered_bytes"), + ), + ], + ); + left.push(Line::from("")); + // Mesh — traffic crossing the peer-link boundary: Transmitted is everything + // this node puts on the wire (originated + forwarded, derived), Received is + // the ingress aggregate from peers (own-delivered + transit + drops). + left.extend(section( + "Mesh", + &[ + ("Transmitted", mesh_tx_value(data)), ( "Received", fwd_value(data, "received_packets", "received_bytes"), ), - ( - "Delivered", - fwd_value(data, "delivered_packets", "delivered_bytes"), - ), - ( - "Forwarded", - fwd_value(data, "forwarded_packets", "forwarded_bytes"), - ), - ( - "Originated", - fwd_value(data, "originated_packets", "originated_bytes"), - ), - ( - "Decode Error", - fwd_value(data, "decode_error_packets", "decode_error_bytes"), - ), - ( - "TTL Exhausted", - fwd_value(data, "ttl_exhausted_packets", "ttl_exhausted_bytes"), - ), - ( - "No Route", - fwd_value(data, "drop_no_route_packets", "drop_no_route_bytes"), - ), - ( - "MTU Exceeded", - fwd_value(data, "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"), - ), - ( - "Send Error", - fwd_value(data, "drop_send_error_packets", "drop_send_error_bytes"), - ), ], - ); + )); left.push(Line::from("")); left.extend(section( "Discovery Requests", @@ -184,15 +203,89 @@ fn draw_routing_stats( ], )); - // Right column: Error Signals + Congestion + // Right column — "Forwarded" (transit / routed through this node). + // Forwarded total, then the route-class breakdown (a percentage partition + // of the total), then the transit-path drop reasons. + let fwd_total = fwd_count(data, "forwarded_packets"); let mut right = section( + "Forwarded", + &[( + "Forwarded", + fwd_value(data, "forwarded_packets", "forwarded_bytes"), + )], + ); + // Blank separator after the Forwarded total, matching the spacing between + // every other section pair; the total and its route-class breakdown read + // as two distinct groups. + right.push(Line::from("")); + // Route-class breakdown: a partition of Forwarded, each line annotated with + // its share of the total. Tree-down cross — the dive-to-tree-child + // cut-through — is the last class; Tree-down + Tree-down cross sum to the + // pre-split tree-down total. + right.extend(section( + "Route Class", + &[ + ( + "Direct Peer", + route_class_value(fwd_count(data, "route_direct_peer"), fwd_total), + ), + ( + "Tree-down", + route_class_value(fwd_count(data, "route_tree_down"), fwd_total), + ), + ( + "Tree-up", + route_class_value(fwd_count(data, "route_tree_up"), fwd_total), + ), + ( + "Cross-link descend", + route_class_value(fwd_count(data, "route_crosslink_descend"), fwd_total), + ), + ( + "Cross-link ascend", + route_class_value(fwd_count(data, "route_crosslink_ascend"), fwd_total), + ), + ( + "Tree-down cross", + route_class_value(fwd_count(data, "route_tree_down_cross"), fwd_total), + ), + ], + )); + right.push(Line::from("")); + right.extend(section( + "Dropped", + &[ + ( + "No Route", + fwd_value(data, "drop_no_route_packets", "drop_no_route_bytes"), + ), + ( + "TTL Exhausted", + fwd_value(data, "ttl_exhausted_packets", "ttl_exhausted_bytes"), + ), + ( + "Decode Error", + fwd_value(data, "decode_error_packets", "decode_error_bytes"), + ), + ( + "MTU Exceeded", + fwd_value(data, "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"), + ), + ( + "Send Error", + fwd_value(data, "drop_send_error_packets", "drop_send_error_bytes"), + ), + ], + )); + right.push(Line::from("")); + right.extend(section( "Error Signals", &[ ("Coords Required", err("coords_required")), ("Path Broken", err("path_broken")), ("MTU Exceeded", err("mtu_exceeded")), ], - ); + )); right.push(Line::from("")); right.extend(section( "Congestion", diff --git a/src/bin/fipstop/ui/snapshots.rs b/src/bin/fipstop/ui/snapshots.rs index 7515311..2bfe1c0 100644 --- a/src/bin/fipstop/ui/snapshots.rs +++ b/src/bin/fipstop/ui/snapshots.rs @@ -1134,7 +1134,12 @@ fn routing_focused_pane_scrolls() { let mut app1 = app_with(Tab::Routing, data); app1.data.insert(Tab::Cache, json!({})); app1.focused_pane.insert(Tab::Routing, 2); - app1.scroll_offsets.insert((Tab::Routing, 2), 6); + // Congestion is the last section of the right ("Forwarded") column, below + // the route-class breakdown, Dropped, and Error Signals groups. The left + // column is the taller of the two, so scrolling fully to the bottom would + // over-scroll the right column past Congestion; this offset lands the + // Congestion region inside the short window instead. + app1.scroll_offsets.insert((Tab::Routing, 2), 24); let buf1 = testkit::render(100, 20, |frame, area| { super::routing::draw(frame, &app1, area); }); diff --git a/src/control/snapshots/show_routing.json b/src/control/snapshots/show_routing.json index ac6e2a3..52c3f5a 100644 --- a/src/control/snapshots/show_routing.json +++ b/src/control/snapshots/show_routing.json @@ -53,6 +53,12 @@ "originated_packets": 0, "received_bytes": 0, "received_packets": 0, + "route_crosslink_ascend": 0, + "route_crosslink_descend": 0, + "route_direct_peer": 0, + "route_tree_down": 0, + "route_tree_down_cross": 0, + "route_tree_up": 0, "ttl_exhausted_bytes": 0, "ttl_exhausted_packets": 0 }, diff --git a/src/control/snapshots/show_status.json b/src/control/snapshots/show_status.json index ef1b811..a67008a 100644 --- a/src/control/snapshots/show_status.json +++ b/src/control/snapshots/show_status.json @@ -22,6 +22,12 @@ "originated_packets": 0, "received_bytes": 0, "received_packets": 0, + "route_crosslink_ascend": 0, + "route_crosslink_descend": 0, + "route_direct_peer": 0, + "route_tree_down": 0, + "route_tree_down_cross": 0, + "route_tree_up": 0, "ttl_exhausted_bytes": 0, "ttl_exhausted_packets": 0 }, diff --git a/src/node/handlers/forwarding.rs b/src/node/handlers/forwarding.rs index 3e01778..8d6f060 100644 --- a/src/node/handlers/forwarding.rs +++ b/src/node/handlers/forwarding.rs @@ -151,6 +151,11 @@ impl Node { } } else { self.metrics().forwarding.record_forwarded(encoded.len()); + // Classify this transit forward by route class (partition of + // forwarded_packets). Done here, at the data-plane chokepoint, so + // the error-signal routing callers of find_next_hop are excluded. + let class = self.classify_forward(&datagram.dest_addr, &next_hop_addr); + self.metrics().forwarding.record_route_class(class); if outgoing_ce { self.metrics().congestion.ce_forwarded.inc(); } diff --git a/src/node/metrics.rs b/src/node/metrics.rs index b58de38..84f5ffc 100644 --- a/src/node/metrics.rs +++ b/src/node/metrics.rs @@ -81,6 +81,51 @@ pub struct ForwardingMetrics { pub drop_send_error_bytes: Counter, pub originated_packets: Counter, pub originated_bytes: Counter, + pub route_tree_up: Counter, + pub route_tree_down: Counter, + pub route_tree_down_cross: Counter, + pub route_crosslink_descend: Counter, + pub route_crosslink_ascend: Counter, + pub route_direct_peer: Counter, +} + +/// Route class of a transit-forwarded packet, classified from tree +/// coordinates at the forwarding decision point. The six variants +/// partition `forwarded_packets` exactly. +/// +/// Two variants are up-and-over forwards (destination not in the chosen +/// peer's subtree); they differ in whether they depend on a child +/// advertising cross-link reach *upward* to its parent: +/// - `TreeDownCross`: the chosen peer is our tree descendant, but the +/// destination is *not* in that child's subtree. The forward only fired +/// because the child advertised cross-link reach upward to us, beyond its +/// own subtree. If children advertised only their subtree upward, this +/// forward would route up instead, so its count measures how much +/// forwarding depends on the upward cross-link advertisement — the +/// dive-to-tree-child cut-through. +/// - `CrosslinkAscend`: the chosen peer is lateral (neither ancestor nor +/// descendant) and the destination is not in its subtree. This is a node +/// using its *own* cross-link, learned via the peer's split-horizon +/// advertisement to its neighbors, so it does not depend on any upward +/// advertisement. Tracked alongside `TreeDownCross` as the lateral +/// up-and-over contrast. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteClass { + /// Chosen peer is our ancestor (tree-up). + TreeUp, + /// Chosen peer is our descendant and dest is in its subtree (canonical + /// tree-down). + TreeDown, + /// Chosen peer is our descendant but dest is *not* in its subtree: the + /// dive-to-tree-child cut-through enabled by upward cross-link + /// advertisement. + TreeDownCross, + /// Chosen peer is lateral and dest is in its subtree (subtree entry). + CrosslinkDescend, + /// Chosen peer is lateral and dest is not in its subtree (up-and-over). + CrosslinkAscend, + /// Chosen peer is the destination itself (degenerate direct hop). + DirectPeer, } impl ForwardingMetrics { @@ -112,6 +157,21 @@ impl ForwardingMetrics { self.originated_bytes.add(bytes as u64); } + /// Record the route class of a transit-forwarded packet. The five + /// classes partition `forwarded_packets`, so this is called exactly + /// once per `record_forwarded` (transit chokepoint only). + #[inline] + pub fn record_route_class(&self, class: RouteClass) { + match class { + RouteClass::TreeUp => self.route_tree_up.inc(), + RouteClass::TreeDown => self.route_tree_down.inc(), + RouteClass::TreeDownCross => self.route_tree_down_cross.inc(), + RouteClass::CrosslinkDescend => self.route_crosslink_descend.inc(), + RouteClass::CrosslinkAscend => self.route_crosslink_ascend.inc(), + RouteClass::DirectPeer => self.route_direct_peer.inc(), + } + } + /// Mirror of `ForwardingStats::record_reject_bytes`: route a typed /// forwarding rejection of `bytes` payload to its packet and byte /// counters. @@ -163,6 +223,12 @@ impl ForwardingMetrics { drop_send_error_bytes: self.drop_send_error_bytes.get(), originated_packets: self.originated_packets.get(), originated_bytes: self.originated_bytes.get(), + route_tree_up: self.route_tree_up.get(), + route_tree_down: self.route_tree_down.get(), + route_tree_down_cross: self.route_tree_down_cross.get(), + route_crosslink_descend: self.route_crosslink_descend.get(), + route_crosslink_ascend: self.route_crosslink_ascend.get(), + route_direct_peer: self.route_direct_peer.get(), } } } diff --git a/src/node/mod.rs b/src/node/mod.rs index e9bb248..177c36c 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -2666,6 +2666,86 @@ impl Node { self.peers.get(&next_hop_id).filter(|p| p.can_send()) } + /// Classify a transit forward by route class from tree coordinates. + /// + /// Called at the transit chokepoint after `find_next_hop` returns a peer, + /// so the six classes partition `forwarded_packets` exactly. The branch + /// that `find_next_hop` took (bloom vs greedy-tree) is *not* the route + /// class: a peer can be selected by either, so the cut-through splits + /// (`TreeDownCross`, `CrosslinkAscend`) are decided here from coordinates, + /// not from which branch fired. + /// + /// Inputs: our coords (`tree_state.my_coords`), the chosen peer's coords + /// (`tree_state.peer_coords`), and the destination coords (re-read from the + /// coord cache, which `find_next_hop` just touched). Both the tree-down and + /// cross-link branches split on whether the destination is in the chosen + /// peer's subtree; when the dest coords are unavailable that test defaults + /// to "not in subtree", i.e. the up-and-over variant (`TreeDownCross` for a + /// descendant peer, `CrosslinkAscend` for a lateral one). + pub(crate) fn classify_forward( + &self, + dest: &NodeAddr, + chosen_peer: &NodeAddr, + ) -> metrics::RouteClass { + // Degenerate: the next hop is the destination itself (Branch 2). + if chosen_peer == dest { + return metrics::RouteClass::DirectPeer; + } + + let my_addr = self.node_addr(); + let my_coords = self.tree_state.my_coords(); + + // Tree-up: the chosen peer is our ancestor. + if my_coords.has_ancestor(chosen_peer) { + return metrics::RouteClass::TreeUp; + } + + // Whether the destination is in the chosen peer's subtree. Both the + // tree-down and cross-link splits below turn on this same test, so it + // is computed once. On the live transit path the dest coords are + // always present here: `find_next_hop` looks them up with an early + // return, so a coord-cache miss yields no next hop to classify (the + // caller signals `CoordsRequired` instead of forwarding). The miss + // branch below is therefore defensive — reachable only by direct + // unit-test calls — and defaults the test to "not in subtree", i.e. + // the up-and-over variant of whichever branch fires (TreeDownCross for + // a descendant peer, CrosslinkAscend for a lateral one), matching the + // original cross-link default-to-ascend. + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let dest_in_peer_subtree = self + .coord_cache + .get(dest, now_ms) + .is_some_and(|dest_coords| dest_coords.has_ancestor(chosen_peer)); + + // Tree-down: the chosen peer is our descendant (we are its ancestor). + // Split by subtree membership: a dest genuinely below the child is the + // canonical tree-down; a dest *not* below it means we only forwarded + // down because the child advertised cross-link reach upward, beyond its + // own subtree — the dive-to-tree-child cut-through (TreeDownCross). + if let Some(peer_coords) = self.tree_state.peer_coords(chosen_peer) + && peer_coords.has_ancestor(my_addr) + { + return if dest_in_peer_subtree { + metrics::RouteClass::TreeDown + } else { + metrics::RouteClass::TreeDownCross + }; + } + + // Cross-link (lateral): split by whether the destination is in the + // chosen peer's subtree. Descend = subtree entry; ascend = up-and-over + // via the node's own cross-link (learned from the peer's split-horizon + // advertisement, independent of any upward advertisement). + if dest_in_peer_subtree { + return metrics::RouteClass::CrosslinkDescend; + } + + metrics::RouteClass::CrosslinkAscend + } + /// Select the best peer from a set of bloom filter candidates. /// /// Uses distance from each candidate's tree coordinates to the destination diff --git a/src/node/stats.rs b/src/node/stats.rs index f6db420..2315ad9 100644 --- a/src/node/stats.rs +++ b/src/node/stats.rs @@ -229,6 +229,12 @@ pub struct ForwardingStatsSnapshot { pub drop_send_error_bytes: u64, pub originated_packets: u64, pub originated_bytes: u64, + pub route_tree_up: u64, + pub route_tree_down: u64, + pub route_tree_down_cross: u64, + pub route_crosslink_descend: u64, + pub route_crosslink_ascend: u64, + pub route_direct_peer: u64, } #[derive(Clone, Debug, Default, Serialize)] diff --git a/src/node/tests/routing.rs b/src/node/tests/routing.rs index 0fb4449..4143ed9 100644 --- a/src/node/tests/routing.rs +++ b/src/node/tests/routing.rs @@ -1110,3 +1110,226 @@ async fn test_routing_source_only_coords_100_nodes() { 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); +} diff --git a/src/tree/state.rs b/src/tree/state.rs index 274db56..0641865 100644 --- a/src/tree/state.rs +++ b/src/tree/state.rs @@ -92,6 +92,15 @@ impl TreeState { &self.my_coords } + /// Test-only override of this node's coordinates, bypassing the + /// parent/declaration state machine. Lets routing tests place the node at + /// an arbitrary tree position to exercise coordinate-based classification. + #[cfg(test)] + pub(crate) fn set_my_coords_for_test(&mut self, coords: TreeCoordinate) { + self.root = *coords.root_id(); + self.my_coords = coords; + } + /// Get the current root. pub fn root(&self) -> &NodeAddr { &self.root