Finding
run_stale_detection calls proc.topology_mut().remove_stale_links(stale_timeout * 3) but never calls remove_stale_nodes. remove_stale_links removes edges from the petgraph and leaves node_index entries intact; node eviction only happens in remove_stale_nodes. The two methods have deliberately different semantics, and skipping the node-removal step means node_count(), contains_node(), neighbors(), shortest_path(), and connected_components() all continue to see dead nodes indefinitely. There is no call to remove_stale_nodes anywhere in non-test production code.
Evidence
crates/kerykeion/src/discovery.rs:209 — only edges are pruned:
proc.topology_mut().remove_stale_links(stale_timeout * 3);
crates/kerykeion/src/topology.rs:88 — remove_stale_nodes is the only path that evicts node_index entries, and it is never called in production:
pub fn remove_stale_nodes(&mut self, timeout: Duration) {
let cutoff = Instant::now() - timeout;
let stale: Vec<NodeNum> = self
.node_index
.iter()
Why this matters
A node that goes offline persists as a phantom in node_index forever. connected_components() then returns a singleton component for every dead node, triggering spurious PartitionDetected signals that propagate to consumers and corrupt the operator's picture of the mesh during the exact moments (nodes dropping out) that matter most under a counter-surveillance threat model. The accumulation is unbounded over long uptimes, and an adversary flooding unique source node IDs can inflate the phantom node count and the false-partition signal rate at will.
Desired correction
After remove_stale_links, call remove_stale_nodes with the same or a shorter timeout so node_index stays consistent with the edge set. Preferably unify both steps into a single prune_stale(timeout) method on MeshTopology that always removes edges first then orphan nodes, making it impossible to call one without the other.
Done when: run_stale_detection (or its equivalent) removes both stale edges and stale nodes in the correct order, and a test verifies that a node with no live edges is absent from node_count() after a cleanup cycle.
Finding
run_stale_detectioncallsproc.topology_mut().remove_stale_links(stale_timeout * 3)but never callsremove_stale_nodes.remove_stale_linksremoves edges from the petgraph and leavesnode_indexentries intact; node eviction only happens inremove_stale_nodes. The two methods have deliberately different semantics, and skipping the node-removal step meansnode_count(),contains_node(),neighbors(),shortest_path(), andconnected_components()all continue to see dead nodes indefinitely. There is no call toremove_stale_nodesanywhere in non-test production code.Evidence
crates/kerykeion/src/discovery.rs:209— only edges are pruned:crates/kerykeion/src/topology.rs:88—remove_stale_nodesis the only path that evictsnode_indexentries, and it is never called in production:Why this matters
A node that goes offline persists as a phantom in
node_indexforever.connected_components()then returns a singleton component for every dead node, triggering spuriousPartitionDetectedsignals that propagate to consumers and corrupt the operator's picture of the mesh during the exact moments (nodes dropping out) that matter most under a counter-surveillance threat model. The accumulation is unbounded over long uptimes, and an adversary flooding unique source node IDs can inflate the phantom node count and the false-partition signal rate at will.Desired correction
After
remove_stale_links, callremove_stale_nodeswith the same or a shorter timeout sonode_indexstays consistent with the edge set. Preferably unify both steps into a singleprune_stale(timeout)method onMeshTopologythat always removes edges first then orphan nodes, making it impossible to call one without the other.Done when:
run_stale_detection(or its equivalent) removes both stale edges and stale nodes in the correct order, and a test verifies that a node with no live edges is absent fromnode_count()after a cleanup cycle.