Stop a leaf node from self-electing as tree root

A leaf-profile node that held the smallest NodeAddr would self-elect as
tree root, but its peers refuse a non-full node as a parent, so it formed
an isolated second root and partitioned the mesh: a multi-hop session from
the leaf to a non-adjacent full node then failed because the far node could
not route a handshake reply back into the leaf's separate coordinate tree.

Gate tree-state self-election on a new self_is_leaf flag, set from the node
profile at construction. A leaf now attaches under its full upstream and
holds that subtree's coordinate for its own routing. That coordinate's
self < root would be rejected by the root-min wire check, but a leaf never
announces it; it reaches peers only via the coordinates carried on the
leaf's session frames, and the upstream already advertises the leaf in its
bloom filter, so the far node routes back through the upstream.

Also fix Node::with_identity to derive the node profile, leaf-only flag,
and bloom state from the config, matching Node::new; it previously
hardcoded the full profile and silently dropped a configured leaf or
non-routing profile.

Covered by sans-IO decision tests (leaf does not self-elect; leaf keeps its
coordinate under a larger-rooted parent) and an end-to-end multi-hop
regression, each confirmed to fail if the gate is reverted.
This commit is contained in:
Johnathan Corgan
2026-07-24 14:42:08 +00:00
parent bcfe9270bb
commit 694d03375b
5 changed files with 286 additions and 8 deletions
+68 -1
View File
@@ -98,9 +98,40 @@ pub(super) async fn make_test_node_with_profile(
make_test_node_inner(config, 1280).await
}
/// Create a test node with a specific profile AND a caller-chosen identity, so
/// a test can pin which node holds the smallest NodeAddr (e.g. force a Leaf to
/// be the numerically smallest node and reproduce the root-election partition
/// deterministically).
pub(super) async fn make_test_node_with_profile_and_identity(
profile: crate::proto::fmp::NodeProfile,
identity: Identity,
) -> TestNode {
use crate::proto::fmp::NodeProfile;
let mut config = Config::new();
match profile {
NodeProfile::Leaf => config.node.leaf_only = true,
NodeProfile::NonRouting => config.node.disable_routing = true,
NodeProfile::Full => {}
}
make_test_node_inner_with_identity(config, 1280, Some(identity)).await
}
/// Shared builder: a test node from an explicit `Config` and transport MTU.
async fn make_test_node_inner(config: Config, mtu: u16) -> TestNode {
let mut node = make_node_with(config);
make_test_node_inner_with_identity(config, mtu, None).await
}
/// Shared builder with an optional caller-chosen identity. `None` generates a
/// fresh random identity (the default); `Some` pins it via `Node::with_identity`.
async fn make_test_node_inner_with_identity(
config: Config,
mtu: u16,
identity: Option<Identity>,
) -> TestNode {
let mut node = match identity {
Some(id) => Node::with_identity(id, config).expect("build node with identity"),
None => make_node_with(config),
};
let transport_id = TransportId::new(1);
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ReceivedPacket>();
@@ -815,6 +846,42 @@ pub(super) async fn run_tree_test_with_profiles(
nodes
}
/// Like `run_tree_test_with_profiles`, but pins node identities so the node at
/// `leaf_idx` holds the strictly smallest NodeAddr in the mesh.
///
/// This makes the root-election partition deterministic: a Leaf (or any non-Full
/// node) that holds the smallest NodeAddr is the one that would self-elect as a
/// second root. Without the leaf gate the resulting mesh partitions; with it the
/// Leaf attaches under its Full upstream and the tree stays connected.
pub(super) async fn run_tree_test_with_profiles_leaf_smallest(
profiles: &[crate::proto::fmp::NodeProfile],
leaf_idx: usize,
edges: &[(usize, usize)],
) -> Vec<TestNode> {
// One identity per node; move the smallest-addr identity into the leaf slot.
let mut ids: Vec<Identity> = (0..profiles.len()).map(|_| Identity::generate()).collect();
let smallest = (0..ids.len())
.min_by(|&a, &b| ids[a].node_addr().cmp(ids[b].node_addr()))
.expect("non-empty");
ids.swap(leaf_idx, smallest);
assert!(
(0..ids.len()).all(|i| i == leaf_idx || ids[leaf_idx].node_addr() < ids[i].node_addr()),
"leaf must hold the unique smallest NodeAddr"
);
let mut nodes = Vec::with_capacity(profiles.len());
for (i, &profile) in profiles.iter().enumerate() {
nodes.push(make_test_node_with_profile_and_identity(profile, ids[i].clone()).await);
}
for &(i, j) in edges {
initiate_handshake(&mut nodes, i, j).await;
}
let total = drain_all_packets(&mut nodes, false).await;
assert!(total > 0, "Should have processed at least some packets");
repair_missing_edge_handshakes(&mut nodes, edges, false).await;
nodes
}
/// Like `run_tree_test` but with per-node transport MTUs.
///
/// `mtus` must have one entry per node. Used for heterogeneous-MTU tests