use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures::future::BoxFuture;
use ntk_hooking::{ArcPhase, CoordinatorClient as HookingCoordinatorClient, EvaluateEnterRequest};
use ntk_neighborhood::{
Arc as NeighborArc, FakeIpRouteManager, FixedRttProbe, LocalNic, NeighborhoodConfig,
NeighborhoodRpcHandler, NeighborhoodStubFactory, NeighborhoodTiming, NodeId,
};
use ntk_netlink::{FakeNetlink, LinkInfo, Operation};
use ntk_proto::v1::{CallerContext, MethodCall, RemoteError, ResponsePayload, TypedValue};
use ntk_rpc::{FakeRpcClient, RpcClient, RpcHandler};
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use crate::kernel::config::NtkdConfig;
use crate::node::adapters::CoordinatorClientAdapter;
use crate::node::dispatch::Dispatcher;
use crate::node::lifecycle::{self, Dialer, NodeInputs, StartedNode};
use crate::node::peers::PeerLinks;
use crate::node::registry::LinkRegistry;
fn config(gsizes: &str) -> NtkdConfig {
NtkdConfig::from_str(&format!("gsizes = {gsizes}\nnics = []\nport = 269\n")).unwrap()
}
const TEST_MERGE_DECISION_TTL: Duration = Duration::from_millis(200);
#[derive(Debug, Default)]
struct Medium {
domain: Mutex<Vec<(String, ntk_neighborhood::Handle)>>,
dispatchers: Mutex<HashMap<u8, Arc<Dispatcher>>>,
}
impl Medium {
fn join(&self, node_id: &str, handle: ntk_neighborhood::Handle) {
self.domain
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((node_id.to_owned(), handle));
}
fn peers_of(&self, self_id: &str) -> Vec<ntk_neighborhood::Handle> {
self.domain
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.filter(|(id, _)| id != self_id)
.map(|(_, h)| h.clone())
.collect()
}
fn peer_by_id(&self, self_id: &str, neighbour_id: NodeId) -> Option<ntk_neighborhood::Handle> {
self.domain
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.filter(|(id, _)| id != self_id)
.find(|(id, _)| {
id.strip_prefix("node-")
.and_then(|idx| idx.parse::<i32>().ok())
.and_then(|idx| NodeId::from_raw(idx + 1).ok())
== Some(neighbour_id)
})
.map(|(_, h)| h.clone())
}
fn register_dispatcher(&self, idx: u32, dispatcher: Arc<Dispatcher>) {
self.dispatchers
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(idx as u8, dispatcher);
}
fn dispatcher_for_addr(&self, addr: &str) -> Option<Arc<Dispatcher>> {
let ip: std::net::Ipv4Addr = addr.parse().ok()?;
self.dispatchers
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&ip.octets()[2])
.cloned()
}
}
#[derive(Debug)]
struct FanOutHandler(Vec<NeighborhoodRpcHandler>);
impl RpcHandler for FanOutHandler {
fn handle<'a>(
&'a self,
caller: CallerContext,
unicast_id: TypedValue,
call: MethodCall,
auth: Option<ntk_proto::v1::Auth>,
) -> BoxFuture<'a, Result<ResponsePayload, RemoteError>> {
Box::pin(async move {
for target in self.0.clone() {
let caller = caller.clone();
let unicast_id = unicast_id.clone();
let call = call.clone();
let auth = auth.clone();
tokio::spawn(async move {
let _ = target.handle(caller, unicast_id, call, auth).await;
});
}
Ok(ResponsePayload::default())
})
}
}
#[derive(Debug)]
struct TestNeighborhoodStubFactory {
self_id: String,
medium: Arc<Medium>,
}
impl NeighborhoodStubFactory for TestNeighborhoodStubFactory {
fn broadcast(&self, dev: &str) -> Arc<dyn RpcClient> {
let targets = self
.medium
.peers_of(&self.self_id)
.into_iter()
.map(|h| NeighborhoodRpcHandler::for_broadcast(h, dev.to_owned()))
.collect();
Arc::new(FakeRpcClient::new(Arc::new(FanOutHandler(targets))))
}
fn unicast(&self, arc: &NeighborArc) -> Arc<dyn RpcClient> {
let peer = self
.medium
.peer_by_id(&self.self_id, arc.neighbour_id)
.expect("test medium: no peer matching this arc's neighbour_id");
Arc::new(FakeRpcClient::new(Arc::new(
NeighborhoodRpcHandler::for_unicast(peer),
)))
}
}
#[derive(Debug)]
struct TestDialer {
medium: Arc<Medium>,
}
impl Dialer for TestDialer {
fn dial(&self, addr: &str, _port: u16) -> BoxFuture<'_, Option<Arc<dyn RpcClient>>> {
let target = self.medium.dispatcher_for_addr(addr);
Box::pin(
async move { target.map(|d| Arc::new(FakeRpcClient::new(d)) as Arc<dyn RpcClient>) },
)
}
}
fn addr_allocator(idx: u32) -> Box<dyn FnMut() -> String + Send> {
let mut n = 0u8;
Box::new(move || {
n += 1;
std::net::Ipv4Addr::new(10, 88, idx as u8, n).to_string()
})
}
struct SimNode {
_tasks: JoinSet<()>,
_cancel: CancellationToken,
kernel: Arc<FakeNetlink>,
started: StartedNode<FakeNetlink>,
}
impl SimNode {
fn qspn(&self) -> ntk_qspn::QspnHandle {
self.started.running.generation.borrow().qspn.clone()
}
fn hooking(&self) -> &ntk_hooking::HookingHandle {
&self.started.running.hooking
}
}
async fn spawn_node(
idx: u32,
gsizes: &str,
initial_position: Option<Vec<u32>>,
medium: &Arc<Medium>,
) -> SimNode {
let links = vec![
LinkInfo {
index: 1,
name: "lo".into(),
is_up: true,
},
LinkInfo {
index: 2,
name: "eth0".into(),
is_up: true,
},
];
let neighborhood_kernel = FakeNetlink::with_links(links.clone());
let routing_kernel = Arc::new(FakeNetlink::with_links(links));
let self_id = format!("node-{idx}");
let stub_factory = Arc::new(TestNeighborhoodStubFactory {
self_id: self_id.clone(),
medium: medium.clone(),
});
let my_id = NodeId::from_raw((idx + 1) as i32).unwrap();
let neighborhood_config = NeighborhoodConfig {
my_id,
max_arcs: 8,
kernel: neighborhood_kernel,
stub_factory,
ip_route_manager: Arc::new(FakeIpRouteManager::new()),
rtt_probe: Arc::new(FixedRttProbe(Some(10))),
timing: NeighborhoodTiming {
radar_interval: Duration::from_millis(15),
arc_monitor_interval: (Duration::from_millis(1), Duration::from_millis(3)),
},
new_linklocal_address: addr_allocator(idx),
signing_key: None,
require_auth: false,
};
let mut tasks = JoinSet::new();
let cancel = CancellationToken::new();
let (neighborhood, neighborhood_join) =
ntk_neighborhood::Manager::spawn(neighborhood_config, cancel.child_token());
tasks.spawn(async move {
let _ = neighborhood_join.await;
});
medium.join(&self_id, neighborhood.clone());
let dialer = Arc::new(TestDialer {
medium: medium.clone(),
});
let started = lifecycle::run(
NodeInputs {
config: config(gsizes),
neighborhood: neighborhood.clone(),
registry: Arc::new(LinkRegistry::new()),
links: Arc::new(PeerLinks::new()),
routing_kernel: routing_kernel.clone(),
dialer,
initial_position,
preformed: None,
my_id,
},
&mut tasks,
cancel.child_token(),
)
.await
.expect("lifecycle::run");
tokio::time::sleep(Duration::from_millis(5)).await;
neighborhood
.start_monitor(LocalNic {
dev: "eth0".to_owned(),
mac: format!("02:00:00:00:{idx:02x}:00"),
})
.await
.expect("start_monitor");
medium.register_dispatcher(idx, started.dispatcher.clone());
SimNode {
_tasks: tasks,
_cancel: cancel,
kernel: routing_kernel,
started,
}
}
async fn wait_until(mut check: impl FnMut() -> bool, timeout: Duration) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if check() {
return true;
}
if tokio::time::Instant::now() >= deadline {
return check();
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
fn address_and_rule_ops(kernel: &FakeNetlink) -> (usize, usize, usize, usize) {
let ops = kernel.operations();
let adds = ops
.iter()
.filter(|o| matches!(o, Operation::AddAddress { .. }))
.count();
let removes = ops
.iter()
.filter(|o| matches!(o, Operation::RemoveAddress { .. }))
.count();
let rule_adds = ops
.iter()
.filter(|o| matches!(o, Operation::AddRule(_)))
.count();
let rule_removes = ops
.iter()
.filter(|o| matches!(o, Operation::RemoveRule(_)))
.count();
(adds, removes, rule_adds, rule_removes)
}
#[tokio::test]
async fn lone_identity_stays_at_its_own_position_and_stays_hooked() {
let medium = Arc::new(Medium::default());
let node = spawn_node(0, "[2]", None, &medium).await;
let initial = node.qspn().my_naddr().positions().to_vec();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(node.qspn().my_naddr().positions(), initial.as_slice());
let snap = node.hooking().snapshot();
assert!(snap.hooked, "create_net is hooked from construction");
}
#[tokio::test]
async fn single_node_coordinator_reserves_its_own_position_but_never_self_answers_entry() {
let medium = Arc::new(Medium::default());
let node = spawn_node(0, "[8]", None, &medium).await;
let my_pos = node.qspn().my_naddr().positions().to_vec();
let dht = ntk_coordinator::CoordinatorClient::new(
node.started.running.generation.borrow().peers.clone(),
ntk_coordinator::Config::default(),
);
let adapter = CoordinatorClientAdapter::new(
dht.clone(),
node.started.running.generation.borrow().coordinator.clone(),
node.qspn(),
node.started.running.net.clone(),
TEST_MERGE_DECISION_TTL,
);
let n_nodes = HookingCoordinatorClient::n_nodes(&adapter).await;
assert_eq!(
n_nodes, 1,
"a network-of-one's own coordinator counts exactly itself"
);
let evaluate = HookingCoordinatorClient::evaluate_enter(
&adapter,
EvaluateEnterRequest {
network_id: 42,
neighbor_pos: my_pos.clone(),
neighbor_min_lvl: 0,
min_lvl: 0,
evaluate_enter_id: 1,
},
)
.await;
assert!(
evaluate.is_err(),
"a solitary node has no host to enter — evaluate_enter must fail, never silently \
answer itself: {evaluate:?}"
);
let reservation = HookingCoordinatorClient::reserve(&adapter, 1, 7)
.await
.expect("a virgin network-of-one's own coordinator resolves its own reservation");
assert_ne!(
reservation.pos, my_pos[0],
"a fresh reservation must never collide with a position this identity already holds"
);
}
#[tokio::test]
async fn discovering_a_peer_joins_and_adopts_the_negotiated_position() {
let medium = Arc::new(Medium::default());
let node0 = spawn_node(0, "[8]", None, &medium).await;
let node1 = spawn_node(1, "[8]", None, &medium).await;
let pos0 = node0.qspn().my_naddr().positions().to_vec();
let pos1 = node1.qspn().my_naddr().positions().to_vec();
let moved = wait_until(
|| {
node0.qspn().my_naddr().positions() != pos0
|| node1.qspn().my_naddr().positions() != pos1
},
Duration::from_secs(30),
)
.await;
assert!(moved, "neither node ever adopted a negotiated position");
let (winner, loser) = if node0.qspn().my_naddr().positions() == pos0 {
(&node0, &node1)
} else {
(&node1, &node0)
};
let loser_snapshot = loser.hooking().snapshot();
let chosen = loser_snapshot
.chosen
.expect("finish_enter resolved a chosen address");
let negotiated = chosen
.naddr
.expect("single-level entry always resolves a full Naddr");
assert_eq!(loser.qspn().my_naddr().positions(), negotiated.positions());
assert_ne!(
negotiated.positions(),
winner.qspn().my_naddr().positions().to_owned().as_slice()
);
let installed = wait_until(
|| address_and_rule_ops(&loser.kernel).0 == 2,
Duration::from_secs(10),
)
.await;
assert!(
installed,
"loser never installed its negotiated generation's kernel address: {:?}",
address_and_rule_ops(&loser.kernel)
);
let (adds, removes, rule_adds, rule_removes) = address_and_rule_ops(&loser.kernel);
assert_eq!(
adds, 2,
"trivial generation + negotiated generation, each installs one address"
);
assert_eq!(
removes, 1,
"exactly one teardown of the trivial generation's address"
);
assert_eq!(rule_adds, 2);
assert_eq!(
rule_removes, 1,
"exactly one teardown of the trivial generation's rule"
);
}
#[tokio::test]
async fn two_virgin_daemons_merge_into_one_network_on_a_multi_level_topology() {
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.with_test_writer()
.try_init();
let medium = Arc::new(Medium::default());
let node0 = spawn_node(0, "[4, 2, 2, 2]", None, &medium).await;
let node1 = spawn_node(1, "[4, 2, 2, 2]", None, &medium).await;
let merged = wait_until(
|| {
let a = node0.qspn().my_naddr().positions().to_vec();
let b = node1.qspn().my_naddr().positions().to_vec();
a[1..] == b[1..] && a[0] != b[0]
},
Duration::from_secs(30),
)
.await;
let a = node0.qspn().my_naddr().positions().to_vec();
let b = node1.qspn().my_naddr().positions().to_vec();
let phases0 = node0.hooking().snapshot().arcs.clone();
let phases1 = node1.hooking().snapshot().arcs.clone();
assert!(
merged,
"the two daemons never merged into one network: node0 at {a:?}, node1 at {b:?} — \
levels above the innermost must agree for these to be one network\n\
node0 arc phases: {phases0:?}\n\
node1 arc phases: {phases1:?}"
);
}
#[tokio::test]
async fn incompatible_topology_never_hooks_and_never_wedges() {
let medium = Arc::new(Medium::default());
let node0 = spawn_node(0, "[2]", None, &medium).await;
let node1 = spawn_node(1, "[3]", None, &medium).await;
let pos0 = node0.qspn().my_naddr().positions().to_vec();
let pos1 = node1.qspn().my_naddr().positions().to_vec();
let reached = wait_until(
|| {
node0
.hooking()
.snapshot()
.arcs
.values()
.any(|p| matches!(p, ArcPhase::IncompatibleTopology))
},
Duration::from_secs(5),
)
.await;
assert!(reached, "the arc never reached IncompatibleTopology");
for (node, initial) in [(&node0, &pos0), (&node1, &pos1)] {
assert_eq!(
node.qspn().my_naddr().positions(),
initial.as_slice(),
"no wedge: still routable at its own create_net position"
);
let snap = node.hooking().snapshot();
assert!(
snap.hooked,
"create_net is hooked from construction, unaffected by the arc"
);
let (_, removes, _, rule_removes) = address_and_rule_ops(&node.kernel);
assert_eq!(
removes, 0,
"no rehook ever fired, so nothing was ever torn down"
);
assert_eq!(rule_removes, 0);
}
}
#[tokio::test]
async fn decide_merge_agrees_across_members_within_the_freshness_window() {
let medium = Arc::new(Medium::default());
let node = spawn_node(0, "[8]", None, &medium).await;
let dht = ntk_coordinator::CoordinatorClient::new(
node.started.running.generation.borrow().peers.clone(),
ntk_coordinator::Config::default(),
);
let coordinator = node.started.running.generation.borrow().coordinator.clone();
let members: Vec<CoordinatorClientAdapter> = (0..3)
.map(|_| {
CoordinatorClientAdapter::new(
dht.clone(),
coordinator.clone(),
node.qspn(),
node.started.running.net.clone(),
TEST_MERGE_DECISION_TTL,
)
})
.collect();
let samples = [100u64, 0, 55];
let mut verdicts = Vec::with_capacity(3);
for (member, &neighbor_n_nodes) in members.iter().zip(&samples) {
let req = ntk_hooking::MergeArbitrationRequest {
my_network_id: 1,
neighbor_network_id: 42,
neighbor_n_nodes,
};
verdicts.push(HookingCoordinatorClient::decide_merge(member, req).await);
}
assert!(
verdicts.iter().all(|&v| v == verdicts[0]),
"every member of the same g-node must agree on the same target within the freshness \
window, regardless of each one's own differing local sample: {verdicts:?}"
);
}
#[tokio::test]
async fn decide_merge_reaches_exactly_one_migrator_and_the_winner_never_flips_within_its_window() {
let medium_a = Arc::new(Medium::default());
let medium_b = Arc::new(Medium::default());
let node_a = spawn_node(0, "[8]", None, &medium_a).await;
let node_b = spawn_node(0, "[8]", None, &medium_b).await;
let dht_a = ntk_coordinator::CoordinatorClient::new(
node_a.started.running.generation.borrow().peers.clone(),
ntk_coordinator::Config::default(),
);
let adapter_a = CoordinatorClientAdapter::new(
dht_a,
node_a
.started
.running
.generation
.borrow()
.coordinator
.clone(),
node_a.qspn(),
node_a.started.running.net.clone(),
TEST_MERGE_DECISION_TTL,
);
let dht_b = ntk_coordinator::CoordinatorClient::new(
node_b.started.running.generation.borrow().peers.clone(),
ntk_coordinator::Config::default(),
);
let adapter_b = CoordinatorClientAdapter::new(
dht_b,
node_b
.started
.running
.generation
.borrow()
.coordinator
.clone(),
node_b.qspn(),
node_b.started.running.net.clone(),
TEST_MERGE_DECISION_TTL,
);
let (small_id, large_id) = (1_i64, 2_i64);
let ask_a = ntk_hooking::MergeArbitrationRequest {
my_network_id: small_id,
neighbor_network_id: large_id,
neighbor_n_nodes: 1,
};
let ask_b = ntk_hooking::MergeArbitrationRequest {
my_network_id: large_id,
neighbor_network_id: small_id,
neighbor_n_nodes: 1,
};
let a_migrates = HookingCoordinatorClient::decide_merge(&adapter_a, ask_a).await;
let b_migrates = HookingCoordinatorClient::decide_merge(&adapter_b, ask_b).await;
assert!(
a_migrates,
"the smaller network_id must be the one that migrates"
);
assert!(
!b_migrates,
"the larger network_id must stay — never both sides migrating (mutual surrender)"
);
let stale_reask = ntk_hooking::MergeArbitrationRequest {
my_network_id: large_id,
neighbor_network_id: small_id,
neighbor_n_nodes: 1_000,
};
let b_still_declines = HookingCoordinatorClient::decide_merge(&adapter_b, stale_reask).await;
assert!(
!b_still_declines,
"a stale re-announcement of the foreign network, within the freshness window, must not \
reopen a closed arbitration"
);
}
#[tokio::test]
async fn decide_merge_re_evaluates_once_its_freshness_window_elapses() {
let medium = Arc::new(Medium::default());
let node = spawn_node(0, "[8]", None, &medium).await;
let dht = ntk_coordinator::CoordinatorClient::new(
node.started.running.generation.borrow().peers.clone(),
ntk_coordinator::Config::default(),
);
let coordinator = node.started.running.generation.borrow().coordinator.clone();
let short_ttl = Duration::from_millis(20);
let adapter = CoordinatorClientAdapter::new(
dht,
coordinator,
node.qspn(),
node.started.running.net.clone(),
short_ttl,
);
let grew = ntk_hooking::MergeArbitrationRequest {
my_network_id: 1,
neighbor_network_id: 99,
neighbor_n_nodes: 100,
};
assert!(
HookingCoordinatorClient::decide_merge(&adapter, grew).await,
"a lone node must yield to a 100-node neighbor"
);
tokio::time::sleep(short_ttl * 3).await;
let shrank = ntk_hooking::MergeArbitrationRequest {
my_network_id: 1,
neighbor_network_id: 99,
neighbor_n_nodes: 0,
};
let re_evaluated = HookingCoordinatorClient::decide_merge(&adapter, shrank).await;
assert!(
!re_evaluated,
"once the freshness window elapses, a real size change on the neighbor's side must be \
picked up, not buried under the earlier stale verdict"
);
}
#[tokio::test]
async fn decide_merge_after_an_aborted_entry_redecides_from_fresh_data() {
let medium = Arc::new(Medium::default());
let node = spawn_node(0, "[8]", None, &medium).await;
let dht = ntk_coordinator::CoordinatorClient::new(
node.started.running.generation.borrow().peers.clone(),
ntk_coordinator::Config::default(),
);
let coordinator = node.started.running.generation.borrow().coordinator.clone();
let short_ttl = Duration::from_millis(20);
let adapter = CoordinatorClientAdapter::new(
dht,
coordinator,
node.qspn(),
node.started.running.net.clone(),
short_ttl,
);
let (my_id, neighbor_id) = (1_i64, 2_i64);
let first_try = ntk_hooking::MergeArbitrationRequest {
my_network_id: my_id,
neighbor_network_id: neighbor_id,
neighbor_n_nodes: 1,
};
assert!(
HookingCoordinatorClient::decide_merge(&adapter, first_try).await,
"a tie must be broken toward the larger network id"
);
tokio::time::sleep(short_ttl * 3).await;
let redo = ntk_hooking::MergeArbitrationRequest {
my_network_id: my_id,
neighbor_network_id: neighbor_id,
neighbor_n_nodes: 1000,
};
assert!(
HookingCoordinatorClient::decide_merge(&adapter, redo).await,
"a redecide after abort must still say yes here — but from the fresh 1000-node reading, \
not a frozen replay of the earlier tie-broken verdict"
);
}