Test cost-based parent selection and kernel-drop detection as unit tests

The cost-selection chaos scenarios (cost-reeval, cost-avoidance,
cost-stability, depth-vs-cost, mixed-technology, bottleneck-parent) tested
TreeState::evaluate_parent's decision logic through a Docker mesh that could
not exercise it reliably: the tree roots at whichever node holds the smallest
NodeAddr, MMP link costs take several measurement windows to settle, and the
parent hold-down plus hysteresis timing all confound the outcome. A
deterministic link-cost flap still produced zero periodic parent switches in a
full run.

Replace those six scenarios with deterministic unit tests in src/tree/tests.rs
that drive evaluate_parent directly: cheaper-link selection at equal depth,
switch-on-cost-change, hysteresis suppressing a marginal change while allowing
a significant one, and the depth-versus-cost effective-depth tradeoff. Each is
constructed so that breaking the cost or hysteresis logic makes it fail.

The congestion kernel-drop signal (SO_RXQ_OVFL) cannot be provoked
deterministically in Docker: a fresh daemon reader keeps up with
container-speed traffic, so the socket receive queue never overflows (an
unshaped run with a 4 KB buffer and heavy traffic recorded zero drops on every
node). Extract the drop-detection edge -- read the cumulative counter, fire an
event only on the transition into a new drop burst -- into
TransportDropState::observe_drops and unit-test it directly. congestion-stress
keeps its ECN and MMP congestion-signal assertions, which do need the real
shaped bottleneck queue.

Remove the retired scenarios from both CI runners and update the chaos README.
This commit is contained in:
Johnathan Corgan
2026-07-23 23:33:01 +00:00
parent be5deee814
commit 08a226fb63
16 changed files with 299 additions and 824 deletions
-18
View File
@@ -412,24 +412,6 @@ jobs:
- suite: tcp-mesh
type: chaos
scenario: tcp-mesh
- suite: bottleneck-parent
type: chaos
scenario: bottleneck-parent
- suite: cost-avoidance
type: chaos
scenario: cost-avoidance
- suite: cost-reeval
type: chaos
scenario: cost-reeval
- suite: cost-stability
type: chaos
scenario: cost-stability
- suite: depth-vs-cost
type: chaos
scenario: depth-vs-cost
- suite: mixed-technology
type: chaos
scenario: mixed-technology
- suite: congestion-stress
type: chaos
scenario: congestion-stress
+4 -7
View File
@@ -417,13 +417,10 @@ impl Node {
for (&tid, transport) in &self.transports {
let congestion = transport.congestion();
let state = self.transport_drops.entry(tid).or_default();
if let Some(current) = congestion.recv_drops {
let new_drops = current > state.prev_drops;
if new_drops && !state.dropping {
new_drop_events.push(tid);
}
state.dropping = new_drops;
state.prev_drops = current;
if let Some(current) = congestion.recv_drops
&& state.observe_drops(current)
{
new_drop_events.push(tid);
}
}
for tid in new_drop_events {
+26
View File
@@ -266,6 +266,32 @@ struct TransportDropState {
dropping: bool,
}
impl TransportDropState {
/// Fold a new cumulative `recv_drops` sample into the state and report
/// whether it marks the *transition* into a dropping condition.
///
/// Returns true only on the edge where the cumulative `SO_RXQ_OVFL`
/// counter rose since the previous sample **and** the transport was not
/// already flagged as dropping. That edge is what `kernel_drop_events`
/// counts: a first observation of a new drop burst, not every sample in
/// which the counter happens to be non-zero. A sample with no rise
/// clears the flag, so a later rise counts as a fresh event.
///
/// Pure and sans-IO by design: the tick handler reads the kernel
/// counter from the socket and does the logging, but the detection
/// decision lives here so it can be tested without a socket, a
/// transport, or a running node — which is the only way it can be
/// tested at all, since the kernel drop itself cannot be provoked
/// deterministically.
fn observe_drops(&mut self, current: u64) -> bool {
let rose = current > self.prev_drops;
let new_event = rose && !self.dropping;
self.dropping = rose;
self.prev_drops = current;
new_event
}
}
/// State for a link waiting for transport-level connection establishment.
///
/// For connection-oriented transports (TCP, Tor), the transport connect runs
+37
View File
@@ -1995,3 +1995,40 @@ async fn handle_msg1_admits_existing_peer_at_cap() {
"rate limiter must rebalance after the (bypass-admitted) handler returns"
);
}
// ===== Transport kernel-drop detection (sans-IO) =====
//
// The drop-detection edge-detector, tested directly. It replaces the
// congestion-drops docker scenario, which could not provoke SO_RXQ_OVFL
// deterministically (a fresh daemon reader keeps up with container-speed
// traffic, so the kernel never overflows the socket queue). The kernel
// dropping datagrams is not FIPS behaviour to test; the FIPS behaviour is
// reading the SO_RXQ_OVFL counter and firing kernel_drop_events on the
// transition into a new drop burst, which is exactly this decision.
#[test]
fn test_transport_drop_state_fires_on_edge_and_rearms() {
let mut s = TransportDropState::default();
// Cumulative counter still 0: no rise, no event.
assert!(!s.observe_drops(0));
// First rise (0 -> 5): a new drop burst is observed, so it fires.
assert!(s.observe_drops(5));
// Counter keeps rising (5 -> 9) but we are already dropping: this is
// the "first observed" contract, so it must NOT fire again.
assert!(!s.observe_drops(9));
// A sample with no further rise clears the dropping flag (no event).
assert!(!s.observe_drops(9));
// A later rise (9 -> 12) is a fresh burst and fires again.
assert!(s.observe_drops(12));
}
#[test]
fn test_transport_drop_state_steady_counter_fires_once() {
let mut s = TransportDropState::default();
// A cumulative counter that jumps once and then holds steady must
// register exactly one event, not one per sample — otherwise a single
// historical drop burst would report congestion forever.
assert!(s.observe_drops(7));
assert!(!s.observe_drops(7));
assert!(!s.observe_drops(7));
}
+166
View File
@@ -536,6 +536,172 @@ fn test_evaluate_parent_picks_loop_free_over_loopy() {
assert_eq!(result, Some(peer2));
}
// ===== Cost-based parent selection =====
//
// These exercise evaluate_parent's MMP-cost path directly, as a sans-IO
// unit test of the exact decision. They replace six Docker chaos
// scenarios — cost-reeval, cost-avoidance, cost-stability, depth-vs-cost,
// mixed-technology and bottleneck-parent — whose subject was this
// decision but which could not test it reliably: the mesh's root is
// whichever node holds the smallest NodeAddr, MMP costs take several
// measurement windows to settle, and hold-down plus hysteresis timing all
// confounded the assertion. Here the peer ancestry, depths and costs are
// constructed directly, so the decision is deterministic and each check
// can fail on a real regression.
#[test]
fn test_evaluate_parent_cost_prefers_cheaper_link_at_equal_depth() {
// mixed-technology / cost-avoidance subject: two candidate parents at
// the SAME depth, one over a cheap (fiber) link and one over an
// expensive (Bluetooth) link. The cheaper link must win.
//
// The cheap peer is given the LARGER NodeAddr on purpose: with cost
// ignored the two candidates tie on depth and the NodeAddr tiebreak
// would pick the expensive, smaller-addr peer. Only a cost-aware
// decision picks the cheaper, larger-addr one, so the assertion
// discriminates.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let expensive = make_node_addr(2); // smaller addr, high cost (Bluetooth)
let cheap = make_node_addr(3); // larger addr, low cost (fiber)
let root = make_node_addr(0);
state.update_peer(
ParentDeclaration::new(expensive, root, 1, 1000),
make_coords(&[2, 0]), // depth 1
);
state.update_peer(
ParentDeclaration::new(cheap, root, 1, 1000),
make_coords(&[3, 0]), // depth 1
);
// eff_depth(expensive) = 1 + 4.0 = 5.0; eff_depth(cheap) = 1 + 1.0 = 2.0
let costs = HashMap::from([(expensive, 4.0_f64), (cheap, 1.0_f64)]);
assert_eq!(state.evaluate_parent(&costs), Some(cheap));
}
#[test]
fn test_evaluate_parent_cost_switches_when_link_to_parent_degrades() {
// cost-reeval subject: the node is parented to A over a cheap link;
// that link then degrades so the alternative B is strictly cheaper.
// Re-evaluation must switch to B. This is the periodic-reeval decision,
// taken here without any timer, netem or MMP-measurement latency.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let peer_a = make_node_addr(2);
let peer_b = make_node_addr(3);
let root = make_node_addr(0);
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[2, 0]), // depth 1
);
state.update_peer(
ParentDeclaration::new(peer_b, root, 1, 1000),
make_coords(&[3, 0]), // depth 1
);
// Adopt A as parent (both links cheap at first).
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
assert!(!state.is_root());
// A's link degrades: eff(A) = 1 + 5.0 = 6.0, eff(B) = 1 + 1.0 = 2.0.
// Default hysteresis is zero, so the strictly-cheaper B wins.
let costs = HashMap::from([(peer_a, 5.0_f64), (peer_b, 1.0_f64)]);
assert_eq!(state.evaluate_parent(&costs), Some(peer_b));
}
#[test]
fn test_evaluate_parent_hysteresis_suppresses_marginal_cost_change() {
// cost-stability subject: a cost change smaller than the hysteresis
// band must NOT trigger a reparent. This is the property the scenario
// was named for and could only approximate with a switch-count ceiling.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
state.set_parent_hysteresis(0.2);
let peer_a = make_node_addr(2);
let peer_b = make_node_addr(3);
let root = make_node_addr(0);
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[2, 0]),
);
state.update_peer(
ParentDeclaration::new(peer_b, root, 1, 1000),
make_coords(&[3, 0]),
);
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
// eff(A) = 1 + 1.0 = 2.0; eff(B) = 1 + 0.9 = 1.9. B is cheaper, but
// 1.9 is not below 2.0 * (1 - 0.2) = 1.6, so hysteresis holds the parent.
let costs = HashMap::from([(peer_a, 1.0_f64), (peer_b, 0.9_f64)]);
assert_eq!(state.evaluate_parent(&costs), None);
}
#[test]
fn test_evaluate_parent_hysteresis_allows_significant_cost_change() {
// cost-stability healthy-path companion: a change LARGER than the band
// must still switch, so the hysteresis test above is not passing merely
// because the node never reparents.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
state.set_parent_hysteresis(0.2);
let peer_a = make_node_addr(2);
let peer_b = make_node_addr(3);
let root = make_node_addr(0);
state.update_peer(
ParentDeclaration::new(peer_a, root, 1, 1000),
make_coords(&[2, 0]),
);
state.update_peer(
ParentDeclaration::new(peer_b, root, 1, 1000),
make_coords(&[3, 0]),
);
state.set_parent(peer_a, 1, 1000);
state.recompute_coords();
// eff(A) = 2.0; eff(B) = 1 + 0.3 = 1.3 < 1.6 threshold → switch to B.
let costs = HashMap::from([(peer_a, 1.0_f64), (peer_b, 0.3_f64)]);
assert_eq!(state.evaluate_parent(&costs), Some(peer_b));
}
#[test]
fn test_evaluate_parent_effective_depth_weighs_depth_against_cost() {
// depth-vs-cost / bottleneck-parent subject: a shallow parent reached
// over an expensive (bottleneck) link versus a deeper parent over a
// cheap link. effective_depth = depth + link_cost decides, and here the
// deeper-but-cheaper path wins — the outcome the depth-vs-cost scenario
// named no falsifiable answer for.
let my_node = make_node_addr(5);
let mut state = TreeState::new(my_node);
let shallow = make_node_addr(2); // depth 1, bottleneck link
let deep = make_node_addr(3); // depth 3, cheap link
let root = make_node_addr(0);
state.update_peer(
ParentDeclaration::new(shallow, root, 1, 1000),
make_coords(&[2, 0]), // depth 1
);
state.update_peer(
ParentDeclaration::new(deep, make_node_addr(6), 1, 1000),
make_coords(&[3, 6, 7, 0]), // depth 3
);
// eff(shallow) = 1 + 3.0 = 4.0; eff(deep) = 3 + 0.5 = 3.5 → pick deep.
// A depth-only decision would take the shallow bottleneck instead.
let costs = HashMap::from([(shallow, 3.0_f64), (deep, 0.5_f64)]);
assert_eq!(state.evaluate_parent(&costs), Some(deep));
}
#[test]
fn test_handle_parent_lost_finds_alternative() {
let my_node = make_node_addr(5);
+14 -33
View File
@@ -44,41 +44,22 @@ Random topologies with increasing stressor intensity.
simultaneously, bandwidth tiers (1/10/100/1000 Mbps), `protect_connectivity`
disabled (partitions allowed).
### Cost-based parent selection
### Cost-based parent selection — retired, now sans-IO unit tests
Explicit topologies with heterogeneous link types (fiber, Bluetooth, WiFi) to
test that the spanning tree selects optimal parents based on link cost.
The cost-selection scenarios (cost-avoidance, depth-vs-cost, bottleneck-parent,
cost-reeval, cost-stability, mixed-technology) were retired on 2026-07-23.
Their subject was the pure `TreeState::evaluate_parent` decision — which parent
wins on `effective_depth = depth + link_cost`, when periodic re-evaluation
switches, and when hysteresis suppresses a flap. A Docker mesh could not test
that reliably: the root is whichever node holds the smallest `NodeAddr`, MMP
costs take several measurement windows to settle, and hold-down plus hysteresis
timing all confound the outcome (a deterministic `link_swap` attempt still
produced zero periodic switches in a full run).
| Scenario | Nodes | Shape | Link types | Duration | What it tests |
| ----------------- | ----- | --------------- | ------------------------ | -------- | ------------------------------------------------------------------- |
| cost-avoidance | 4 | Diamond | Fiber + Bluetooth | 120s | n04 picks fiber parent (n03) over Bluetooth parent (n02) |
| depth-vs-cost | 4 | Linear tree | Fiber + Bluetooth | 120s | Cost tradeoff: depth vs. Bluetooth link quality |
| bottleneck-parent | 10 | Tree with BT | Fiber + Bluetooth | 120s | n06 avoids Bluetooth bottleneck via n02, picks fiber via n03 |
| cost-mixed-7node | 7 | Multi-type tree | Fiber + Bluetooth + WiFi | 180s | n06 prefers fiber (n03) over WiFi (n04) |
| cost-reeval | 4 | Diamond | Fiber (mutated) | 180s | Periodic re-evaluation triggers parent switch (reeval_interval=15s) |
| cost-stability | 4 | Diamond | WiFi (all) | 180s | Hysteresis prevents flapping when costs vary within 20% band |
- **cost-avoidance**, **depth-vs-cost**: Minimal scenarios validating the core
cost formula. Bluetooth (L2CAP) links use 15-40ms delay and 2-8% loss;
fiber uses 1-5ms delay and 0-1% loss.
- **bottleneck-parent**: Larger topology where some nodes have both fiber and
Bluetooth paths to choose from, and one node (n09) is stuck with Bluetooth
(no alternative).
- **cost-mixed-7node**: Three link technologies in one mesh. Traffic enabled.
- **cost-reeval**: Netem mutation (50% fraction, every 12-18s) degrades random
links. FIPS override sets `reeval_interval_secs=15` so periodic re-evaluation
catches cost asymmetry. Look for `trigger=periodic` in logs.
- **cost-stability**: All links are WiFi. Mutation swings costs between
`slightly_better` and `slightly_worse` — within the hysteresis band. Expect
≤ 5 parent switches over 180s.
### Mixed-technology
Larger explicit topologies combining multiple link technologies.
| Scenario | Nodes | Link types | Duration | Netem mutation | What it tests |
| ---------------- | ----- | ------------------------ | -------- | -------------- | ------------------------------------------------ |
| mixed-technology | 10 | Fiber + Bluetooth + WiFi | 180s | 20%/30-60s | Tree convergence across heterogeneous link types |
That logic is now covered by deterministic sans-IO unit tests in
`src/tree/tests.rs` (`test_evaluate_parent_cost_*`, `..._hysteresis_*`,
`..._effective_depth_*`), which run in the cargo quartet on every commit and
can each be shown to fail by breaking the cost or hysteresis logic.
### Transport-specific
@@ -1,107 +0,0 @@
# Bottleneck Parent: 10-node focused Bluetooth bottleneck test
#
# Explicit topology with two Bluetooth (L2CAP) links that create
# bottleneck parent candidates. Tests that nodes avoid choosing
# Bluetooth parents when fiber alternatives exist at the same or
# slightly greater depth.
#
# Topology:
#
# n01 (root)
# / | \ \
# f f f f
# / | \ \
# n02 n03 n04 n05
# | / | \ |
# BT f f BT
# | / | \ |
# n06 n07 n08 n09
# |
# f
# |
# n10
#
# Edges and link types:
# Fiber: n01-n02, n01-n03, n01-n04, n01-n05,
# n03-n06, n03-n07, n04-n08, n08-n10
# Bluetooth: n02-n06, n05-n09
#
# Cross-links: n03-n08 (fiber) — gives n08 a fiber alternative to n04
#
# Test subjects:
# - n06 has Bluetooth (n02) and fiber (n03) at depth 1 — should pick n03
# - n09 has only Bluetooth (n05) — no alternative, stuck with BT parent
scenario:
name: "bottleneck-parent"
seed: 42
duration_secs: 60
topology:
algorithm: explicit
num_nodes: 10
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n01, n04]
- [n01, n05]
- [n02, n06]
- [n03, n06]
- [n03, n07]
- [n03, n08]
- [n04, n08]
- [n05, n09]
- [n08, n10]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
link_policies:
# Bluetooth (L2CAP) links
- edges: ["n02-n06", "n05-n09"]
policy:
delay_ms: [15, 40]
jitter_ms: [5, 15]
loss_pct: [2, 8]
mutation:
interval_secs: {min: 30, max: 60}
fraction: 0.2
policies:
normal:
delay_ms: [1, 5]
loss_pct: [0, 0.5]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 2
interval_secs: {min: 15, max: 30}
duration_secs: {min: 5, max: 10}
parallel_streams: 2
# The two test subjects named at the top of this file, encoded. Both
# existed only as comments and were checked by nothing.
#
# n06 has a Bluetooth parent (n02) and a fiber one (n03) at the same depth
# and should take the fiber. n09 has only Bluetooth, so asserting it keeps
# n05 is asserting that the absence of an alternative is handled without
# thrashing, not that Bluetooth was preferred.
#
# From the archived corpus: across the six provably-completed runs, n06's
# parent is n03 in all six and n09's is n05 in all six.
assertions:
tree_parents:
n06: n03
n09: n05
logging:
rust_log: "info"
output_dir: "./sim-results"
+30 -37
View File
@@ -7,17 +7,22 @@
# Congestion detection signals exercised:
# 1. MMP loss detection: netem loss exceeds the 5% loss_threshold,
# triggering detect_congestion() via MMP metrics on transit nodes.
# 2. Kernel socket drops: small recv_buf_size (8KB) combined with
# traffic saturation causes SO_RXQ_OVFL on the UDP socket,
# triggering the transport drop detection path.
# 3. Ingress policing: tc policer on the receive side drops excess
# 2. Ingress policing: tc policer on the receive side drops excess
# inbound packets, creating bursty arrival patterns.
# 3. ECN CE marking under the shaped bottleneck queue.
#
# The kernel socket-drop signal (SO_RXQ_OVFL) is NOT exercised here. This
# scenario's 1 Mbps cap and ingress policer, which its ECN/MMP signals
# require, make socket overflow impossible. It also cannot be provoked
# deterministically anywhere in Docker — a fresh daemon reader keeps up
# with container-speed traffic — so the FIPS drop-detection logic is
# unit-tested directly in src/node/tests/unit.rs instead. See the note at
# the assertions block below.
#
# ECN is explicitly enabled via fips_overrides.
#
# Success criteria (verified via post-run congestion snapshot):
# - congestion_detected > 0 on at least one forwarding node
# - kernel_drop_events > 0 on at least one node
# - ce_forwarded > 0 on transit nodes
# - ce_received > 0 on destination nodes
#
@@ -118,40 +123,28 @@ assertions:
min_nodes_ce_forwarded: 1
min_nodes_ce_received: 1
# The fourth criterion, "kernel_drop_events > 0 on at least one node", is
# NOT asserted, because this scenario has never met it: across all 182
# archived runs, including the six that meet the other three, no node has
# reported a non-zero kernel_drop_events. Asserting it would red the scenario
# permanently, and asserting a weakened version would assert nothing.
# The kernel socket-drop criterion is NOT asserted here and no longer
# belongs to this scenario. The FIPS drop-DETECTION logic is unit-tested in
# src/node/tests/unit.rs (2026-07-23); the kernel dropping datagrams is a
# kernel behaviour, not FIPS's to test, and could not be provoked in Docker.
#
# CORRECTED 2026-07-23. An earlier version of this note concluded that the
# transport drop-detection path "is exercised by nothing". That is wrong, and
# the error was one of scope: it looked only inside this scenario. Widening
# the scan to every scenario's congestion snapshots -- 2317 files, 11778 node
# records -- finds 51 records reporting kernel_drop_events > 0 across six
# other scenarios (depth-vs-cost, bottleneck-parent, tcp-mesh, cost-avoidance,
# maelstrom-sparse, mixed-technology), with raw drop counts up to 41165. The
# path is live and plumbed through to the snapshot. What is dead is this
# scenario's ability to provoke it.
# Why it could never be met here: across all 182 archived runs of this
# scenario, including the six that meet the three signals above, no node
# ever reported a non-zero kernel_drop_events. SO_RXQ_OVFL counts datagrams
# arriving at a FULL receive queue, and the 1 Mbps cap sets the arrival rate
# to 125 kB/s per link. Linux doubles a requested SO_RCVBUF, so the queue is
# 8192 bytes and filling it would take ~65 ms of reader stall (~22 ms even at
# the busiest node's 3 Mbps aggregate). Traffic volume cannot cause the
# overflow because the volume is capped below the rate the buffer drains, and
# the ingress policer discards excess in tc before the socket ever sees it.
# An unshaped attempt (congestion-drops, 2026-07-23) recorded zero raw drops
# on every node even with a 4 KB buffer and heavy iperf: a fresh daemon
# reader keeps up, so the overflow cannot be provoked deterministically. That
# is why the detection edge is tested as a sans-IO unit test.
#
# The inversion is the lead: every scenario that DOES overflow runs with the
# default 2 MB recv_buf_size, while this one, the only scenario that shrinks
# it to 4 KB, never does. SO_RXQ_OVFL counts datagrams arriving at a full
# queue, and the 1 Mbps cap below sets the arrival rate to 125 kB/s per link.
# Linux doubles a requested SO_RCVBUF, so the queue is 8192 bytes and filling
# it takes ~65 ms of reader stall -- ~22 ms even at the busiest node's 3 Mbps
# aggregate. Traffic volume cannot cause the overflow because the volume is
# capped below the rate the buffer drains. The ingress policer works against
# the same signal, discarding excess in tc before the socket ever sees it.
#
# So signal 3 appears to suppress signal 2, and the two may not belong in one
# scenario. Tracked with the experiment that would settle it (raise or drop
# the bandwidth cap for one run and watch the counter) in ISSUE-2026-0084.
#
# Note also that the header above says "small recv_buf_size (8KB)" while
# fips_overrides sets 4096. The 8 KB figure matches what Linux allocates after
# doubling, so the comment may be describing the effective queue rather than
# the setting; it reads as the setting and should say which it means.
# The recv_buf_size: 4096 override below is kept because the three asserted
# signals were validated with it in place; it is inert for socket overflow
# under this scenario's cap.
logging:
rust_log: "info"
@@ -1,96 +0,0 @@
# Cost-Based Parent Selection: Bottleneck Avoidance Test
#
# Topology (explicit 4-node diamond):
#
# n01 (root — smallest addr)
# / \
# fiber fiber
# / \
# n02 n03
# \ /
# BT fiber
# \ /
# n04 (test subject)
#
# n04 has two candidate parents at depth 1: n02 (via Bluetooth L2CAP)
# and n03 (via fiber). Cost-based selection should pick n03 because
# link_cost = etx * (1 + srtt_ms/100), so the far slower Bluetooth link
# costs far more:
# effective_depth(n02) = 1 + ~4.5 (Bluetooth, ~150-250ms) = ~5.5
# effective_depth(n03) = 1 + ~1.05 (fiber, ~1-5ms) = ~2.05
#
# The Bluetooth delay was widened deliberately, and the size is set by load,
# not by realism. At the original 15-40ms the cost margin was ~0.4, and even
# 80-120ms was not enough: under the CI's parallel-chaos contention the
# *measured* srtt of the fiber link spikes (host scheduling, not netem) and
# was observed to exceed a 120ms Bluetooth base, flipping n04 to n02. At
# 150-250ms the gap dwarfs any plausible one-sided fiber spike, so the
# assertion below is a reliable verdict rather than a coin toss under load.
# n04 still establishes the n02 link (it was parented to it when this flaked),
# so the choice remains real rather than vacuous.
#
# Validation: tree snapshot shows n04's parent is n03.
scenario:
name: "cost-avoidance"
seed: 42
duration_secs: 45
topology:
algorithm: explicit
num_nodes: 4
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n02, n04]
- [n03, n04]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# Fiber-like
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
link_policies:
# Bluetooth (L2CAP) link from n02 to n04. Delay widened to 150-250ms so the
# fiber-vs-Bluetooth cost margin survives CI load spikes on the fiber
# probe's measured latency (see header). Well within handshake timeouts, so
# the n02 link still establishes and the choice stays real.
- edges: ["n02-n04"]
policy:
delay_ms: [150, 250]
jitter_ms: [15, 30]
loss_pct: [2, 8]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 2
interval_secs: {min: 15, max: 30}
duration_secs: {min: 5, max: 10}
parallel_streams: 2
# The validation line at the top of this file, encoded. It existed only as
# that comment and was checked by nothing, so the scenario could not fail
# on the one thing it was built to test.
#
# From the archived corpus: of the six runs that carry a status.txt and are
# therefore provably completed, n04's parent is n03 in all six. Among the
# 176 older runs the picture is quite different -- n04 is absent from the
# snapshot in 107 of them and is its own root in 49 -- but those are runs
# whose tree had not converged when the snapshot was taken rather than runs
# that chose the wrong parent, which is why the assertion fails an absent
# or self-parented node explicitly instead of skipping it.
assertions:
tree_parents:
n04: n03
logging:
rust_log: "info"
output_dir: "./sim-results"
-117
View File
@@ -1,117 +0,0 @@
# Periodic Cost Re-evaluation Test
#
# Topology (explicit 4-node diamond):
#
# n01 (root)
# / \
# fiber fiber
# / \
# n02 n03
# \ /
# fiber fiber
# \ /
# n04 (test subject)
#
# Initial state: All links are fiber. n04 has two candidate parents at
# depth 1: n02 and n03. Both have identical costs (~1.01), so n04 picks
# n02 (smaller NodeAddr, tiebreak rule).
#
# Mutation: Stochastic netem mutation with a single "degraded" policy
# (Bluetooth-like: 15-40ms delay, 2-8% loss). With fraction=0.5, on
# average 2 of 4 edges degrade each round. Over 12 mutation rounds
# (180s / 15s interval), n02-n04 will be degraded in some rounds.
#
# Expected behavior: When n02-n04 is degraded and n03-n04 stays fiber
# (or vice versa), periodic re-evaluation detects the cost asymmetry
# and switches parents. Look for "trigger = periodic" in logs.
#
# FIPS overrides: reeval_interval_secs=15 (vs default 60) to increase
# the chance of catching a cost asymmetry within the mutation window.
#
# Validation: grep n04 logs for "Parent switched via periodic cost
# re-evaluation" (trigger=periodic). If mutation never creates enough
# asymmetry in a particular seed, the test still validates that periodic
# re-eval runs without interference.
#
# THIS SCENARIO COULD NOT EXERCISE ITS SUBJECT AT ALL until 2026-07-23, and
# the reason is worth keeping because nothing about it was visible from here.
# The mesh roots at the numerically smallest NodeAddr, a hash of the node's
# key (src/tree/state.rs:363-390), and that turned out to be **n04** — this
# scenario's own designated test subject. A root has no parent, and
# evaluate_parent returns early for the smallest address, so the parent switch
# described above could never happen. The tree was inverted: n04 at depth 0,
# n01 a leaf at depth 2, in all five recent runs.
#
# The archived logs show exactly that shape. The live info! string above
# appears in 14 of 105 historical runs, on n04 in 13 of them, back when keys
# were regenerated per run and the root wandered. In the five runs before this
# fix it appears only on **n01** — the node that actually had a parent choice.
#
# `topology.pin_root` (default true) now assigns identities in NodeAddr order
# so n01 is root and n04 is the depth-1 subject this file describes.
#
# MEASURED after the fix, 2026-07-23: the periodic switch now happens, in 2 of
# 3 runs on n04 (0, 1, 1 occurrences). Before the fix it was 0 in 5 of 5, and
# structurally impossible rather than merely rare. So the subject is exercised
# for the first time.
#
# STILL NOT ASSERTED, deliberately. A "at least one periodic switch" assertion
# would red roughly one run in three on this evidence, and a single run reading
# zero is an argument against asserting rather than for it. Two ways forward,
# neither taken here: gather enough runs to know whether 2-in-3 is the real
# rate, or reduce the variance -- the mutation is stochastic with fraction=0.5,
# so a deterministic link-degradation schedule would make the asymmetry this
# scenario needs occur every run and the outcome assertable.
# Tracked in ISSUE-2026-0085.
scenario:
name: "cost-reeval"
seed: 42
duration_secs: 180
topology:
algorithm: explicit
num_nodes: 4
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n02, n04]
- [n03, n04]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# Fiber-like baseline
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
mutation:
interval_secs: {min: 12, max: 18}
fraction: 0.5
policies:
degraded:
delay_ms: [15, 40]
jitter_ms: [5, 15]
loss_pct: [2, 8]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 1
interval_secs: {min: 15, max: 30}
duration_secs: {min: 5, max: 10}
parallel_streams: 2
logging:
rust_log: "info"
output_dir: "./sim-results"
fips_overrides:
node:
tree:
reeval_interval_secs: 15
-101
View File
@@ -1,101 +0,0 @@
# Cost-Based Parent Selection: Hysteresis Stability Test
#
# Topology (explicit 4-node diamond, symmetric):
#
# n01 (root)
# / \
# wifi wifi
# / \
# n02 n03
# \ /
# wifi wifi
# \ /
# n04 (test subject)
#
# All links are WiFi-like with similar characteristics. Aggressive
# netem mutation shifts link qualities every 10-20s, but the changes
# stay within the 20% hysteresis band. n04 should pick one parent
# and mostly stick with it — flapping indicates insufficient hysteresis.
#
# Validation: n04 should switch parent at most 5 times. This is now the
# max_parent_switches assertion below, scoped to n04. It previously
# existed only as this comment and was checked by nothing, so the
# scenario could not fail on it.
#
# The threshold of 5 is the one this comment always named, kept
# deliberately rather than re-derived. What the number is worth, from 11
# completed archived runs of this scenario: n04 was 1 or 2 every time and
# never reached 3, while the mesh-wide total ranged 3 to 6. Two
# consequences worth knowing before trusting a green result here.
#
# First, 5 is a long way above anything observed, so this catches only
# near-pathological reparenting. The daemon's own 30s parent hold-down
# caps non-mandatory switches at roughly 6 per 180s run, so the band
# where this can fail is narrow. A hysteresis regression that added two
# or three switches would pass.
#
# Second, the count spans the container's whole life, not just the
# mutation phase: in the archived runs nearly every n04 switch lands in
# the first 100ms during initial tree formation, before the first netem
# mutation fires. So this number is dominated by convergence transients
# rather than by the hysteresis behaviour the scenario is named for.
#
# Tightening to 3 would make it a real detector against the observed
# distribution. That is a change to the stated criterion rather than a
# fix, so it is left as a decision rather than taken here.
scenario:
name: "cost-stability"
seed: 42
duration_secs: 180
topology:
algorithm: explicit
num_nodes: 4
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n02, n04]
- [n03, n04]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# WiFi baseline
delay_ms: [5, 20]
jitter_ms: [2, 5]
loss_pct: [1, 3]
mutation:
interval_secs: {min: 10, max: 20}
fraction: 1.0
policies:
slightly_better:
delay_ms: [3, 8]
jitter_ms: [1, 3]
loss_pct: [0, 1]
slightly_worse:
delay_ms: [15, 25]
jitter_ms: [3, 8]
loss_pct: [2, 4]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 2
interval_secs: {min: 15, max: 30}
duration_secs: {min: 5, max: 15}
parallel_streams: 2
assertions:
max_parent_switches:
node: n04
max_total: 5
logging:
rust_log: "info"
output_dir: "./sim-results"
-103
View File
@@ -1,103 +0,0 @@
# Cost-Based Parent Selection: Deeper Fiber Beats Shallow Bluetooth
#
# Topology (explicit 4-node):
#
# n01 (root)
# / \
# fiber BT
# / \
# n02 n04 (test subject)
# | /
# fiber fiber
# | /
# n03
#
# n04 has two candidate parents:
# - n01 (root, depth 0) via Bluetooth L2CAP: effective_depth = 0 + ~1.4 = ~1.4
# - n03 (depth 2) via fiber: effective_depth = 2 + ~1.01 = ~3.01
#
# Without cost-based selection, n04 would pick n01 (depth 0 < depth 2).
# With cost-based selection and these Bluetooth impairments, n04 may
# still pick n01 since the Bluetooth cost (~1.4) is modest. This tests
# that the cost formula correctly weighs depth against link quality.
#
# Validation: tree snapshot shows n04's parent selection reflects the
# actual cost tradeoff between depth and link quality.
#
# NO PARENT IS ASSERTED HERE, and unlike the other cost scenarios that is
# not because the implementation disagrees with the comment. It is because
# the validation line above does not name an outcome: "reflects the actual
# cost tradeoff" is satisfied by either answer, so there is nothing to
# encode. The comment above even says n04 "may still pick n01".
#
# The corpus agrees that both answers occur. Across the five provably
# completed archived runs, n04's parent is n01 in three and n03 in two,
# with the same seed each time, so the choice is not deterministic under
# this configuration.
#
# Pinning whichever answer is more common would convert an unspecified
# behaviour into a regression guard for something nobody decided, and
# would go red roughly two runs in five besides. What this scenario needs
# first is a decision about which parent is correct given the stated
# effective depths, and that is a protocol question, not a harness one.
scenario:
name: "depth-vs-cost"
seed: 42
duration_secs: 45
topology:
algorithm: explicit
num_nodes: 4
params:
adjacency:
- [n01, n02]
- [n02, n03]
- [n03, n04]
- [n01, n04]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# Fiber-like
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
link_policies:
# Bluetooth (L2CAP) link from n01 to n04
- edges: ["n01-n04"]
policy:
delay_ms: [15, 40]
jitter_ms: [5, 15]
loss_pct: [2, 8]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 1
interval_secs: {min: 15, max: 30}
duration_secs: {min: 5, max: 10}
parallel_streams: 2
# Baseline: the mesh came up, agreed on a root, and took parents. This
# asserts nothing about which parent n04 chooses, which is unspecified;
# it exists so that a run in which the mesh never formed cannot report
# success, which until now it could,
# because this scenario carried no assertions at all.
#
# Four nodes, one root, three parented and four sessions in all five
# provably-completed archived runs.
assertions:
baseline:
min_nodes_reporting: 4
max_roots: 1
min_nodes_parented: 3
min_sessions: 2
logging:
rust_log: "info"
output_dir: "./sim-results"
@@ -1,186 +0,0 @@
# Mixed Technology: 10-node heterogeneous network
#
# Explicit topology with Bluetooth (L2CAP), WiFi, and fiber links.
# Tests that cost-based parent selection produces a tree favoring
# low-cost paths when multiple link technologies coexist.
#
# Topology:
#
# n01 (root)
# / | \
# f f f
# / | \
# n02 n03 n04
# | \ | \ | \
# f BT f f BT f
# | \ | | \ |
# n05 n06 n07 n08 n09
# \ /
# wifi---wifi
# n10
#
# Edges and link types:
# Fiber: n01-n02, n01-n03, n01-n04, n02-n05, n03-n07, n04-n09
# Bluetooth: n02-n06, n04-n08
# WiFi: n03-n06, n08-n10
# Fiber: n03-n08, n06-n10
#
# Test subjects:
# - n08 has fiber (n03) and Bluetooth (n04) parents — should pick n03.
# ASSERTED below. Holds in 4 of 4 runs under the pinned root.
# - n06 was documented as having "fiber (n03) and Bluetooth (n02) parents".
# CORRECTED 2026-07-23: that is wrong, and the netem block below is the
# evidence rather than the ASCII art above. `n03-n06` is a **WiFi** link
# (5-20ms, 1-3% loss) and `n02-n06` is Bluetooth (15-40ms, 2-8% loss), so
# n06 chooses between two impaired links, not between fiber and Bluetooth.
# n06's only fiber-grade link is `n06-n10`, and n10 sits at depth 3 so it
# is never a good parent. There is therefore no reason to expect n06 to
# prefer n03, and the mutation block (fraction 0.2, degraded 50-100ms)
# moves the two costs run to run: across four runs n06 took n03 twice and
# n02 twice. NOT asserted, and the observed split is the correct outcome
# rather than a defect — in the run checked, n03 measured a link cost of 3
# against n02's 1, so effective depth 1+3 beat by 1+1 and the daemon chose
# the cheaper path exactly as it should.
#
# Netem mutation shifts fiber-only links between normal and degraded.
#
# n08 IS now asserted (below); n06 is not, and both reasons trace to the root,
# which was found and fixed rather than papered over here.
#
# The diagram above puts n01 at the top, and both test subjects only make
# sense in a tree rooted there. The mesh does not root by topology: it roots
# at the numerically smallest NodeAddr (src/tree/state.rs:363-390), which is a
# hash of the node's key and bears no relation to the numbering. Until
# 2026-07-23 this scenario rooted at **n09** in every run, which put both
# criteria out of reach. Under that root n08's two candidates were not at
# equal depth, so its choice was settled by depth long before link technology
# could matter, and n08 taking n04 was CORRECT behaviour that these comments
# made look like a defect.
#
# What the archive shows, for anyone re-deriving this later: across the five
# runs under the old root, n06's parent is n10 in three and n03 in two, and
# n08's is n04 in four and n03 in one. Those numbers describe a tree rooted at
# n09 and must not be used to calibrate anything now.
#
# `topology.pin_root` (default true) now assigns identities in NodeAddr order,
# so n01 holds the smallest and the diagram describes the tree that forms.
# Under that root n08 takes fiber n03 as its subject assertion (below) now
# checks. n06 stays unasserted: its "fiber n03" was a WiFi link (see the
# corrected note above), so it was never a founded criterion. Tracked in
# ISSUE-2026-0085.
#
# The n08 assertion is made reliable, not merely reachable: the Bluetooth
# contrast is widened (netem below) and the two links n08 chooses between are
# excluded from the netem mutation, so neither host load nor a random
# degradation can flip the asserted comparison.
scenario:
name: "mixed-technology"
seed: 42
duration_secs: 90
topology:
algorithm: explicit
num_nodes: 10
params:
adjacency:
- [n01, n02]
- [n01, n03]
- [n01, n04]
- [n02, n05]
- [n02, n06]
- [n03, n06]
- [n03, n07]
- [n03, n08]
- [n04, n08]
- [n04, n09]
- [n06, n10]
- [n08, n10]
subnet: "172.20.0.0/24"
ip_start: 10
netem:
enabled: true
default_policy:
# Fiber-like defaults
delay_ms: [1, 5]
jitter_ms: [0, 1]
loss_pct: [0, 0.5]
link_policies:
# Bluetooth (L2CAP) links. Delay widened to 150-250ms so the
# fiber-vs-Bluetooth cost margin on n08's choice (fiber n03 vs Bluetooth
# n04) dwarfs any load-induced srtt spike on the fiber probe, matching
# cost-avoidance. Combined with excluding n03-n08/n04-n08 from the mutation
# (below), n08's asserted comparison is robust to both load and churn.
- edges: ["n02-n06", "n04-n08"]
policy:
delay_ms: [150, 250]
jitter_ms: [15, 30]
loss_pct: [2, 8]
# WiFi links (moderate latency, low loss)
- edges: ["n03-n06", "n08-n10"]
policy:
delay_ms: [5, 20]
jitter_ms: [2, 5]
loss_pct: [1, 3]
mutation:
interval_secs: {min: 30, max: 60}
fraction: 0.2
# Never mutate the two links n08's tree_parents assertion depends on: the
# degraded policy (50-100ms) could otherwise slow the fiber n03-n08 link
# enough to flip n08 to n04 independently of the widened Bluetooth contrast,
# making the assertion a coin toss. The mutation still churns the other ten
# links, so the dynamic behaviour it exists to exercise is unchanged.
exclude_edges: ["n03-n08", "n04-n08"]
policies:
normal:
delay_ms: [1, 10]
loss_pct: [0, 1]
degraded:
delay_ms: [50, 100]
jitter_ms: [10, 30]
loss_pct: [3, 8]
link_flaps:
enabled: false
traffic:
enabled: true
max_concurrent: 3
interval_secs: {min: 10, max: 30}
duration_secs: {min: 5, max: 15}
parallel_streams: 4
# Baseline: the mesh came up, agreed on a root, and took parents. This
# asserts nothing about parent choice, which this scenario cannot
# currently assert; it exists so that a run in which the mesh never formed
# cannot report success, which until now it could,
# because this scenario carried no assertions at all.
#
# Ten nodes, one root, nine parented and six to eight sessions across
# the five provably-completed archived runs. The session floor is set
# below the observed minimum deliberately: it is here to catch a mesh
# that carries no traffic at all, not to pin a throughput.
assertions:
baseline:
min_nodes_reporting: 10
max_roots: 1
min_nodes_parented: 9
min_sessions: 3
# The scenario's own subject, assertable for the first time now that the
# root is pinned to n01 and the topology above describes the tree that
# forms. n08's two candidates sit at equal depth, so the choice is decided
# by link cost alone: n03 over fiber-grade default netem against n04 over
# Bluetooth. That is the fiber-versus-Bluetooth preference this scenario
# exists to test.
#
# The criterion is encoded as written rather than calibrated from runs:
# "should pick n03" is the spec, and four of four runs agree. n06 is
# deliberately absent — see the corrected note in the header for why its
# documented criterion was never founded on this topology.
tree_parents:
n08: n03
logging:
rust_log: "info"
output_dir: "./sim-results"
+5 -5
View File
@@ -46,11 +46,11 @@ class TopologyConfig:
transport_mix: dict[str, float] | None = None
# Assign derived identities in NodeAddr order so n01 holds the smallest and
# is therefore the root every scenario diagram draws. Default on: without it
# the root is effectively arbitrary, which left `cost-reeval` rooted at its
# own designated test subject — so the parent switch it exists to observe
# could not occur — and made `mixed-technology`'s parent criteria
# unreachable. Set false where election from an arbitrary key distribution
# is itself the subject.
# the root is whichever node happens to hold the smallest key, which is
# effectively arbitrary and leaves any scenario that reasons about a
# specific root (e.g. bloom-storm's diamond, or a baseline max_roots check)
# describing a tree that does not form. Set false where election from an
# arbitrary key distribution is itself the subject.
pin_root: bool = True
# Set by --subnet to opt out of claiming a free range. Not a scenario-file
# key: a scenario that hardcoded its range would reintroduce the collision
+4 -5
View File
@@ -208,11 +208,10 @@ def generate_topology(
# The mesh roots itself at the numerically smallest NodeAddr
# (`src/tree/state.rs:363-390`), which is a hash of the node's public key
# and so bears no relation to the node numbering. Every scenario diagram in
# this tree draws n01 at the top, and before this ordering was applied that
# held in only three of thirteen: `cost-reeval` rooted at n04 — its own
# designated test subject, which therefore had no parent to switch and could
# not exercise what the scenario exists to test — and `mixed-technology` at
# n09, which made its two documented parent criteria unreachable.
# this tree draws n01 at the top, and before this ordering was applied the
# root landed on an arbitrary node in most scenarios — which is why the
# cost-selection scenarios that reasoned about a specific root could never
# be turned into reliable assertions and were moved to sans-IO unit tests.
#
# So derive the identities from the mesh name as before, then *assign* them
# in NodeAddr order: n01 receives the smallest and is the root, n02 the next,
+13 -9
View File
@@ -31,9 +31,7 @@
# acl-allowlist, admission-cap, firewall, nat-cone, nat-symmetric,
# nat-lan, nostr-publish-consume, stun-faults,
# chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh,
# chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent,
# chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability,
# chaos-depth-vs-cost, chaos-mixed-technology, chaos-congestion-stress,
# chaos-ethernet-only, chaos-tcp-mesh, chaos-congestion-stress,
# chaos-bloom-storm,
# sidecar, dns-resolver, deb-install
#
@@ -124,15 +122,21 @@ CHAOS_SUITES=(
"ethernet-mesh ethernet-mesh"
"ethernet-only ethernet-only"
"tcp-mesh tcp-mesh"
"bottleneck-parent bottleneck-parent"
"cost-avoidance cost-avoidance"
"cost-reeval cost-reeval"
"cost-stability cost-stability"
"depth-vs-cost depth-vs-cost"
"mixed-technology mixed-technology"
"congestion-stress congestion-stress"
"bloom-storm bloom-storm"
)
# Scenarios retired 2026-07-23 because their subject was a pure decision the
# Docker harness could not test reliably, now covered by sans-IO unit tests:
# - cost-reeval, cost-avoidance, cost-stability, depth-vs-cost,
# mixed-technology, bottleneck-parent: the evaluate_parent() cost/depth/
# hysteresis decision (root election, MMP measurement lag and hold-down
# timing all confounded the assertions) -> src/tree/tests.rs.
# - congestion-drops (never landed): the SO_RXQ_OVFL detection edge. A
# fresh daemon reader keeps up with container-speed traffic, so the
# socket overflow could not be provoked deterministically; the FIPS
# detection logic is now unit-tested in src/node/tests/unit.rs.
# congestion-stress stays: it exercises the ECN/MMP congestion signals,
# which do need the real shaped bottleneck queue.
GATEWAY_SUITES=(gateway)
SIDECAR_SUITES=(sidecar)
ACL_SUITES=(acl-allowlist)