Merge branch 'master' into next

Carries the routing next-hop allocation removal up from master.

This one needed a real resolution rather than a side. Master rewrote the
selector to enumerate borrowed peers and deleted the candidate-assembly
stage outright, while this branch had added a Full-profile narrowing to
that same stage: only Full peers carry transit bloom filters, so
candidates were `may_reach && is_full`. Taking master's side would have
silently dropped that filter and let a leaf or non-routing peer be
chosen as a transit next hop on the strength of a bloom filter it does
not maintain.

So `peer_is_full` moves to the borrowed-peer signature alongside the
rest of the seam, and its filter joins the fused predicate in
`select_best_candidate`. The three predicates are unchanged as a set:
a peer must may_reach, be Full, and be able to send.

Both shell implementations follow: the node's, which now reads the
profile off the borrowed peer instead of a second map lookup, and the
test mock's.

Added `candidate_selection_excludes_non_full_peers`, because the test
that used to cover this asserted on the deleted assembly function and
could not survive. It pins both halves: a cheaper, closer, sendable
non-Full peer must lose to an expensive Full one, and a view containing
only non-Full peers must yield no bloom hop at all. Break-checked —
removing the `is_full` term from the predicate reds this test and
nothing else.

Quartet green on the merged tree at 1798 lib tests.
This commit is contained in:
Johnathan Corgan
2026-08-10 02:28:38 +00:00
7 changed files with 312 additions and 228 deletions
+2
View File
@@ -168,6 +168,8 @@ with v0.4.x or earlier peers.
- Connected UDP peer drains now batch macOS receives with `recvmsg_x(2)`,
matching the wildcard UDP receive path instead of issuing one `recv(2)`
syscall per queued datagram.
- Routing next-hop selection now visits borrowed peers and coordinates instead
of allocating candidate snapshots for each forwarded packet.
- Inbound msg1 is classified before it is rate limited, and rekey or restart
msg1 arriving on a link belonging to a promoted peer now draws on its own
+42 -47
View File
@@ -1,23 +1,18 @@
//! Micro-benchmark quantifying the per-forwarded-packet heap-allocation cost
//! of the routing next-hop candidate-assembly path.
//! Micro-benchmark quantifying the per-forwarded-packet cost of routing
//! next-hop selection before and after candidate assembly was fused.
//!
//! `find_next_hop` runs once per forwarded data packet. Its sans-IO core
//! assembles a `Vec<Candidate>` by enumerating every peer through the
//! `RoutingView` seam: `peer_addrs()` materializes a `Vec<NodeAddr>` of all
//! peers, the survivors are snapshotted (each cloning its `TreeCoordinate`),
//! and the result is collected into a second `Vec`. This bench measures that
//! per-call allocation against a fused zero-alloc reference that iterates the
//! peer map directly and borrows coordinates instead of cloning.
//! `find_next_hop` runs once per forwarded data packet. The former path
//! materialized a `Vec<NodeAddr>`, cloned each surviving `TreeCoordinate` into
//! a second `Vec<Candidate>`, then selected from that snapshot. The current
//! path visits borrowed peers, filters and selects inline, and borrows
//! coordinates. This bench retains the former implementation as a baseline
//! and measures both paths in the same process.
//!
//! Visibility caveat: the production `routing_candidates` / `select_best_candidate`
//! / `RoutingView` / `Candidate` are `pub(crate)` (src/proto/routing/core.rs)
//! and are not re-exported at the crate root, so an external bench crate cannot
//! name them. Rather than change production visibility, this file reproduces
//! that path verbatim over the real public `NodeAddr` / `TreeCoordinate` /
//! `CoordEntry` / `BloomFilter` types with the same iterator chain and the same
//! `HashMap`-backed view the shell uses (src/node/mod.rs NodeRoutingView). The
//! allocation behavior is therefore identical to production by construction;
//! only the symbol identity differs.
//! Visibility caveat: the production selector and `RoutingView` are
//! `pub(crate)` and are not re-exported at the crate root, so an external bench
//! crate cannot name them. Rather than widen production visibility, this file
//! mirrors both implementations over the real public routing value types and
//! the same `HashMap`-backed shape used by `NodeRoutingView`.
use std::alloc::{GlobalAlloc, Layout, System};
use std::collections::HashMap;
@@ -65,9 +60,9 @@ const REACH_DENOMINATOR: usize = 2;
const COORD_DEPTH: usize = 8;
// ---------------------------------------------------------------------------
// Reproduction of the pub(crate) routing seam (src/proto/routing/core.rs).
// Former allocating routing seam, retained as the before baseline.
// ---------------------------------------------------------------------------
trait RoutingView {
trait FormerRoutingView {
fn peer_addrs(&self) -> Vec<NodeAddr>;
fn peer_may_reach(&self, peer: &NodeAddr, dest: &NodeAddr) -> bool;
fn peer_can_send(&self, peer: &NodeAddr) -> bool;
@@ -75,20 +70,20 @@ trait RoutingView {
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate>;
}
struct Candidate {
struct FormerCandidate {
addr: NodeAddr,
can_send: bool,
link_cost: f64,
coords: Option<TreeCoordinate>,
}
/// Verbatim from `routing::routing_candidates` (core.rs). Allocates the
/// `peer_addrs` Vec, clones each survivor's coords, and collects into a Vec.
fn routing_candidates(rv: &impl RoutingView, dest: &NodeAddr) -> Vec<Candidate> {
/// Former `routing::routing_candidates`. Allocates the `peer_addrs` Vec,
/// clones each survivor's coords, and collects into a second Vec.
fn former_routing_candidates(rv: &impl FormerRoutingView, dest: &NodeAddr) -> Vec<FormerCandidate> {
rv.peer_addrs()
.into_iter()
.filter(|peer| rv.peer_may_reach(peer, dest))
.map(|peer| Candidate {
.map(|peer| FormerCandidate {
can_send: rv.peer_can_send(&peer),
link_cost: rv.peer_link_cost(&peer),
coords: rv.peer_coords(&peer),
@@ -97,14 +92,14 @@ fn routing_candidates(rv: &impl RoutingView, dest: &NodeAddr) -> Vec<Candidate>
.collect()
}
/// Verbatim from `routing::select_best_candidate` (core.rs). Pure, no alloc.
fn select_best_candidate(
candidates: &[Candidate],
/// Former `routing::select_best_candidate`. Pure itself; consumes the snapshot.
fn former_select_best_candidate(
candidates: &[FormerCandidate],
dest_coords: &TreeCoordinate,
my_coords: &TreeCoordinate,
) -> Option<NodeAddr> {
let my_distance = my_coords.distance_to(dest_coords);
let mut best: Option<(&Candidate, f64, usize)> = None;
let mut best: Option<(&FormerCandidate, f64, usize)> = None;
for candidate in candidates {
if !candidate.can_send {
continue;
@@ -149,7 +144,7 @@ struct BenchView {
coords: HashMap<NodeAddr, TreeCoordinate>,
}
impl RoutingView for BenchView {
impl FormerRoutingView for BenchView {
fn peer_addrs(&self) -> Vec<NodeAddr> {
self.peers.keys().copied().collect()
}
@@ -167,10 +162,10 @@ impl RoutingView for BenchView {
}
}
/// Zero-alloc reference: what an iterator/visitor seam would do. Iterates the
/// peer map directly, fuses the may_reach + can_send filters, borrows coords
/// instead of cloning, and tracks the best hop inline. No Vec, no coord clone.
fn resolve_next_hop_zeroalloc(
/// Current borrowed selector. Mirrors the production visitor seam after
/// monomorphization: peer facts come from the map entry, coordinates are
/// borrowed from the tree-state map, and the winner is tracked inline.
fn select_best_candidate_current(
view: &BenchView,
dest: &NodeAddr,
dest_coords: &TreeCoordinate,
@@ -310,19 +305,19 @@ fn report_allocs() {
println!("\n=== allocations per call (heap alloc ops: alloc+alloc_zeroed+realloc) ===");
println!(
"{:>6} {:>10} {:>16} {:>16}",
"peers", "survivors", "current/call", "zero-alloc/call"
"peers", "survivors", "former/call", "current/call"
);
for &n in &PEER_COUNTS {
let s = Scenario::new(n);
let survivors = s.survivors();
let former = count_allocs(ITERS, || {
let candidates = former_routing_candidates(&s.view, &s.dest);
former_select_best_candidate(&candidates, &s.dest_coords, &s.my_coords)
});
let current = count_allocs(ITERS, || {
let cands = routing_candidates(&s.view, &s.dest);
select_best_candidate(&cands, &s.dest_coords, &s.my_coords)
select_best_candidate_current(&s.view, &s.dest, &s.dest_coords, &s.my_coords)
});
let zero = count_allocs(ITERS, || {
resolve_next_hop_zeroalloc(&s.view, &s.dest, &s.dest_coords, &s.my_coords)
});
println!("{n:>6} {survivors:>10} {current:>16.2} {zero:>16.2}");
println!("{n:>6} {survivors:>10} {former:>16.2} {current:>16.2}");
}
println!();
}
@@ -333,19 +328,19 @@ fn bench_next_hop(c: &mut Criterion) {
let mut group = c.benchmark_group("find_next_hop");
for &n in &PEER_COUNTS {
let scenario = Scenario::new(n);
group.bench_with_input(BenchmarkId::new("current_alloc", n), &n, |b, _| {
group.bench_with_input(BenchmarkId::new("former_allocating", n), &n, |b, _| {
b.iter(|| {
let cands = routing_candidates(&scenario.view, &scenario.dest);
black_box(select_best_candidate(
&cands,
let candidates = former_routing_candidates(&scenario.view, &scenario.dest);
black_box(former_select_best_candidate(
&candidates,
&scenario.dest_coords,
&scenario.my_coords,
))
});
});
group.bench_with_input(BenchmarkId::new("zero_alloc_ref", n), &n, |b, _| {
group.bench_with_input(BenchmarkId::new("current_borrowed", n), &n, |b, _| {
b.iter(|| {
black_box(resolve_next_hop_zeroalloc(
black_box(select_best_candidate_current(
&scenario.view,
&scenario.dest,
&scenario.dest_coords,
+36 -32
View File
@@ -2837,26 +2837,24 @@ impl Node {
// 3. Bloom filter candidates — requires dest_coords for loop-free selection.
// If no candidate is strictly closer, fall through to tree routing.
// The sans-IO core assembles the candidate snapshot over the
// `RoutingView` seam (enumerate peers, apply the bloom `may_reach`
// filter, snapshot each), then picks the winner; the shell supplies
// only the raw per-peer reads.
let candidates = {
// The sans-IO core enumerates borrowed peers over the `RoutingView`
// seam, applies the bloom/send/progress filters, and tracks the
// winner inline; the shell supplies only raw per-peer reads.
let next_hop = {
let view = NodeRoutingView {
coord_cache: &self.coord_cache,
peers: &self.peers,
tree_state: &self.tree_state,
congested: false,
};
routing::routing_candidates(&view, dest_node_addr)
};
if !candidates.is_empty()
&& let Some(next_hop) = routing::select_best_candidate(
&candidates,
routing::select_best_candidate(
&view,
dest_node_addr,
&dest_coords,
self.tree_state.my_coords(),
)
{
};
if let Some(next_hop) = next_hop {
return self.peers.get(&next_hop);
}
@@ -3224,17 +3222,16 @@ impl Node {
/// Shell-side [`routing::RoutingView`] seam over live `Node` state — the sole
/// routing read adapter the shell retains. It hands the sans-IO routing core
/// raw per-peer reads (enumeration plus `may_reach` / `can_send` / `link_cost`
/// / `coords`) so the candidate assembly, selection, and error synthesis all
/// live in `proto::routing::core`; no routing decision or assembly logic
/// remains here.
/// borrowed peers plus raw `may_reach` / `can_send` / `link_cost` / `coords`
/// reads so selection and error synthesis live in `proto::routing::core`; no
/// routing decision logic remains here.
///
/// Field-narrowed to `coord_cache` + `peers` + `tree_state` (never `&Node`
/// whole) so it borrows disjointly from `&mut self.routing` on the
/// forward/synth path, where the handler also holds the mutable `Router`.
///
/// Two call sites:
/// - `find_next_hop` builds it to assemble bloom candidates via the `peer_*`
/// - `find_next_hop` builds it to select a bloom candidate via the `peer_*`
/// reads; it never queries `is_congested`, so it leaves `congested` false.
/// - `handle_session_datagram` builds it for `Router::route` / `synth_*`,
/// which read `is_congested` (precomputed once for the resolved next hop)
@@ -3247,6 +3244,11 @@ pub(in crate::node) struct NodeRoutingView<'a> {
}
impl routing::RoutingView for NodeRoutingView<'_> {
type Peer<'a>
= (&'a NodeAddr, &'a ActivePeer)
where
Self: 'a;
fn is_congested(&self, _next_hop: &NodeAddr) -> bool {
self.congested
}
@@ -3255,32 +3257,34 @@ impl routing::RoutingView for NodeRoutingView<'_> {
self.coord_cache.get(dest, now_ms).cloned()
}
fn peer_addrs(&self) -> Vec<NodeAddr> {
self.peers.keys().copied().collect()
fn for_each_peer<'a>(&'a self, mut visitor: impl FnMut(Self::Peer<'a>)) {
for peer in self.peers {
visitor(peer);
}
}
fn peer_may_reach(&self, peer: &NodeAddr, dest: &NodeAddr) -> bool {
self.peers.get(peer).is_some_and(|p| p.may_reach(dest))
fn peer_addr<'a>(&'a self, peer: Self::Peer<'a>) -> NodeAddr {
*peer.0
}
fn peer_can_send(&self, peer: &NodeAddr) -> bool {
self.peers.get(peer).is_some_and(|p| p.can_send())
fn peer_may_reach<'a>(&'a self, peer: Self::Peer<'a>, dest: &NodeAddr) -> bool {
peer.1.may_reach(dest)
}
fn peer_link_cost(&self, peer: &NodeAddr) -> f64 {
self.peers
.get(peer)
.map_or(f64::INFINITY, |p| p.link_cost())
fn peer_can_send<'a>(&'a self, peer: Self::Peer<'a>) -> bool {
peer.1.can_send()
}
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate> {
self.tree_state.peer_coords(peer).cloned()
fn peer_link_cost<'a>(&'a self, peer: Self::Peer<'a>) -> f64 {
peer.1.link_cost()
}
fn peer_is_full(&self, peer: &NodeAddr) -> bool {
self.peers
.get(peer)
.is_some_and(|p| p.peer_profile() == NodeProfile::Full)
fn peer_coords<'a>(&'a self, peer: Self::Peer<'a>) -> Option<&'a TreeCoordinate> {
self.tree_state.peer_coords(peer.0)
}
fn peer_is_full<'a>(&'a self, peer: Self::Peer<'a>) -> bool {
peer.1.peer_profile() == NodeProfile::Full
}
}
+46 -83
View File
@@ -7,14 +7,12 @@
//! actual encrypted sends, metrics, and logging). No I/O, no clock, no
//! metrics, no logging here.
//!
//! This module also holds the pure candidate assembly ([`routing_candidates`])
//! and the hop-selection / route-classification helpers
//! ([`select_best_candidate`], [`classify_forward`]). The assembly enumerates
//! peers through the [`RoutingView`] seam, applies the bloom `may_reach`
//! filter, and snapshots each survivor into a [`Candidate`]; the shell hands
//! over only raw per-peer reads (enumeration plus `may_reach` / `can_send` /
//! `link_cost` / `coords`). Selection and classification then consume the
//! assembled set. All routing narrowing and decision logic lives here.
//! This module also holds the pure hop-selection / route-classification helpers
//! ([`select_best_candidate`], [`classify_forward`]). Selection enumerates
//! borrowed peers through the [`RoutingView`] seam, applies the bloom
//! `may_reach` and send/progress filters, and tracks the winner inline. The
//! shell hands over only raw per-peer reads; all routing narrowing and decision
//! logic lives here.
use super::state::Router;
use super::wire::{CoordsRequired, MtuExceeded, PathBroken};
@@ -28,6 +26,12 @@ use crate::{NodeAddr, TreeCoordinate};
/// `proto` free of any dependency on `node` and lets the core be unit-tested
/// with a mock.
pub(crate) trait RoutingView {
/// Borrowed peer handle exposed while enumerating this view. The concrete
/// shell type stays opaque to the routing core.
type Peer<'a>: Copy
where
Self: 'a;
/// Is the outgoing link toward `next_hop` congested (ECN local signal)?
fn is_congested(&self, next_hop: &NodeAddr) -> bool;
/// Cached destination coordinates for `dest`, if any (read-only lookup).
@@ -36,23 +40,23 @@ pub(crate) trait RoutingView {
/// [`Router::synth_routing_error`].
fn cached_coords(&self, dest: &NodeAddr, now_ms: u64) -> Option<TreeCoordinate>;
/// Node addresses of every currently-active peer — the raw enumeration the
/// candidate assembly iterates. No filtering or ordering is applied here;
/// [`routing_candidates`] applies the bloom `may_reach` narrowing in core.
fn peer_addrs(&self) -> Vec<NodeAddr>;
/// Visit every currently-active peer without filtering or ordering.
fn for_each_peer<'a>(&'a self, visitor: impl FnMut(Self::Peer<'a>));
/// Node address of a borrowed peer.
fn peer_addr<'a>(&'a self, peer: Self::Peer<'a>) -> NodeAddr;
/// Does `peer`'s bloom filter indicate it may reach `dest`? The raw
/// per-peer predicate the core assembly filters candidates on.
fn peer_may_reach(&self, peer: &NodeAddr, dest: &NodeAddr) -> bool;
/// per-peer predicate the core filters candidates on.
fn peer_may_reach<'a>(&'a self, peer: Self::Peer<'a>, dest: &NodeAddr) -> bool;
/// Can `peer`'s session currently carry a forward?
fn peer_can_send(&self, peer: &NodeAddr) -> bool;
fn peer_can_send<'a>(&'a self, peer: Self::Peer<'a>) -> bool;
/// `peer`'s outgoing link cost (lower is preferred).
fn peer_link_cost(&self, peer: &NodeAddr) -> f64;
fn peer_link_cost<'a>(&'a self, peer: Self::Peer<'a>) -> f64;
/// `peer`'s tree coordinates, if known.
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate>;
fn peer_coords<'a>(&'a self, peer: Self::Peer<'a>) -> Option<&'a TreeCoordinate>;
/// Does `peer` advertise the Full node profile? Only Full peers carry
/// transit bloom filters, so the candidate assembly narrows to them
/// alongside the `may_reach` filter.
fn peer_is_full(&self, peer: &NodeAddr) -> bool;
/// transit bloom filters, so routing narrows to them alongside the
/// `may_reach` filter.
fn peer_is_full<'a>(&'a self, peer: Self::Peer<'a>) -> bool;
}
/// A next hop the shell resolved for a transit forward: the peer address and
@@ -273,102 +277,61 @@ pub(crate) enum RouteClass {
DirectPeer,
}
/// A bloom-filter routing candidate, snapshotted by [`routing_candidates`]
/// from the per-peer reads the [`RoutingView`] seam exposes.
/// Select the best next hop from the active peers that may reach `dest`.
///
/// The assembly applies the bloom `may_reach` narrowing before building each
/// snapshot, so [`select_best_candidate`] is a pure consumer of an
/// already-narrowed set and names no shell peer type.
pub(crate) struct Candidate {
/// The candidate peer's node address.
pub addr: NodeAddr,
/// Whether the peer's session can currently carry a forward.
pub can_send: bool,
/// The outgoing link cost (lower is preferred).
pub link_cost: f64,
/// The candidate's tree coordinates, if known.
pub coords: Option<TreeCoordinate>,
}
/// Assemble the bloom-filter routing candidates toward `dest`.
///
/// Enumerates every peer through the [`RoutingView`] seam, keeps only
/// Full-profile peers whose bloom `may_reach` the destination, and snapshots
/// each surviving peer's send-eligibility, link cost, and tree coordinates
/// into a [`Candidate`]. Pure over the seam's
/// primitive reads — the shell hands over raw per-peer data only, so all
/// narrowing and snapshotting happens here and [`select_best_candidate`]
/// consumes an already-assembled set. Candidate order follows the seam's
/// enumeration, which the selection ordering renders immaterial (it breaks
/// ties deterministically on `node_addr`).
pub(crate) fn routing_candidates(rv: &impl RoutingView, dest: &NodeAddr) -> Vec<Candidate> {
rv.peer_addrs()
.into_iter()
.filter(|peer| rv.peer_may_reach(peer, dest) && rv.peer_is_full(peer))
.map(|peer| Candidate {
can_send: rv.peer_can_send(&peer),
link_cost: rv.peer_link_cost(&peer),
coords: rv.peer_coords(&peer),
addr: peer,
})
.collect()
}
/// Select the best next hop from a set of bloom-filter candidates.
///
/// Uses each candidate's tree-coordinate distance to the destination as the
/// primary metric (after link cost). Only peers strictly closer to the
/// destination than we are (`my_coords`) are eligible — the self-distance
/// check that prevents routing loops.
/// Enumerates borrowed peers through [`RoutingView`], applies the bloom and
/// send-eligibility filters, and tracks the best hop inline without allocating
/// candidate vectors or cloning coordinates. Only peers strictly closer to the
/// destination than we are (`my_coords`) are eligible — the self-distance check
/// that prevents routing loops.
///
/// Ordering: `(link_cost, distance_to_dest, node_addr)`. Returns the winning
/// peer's address, or `None` when no candidate is send-ready and strictly
/// closer to the destination than us.
pub(crate) fn select_best_candidate(
candidates: &[Candidate],
rv: &impl RoutingView,
dest: &NodeAddr,
dest_coords: &TreeCoordinate,
my_coords: &TreeCoordinate,
) -> Option<NodeAddr> {
let my_distance = my_coords.distance_to(dest_coords);
let mut best: Option<(&Candidate, f64, usize)> = None;
let mut best: Option<(NodeAddr, f64, usize)> = None;
for candidate in candidates {
if !candidate.can_send {
continue;
rv.for_each_peer(|peer| {
if !rv.peer_may_reach(peer, dest) || !rv.peer_is_full(peer) || !rv.peer_can_send(peer) {
return;
}
let cost = candidate.link_cost;
let addr = rv.peer_addr(peer);
let cost = rv.peer_link_cost(peer);
let dist = candidate
.coords
.as_ref()
let dist = rv
.peer_coords(peer)
.map(|pc| pc.distance_to(dest_coords))
.unwrap_or(usize::MAX);
// Self-distance check: only consider peers strictly closer
// to the destination than we are (prevents routing loops)
if dist >= my_distance {
continue;
return;
}
let dominated = match &best {
None => true,
Some((_, best_cost, best_dist)) => {
Some((best_addr, best_cost, best_dist)) => {
cost < *best_cost
|| (cost == *best_cost && dist < *best_dist)
|| (cost == *best_cost
&& dist == *best_dist
&& candidate.addr < best.as_ref().unwrap().0.addr)
|| (cost == *best_cost && dist == *best_dist && addr < *best_addr)
}
};
if dominated {
best = Some((candidate, cost, dist));
best = Some((addr, cost, dist));
}
}
});
best.map(|(candidate, _, _)| candidate.addr)
best.map(|(addr, _, _)| addr)
}
/// Classify a transit forward by route class from tree coordinates.
+5 -5
View File
@@ -7,10 +7,10 @@
//! - `core.rs` — the `RoutingView` read-seam trait, the `NextHop` /
//! `RouteOutcome` types, `Router::route`, the pure transit-forward
//! decision (local-vs-forward, transit TTL, path-MTU min-fold, ECN CE), and the
//! pure candidate assembly + hop-selection / route-classification helpers
//! (`Candidate`, `RouteClass`, `routing_candidates`, `select_best_candidate`,
//! `classify_forward`). The assembly reads raw per-peer data through the
//! `RoutingView` seam; the shell keeps only the seam impl.
//! pure hop-selection / route-classification helpers (`RouteClass`,
//! `select_best_candidate`, `classify_forward`). Selection reads borrowed
//! per-peer data through the `RoutingView` seam; the shell keeps only the seam
//! impl.
//! - `state.rs` — `Router`, the routing-subsystem state owned by `Node`.
//! - `limits.rs` — the routing error-signal rate limiter.
//! - `wire.rs` — the routing error-signal PDUs (`CoordsRequired`,
@@ -27,7 +27,7 @@ mod tests;
pub(crate) use core::{
DropReason, NextHop, RouteAction, RouteClass, RouteOutcome, RoutingView, classify_forward,
routing_candidates, select_best_candidate,
select_best_candidate,
};
pub(crate) use limits::RoutingErrorRateLimiter;
pub(crate) use state::Router;
+156 -43
View File
@@ -1,13 +1,13 @@
//! Tests for the sans-IO routing decision core.
use super::util::{MockPeer, MockRoutingView, make_coords, make_datagram_ref, make_next_hop};
use crate::TreeCoordinate;
use crate::proto::link::SessionDatagramRef;
use crate::proto::routing::RoutingSignalType;
use crate::proto::routing::{
DropReason, RouteAction, RouteOutcome, Router, RoutingView, routing_candidates,
DropReason, RouteAction, RouteOutcome, Router, RoutingView, select_best_candidate,
};
use crate::testutil::make_node_addr;
use crate::{NodeAddr, TreeCoordinate};
/// Decode a forwarded byte buffer (which carries the leading msg_type byte)
/// back into a borrowed view so tests can inspect the re-encoded header.
@@ -15,6 +15,43 @@ fn decode_forward(bytes: &[u8]) -> SessionDatagramRef<'_> {
SessionDatagramRef::decode(&bytes[1..]).expect("forwarded datagram re-decodes")
}
fn choose_candidate(
rv: &MockRoutingView,
dest: &NodeAddr,
dest_coords: &TreeCoordinate,
my_coords: &TreeCoordinate,
) -> Option<NodeAddr> {
select_best_candidate(rv, dest, dest_coords, my_coords)
}
fn mock_peer(
addr: u8,
dest: NodeAddr,
may_reach: bool,
can_send: bool,
link_cost: f64,
coords: Option<&[u8]>,
) -> MockPeer {
MockPeer {
addr: make_node_addr(addr),
reach: may_reach.then_some(dest).into_iter().collect(),
can_send,
link_cost,
coords: coords.map(make_coords),
is_full: true,
}
}
/// A peer that is eligible in every respect except that it does not advertise
/// the Full node profile, so it carries no transit bloom filter and must not
/// be selected as a next hop.
fn mock_non_full_peer(addr: u8, dest: NodeAddr, link_cost: f64, coords: Option<&[u8]>) -> MockPeer {
MockPeer {
is_full: false,
..mock_peer(addr, dest, true, true, link_cost, coords)
}
}
/// A transit datagram that arrived already exhausted is dropped and charged
/// to `TtlExhausted`. A next hop is supplied so the drop is evidence of the
/// TTL gate rather than of an absent route.
@@ -253,57 +290,98 @@ fn cached_coords_reads_the_view_table() {
}
#[test]
fn routing_candidates_filters_by_may_reach_and_snapshots() {
fn candidate_selection_is_independent_of_peer_enumeration_order() {
let dest = make_node_addr(0x50);
let reacher = make_node_addr(0x60);
let non_reacher = make_node_addr(0x61);
let non_full_reacher = make_node_addr(0x62);
let reacher_coords = make_coords(&[0x01, 0x60]);
let root = 0x00;
let my_coords = make_coords(&[0x10, root]);
let dest_coords = make_coords(&[0x50, root]);
let lower_addr = mock_peer(0x20, dest, true, true, 1.0, Some(&[root]));
let higher_addr = mock_peer(0x30, dest, true, true, 1.0, Some(&[root]));
let forward = MockRoutingView {
peers: vec![lower_addr.clone(), higher_addr.clone()],
..MockRoutingView::new(false)
};
let reverse = MockRoutingView {
peers: vec![higher_addr, lower_addr],
..MockRoutingView::new(false)
};
assert_eq!(
choose_candidate(&forward, &dest, &dest_coords, &my_coords),
Some(make_node_addr(0x20))
);
assert_eq!(
choose_candidate(&reverse, &dest, &dest_coords, &my_coords),
Some(make_node_addr(0x20))
);
}
#[test]
fn candidate_selection_filters_bloom_unsendable_and_missing_coords() {
let dest = make_node_addr(0x50);
let root = 0x00;
let my_coords = make_coords(&[0x10, root]);
let dest_coords = make_coords(&[0x50, root]);
let eligible = make_node_addr(0x60);
let rv = MockRoutingView {
peers: vec![
MockPeer {
addr: reacher,
reach: vec![dest],
can_send: true,
link_cost: 2.5,
coords: Some(reacher_coords.clone()),
is_full: true,
},
MockPeer {
// Bloom filter does not contain dest — narrowed out in core.
addr: non_reacher,
reach: Vec::new(),
can_send: true,
link_cost: 1.0,
coords: None,
is_full: true,
},
MockPeer {
// Bloom may_reach the dest, but not a Full-profile peer —
// narrowed out by the Full filter.
addr: non_full_reacher,
reach: vec![dest],
can_send: true,
link_cost: 0.5,
coords: None,
is_full: false,
},
mock_peer(0x01, dest, true, false, 0.0, Some(&[0x50, root])),
mock_peer(0x02, dest, true, true, 0.0, None),
mock_peer(0x03, dest, false, true, 0.0, Some(&[0x50, root])),
mock_peer(0x60, dest, true, true, 10.0, Some(&[root])),
],
..MockRoutingView::new(false)
};
let candidates = routing_candidates(&rv, &dest);
assert_eq!(
choose_candidate(&rv, &dest, &dest_coords, &my_coords),
Some(eligible)
);
}
#[test]
fn candidate_must_be_strictly_closer_than_self() {
let dest = make_node_addr(0x50);
let root = 0x00;
let my_coords = make_coords(&[0x10, root]);
let dest_coords = make_coords(&[0x50, root]);
let rv = MockRoutingView {
peers: vec![
// A sibling is exactly as far from dest as this node.
mock_peer(0x20, dest, true, true, 1.0, Some(&[0x20, root])),
// This descendant of a sibling is farther from dest.
mock_peer(0x21, dest, true, true, 0.5, Some(&[0x21, 0x20, root])),
],
..MockRoutingView::new(false)
};
assert_eq!(choose_candidate(&rv, &dest, &dest_coords, &my_coords), None);
}
#[test]
fn candidate_ordering_is_cost_then_distance_then_address() {
let dest = make_node_addr(0x50);
let root = 0x00;
let my_coords = make_coords(&[0x10, 0x11, root]);
let dest_coords = make_coords(&[0x50, root]);
let rv = MockRoutingView {
peers: vec![
// Lowest address loses because distance precedes address.
mock_peer(0x01, dest, true, true, 1.0, Some(&[root])),
// Closest peer loses because cost is the primary key.
mock_peer(0x02, dest, true, true, 1.0, Some(&[0x50, root])),
mock_peer(0x04, dest, true, true, 0.5, Some(&[root])),
// Same cost and distance: lower address wins.
mock_peer(0x03, dest, true, true, 0.5, Some(&[root])),
],
..MockRoutingView::new(false)
};
assert_eq!(
candidates.len(),
1,
"only Full peers whose bloom may_reach the dest survive assembly"
choose_candidate(&rv, &dest, &dest_coords, &my_coords),
Some(make_node_addr(0x03))
);
let c = &candidates[0];
assert_eq!(c.addr, reacher);
assert!(c.can_send);
assert_eq!(c.link_cost, 2.5);
assert_eq!(c.coords, Some(reacher_coords));
}
/// Extract the error-PDU msg_type byte from an encoded routing-error action.
@@ -451,3 +529,38 @@ fn synth_mtu_exceeded_rate_limit_gate_suppresses_second_call() {
.is_some()
);
}
/// A non-Full peer is excluded even when its bloom filter may reach the
/// destination, it can send, it is strictly closer, and its link cost beats
/// every Full peer's. Only Full peers carry transit bloom filters, so trusting
/// a non-Full peer's would route into a node that cannot forward.
#[test]
fn candidate_selection_excludes_non_full_peers() {
let dest = make_node_addr(0x50);
let root = 0x00;
let my_coords = make_coords(&[0x10, root]);
let dest_coords = make_coords(&[0x50, root]);
let full = mock_peer(0x70, dest, true, true, 10.0, Some(&[root]));
let non_full = mock_non_full_peer(0x71, dest, 0.5, Some(&[root]));
let rv = MockRoutingView {
peers: vec![non_full.clone(), full.clone()],
..MockRoutingView::new(false)
};
assert_eq!(
choose_candidate(&rv, &dest, &dest_coords, &my_coords),
Some(make_node_addr(0x70)),
"the cheaper non-Full peer must not win"
);
let only_non_full = MockRoutingView {
peers: vec![non_full],
..MockRoutingView::new(false)
};
assert_eq!(
choose_candidate(&only_non_full, &dest, &dest_coords, &my_coords),
None,
"with no Full candidate there is no bloom next hop at all"
);
}
+25 -18
View File
@@ -5,8 +5,9 @@ use crate::proto::routing::{NextHop, RoutingView};
use crate::testutil::make_node_addr;
use crate::{NodeAddr, TreeCoordinate};
/// A mock peer for the candidate-assembly seam: the set of destinations its
/// bloom filter reaches, its send state, link cost, and tree coordinates.
/// A mock peer for the routing seam: the set of destinations its bloom filter
/// reaches, its send state, link cost, tree coordinates and node profile.
#[derive(Clone)]
pub(super) struct MockPeer {
pub(super) addr: NodeAddr,
pub(super) reach: Vec<NodeAddr>,
@@ -32,13 +33,14 @@ impl MockRoutingView {
peers: Vec::new(),
}
}
fn peer(&self, addr: &NodeAddr) -> Option<&MockPeer> {
self.peers.iter().find(|p| p.addr == *addr)
}
}
impl RoutingView for MockRoutingView {
type Peer<'a>
= &'a MockPeer
where
Self: 'a;
fn is_congested(&self, _next_hop: &NodeAddr) -> bool {
self.congested
}
@@ -48,23 +50,28 @@ impl RoutingView for MockRoutingView {
.find(|(addr, _)| addr == dest)
.map(|(_, coords)| coords.clone())
}
fn peer_addrs(&self) -> Vec<NodeAddr> {
self.peers.iter().map(|p| p.addr).collect()
fn for_each_peer<'a>(&'a self, mut visitor: impl FnMut(Self::Peer<'a>)) {
for peer in &self.peers {
visitor(peer);
}
}
fn peer_may_reach(&self, peer: &NodeAddr, dest: &NodeAddr) -> bool {
self.peer(peer).is_some_and(|p| p.reach.contains(dest))
fn peer_addr<'a>(&'a self, peer: Self::Peer<'a>) -> NodeAddr {
peer.addr
}
fn peer_can_send(&self, peer: &NodeAddr) -> bool {
self.peer(peer).is_some_and(|p| p.can_send)
fn peer_may_reach<'a>(&'a self, peer: Self::Peer<'a>, dest: &NodeAddr) -> bool {
peer.reach.contains(dest)
}
fn peer_link_cost(&self, peer: &NodeAddr) -> f64 {
self.peer(peer).map_or(f64::INFINITY, |p| p.link_cost)
fn peer_can_send<'a>(&'a self, peer: Self::Peer<'a>) -> bool {
peer.can_send
}
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate> {
self.peer(peer).and_then(|p| p.coords.clone())
fn peer_link_cost<'a>(&'a self, peer: Self::Peer<'a>) -> f64 {
peer.link_cost
}
fn peer_is_full(&self, peer: &NodeAddr) -> bool {
self.peer(peer).is_some_and(|p| p.is_full)
fn peer_coords<'a>(&'a self, peer: Self::Peer<'a>) -> Option<&'a TreeCoordinate> {
peer.coords.as_ref()
}
fn peer_is_full<'a>(&'a self, peer: Self::Peer<'a>) -> bool {
peer.is_full
}
}