swactor/crates/simulation/tests/deploy_scenarios.rs
Zachery Aaron Shores-Chmielewski 3cf0ae5947 refactor: consolidate crate functions (#50)
Remove co-dependencies for different modules found in `crates` and migrate the development history to a new repository. The docs were stale, and largely not getting used, so simply deleted for now. When code stabilizes more, they will become useful again.
Co-authored-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
Co-committed-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-02-24 09:12:28 +00:00

1015 lines
43 KiB
Rust

#![cfg(feature = "distribution")]
//! Deployment topology simulation scenarios — NAT, relay, firewall, staggered join.
//!
//! These tests model real deployment topologies (home NAT + cloud VPS) to catch
//! transport-level issues before hitting the real internet.
use simulation::distribution::properties::{
analyze, check_convergence, check_group_convergence, check_membership_accuracy,
check_staggered_join, check_zero_convergence,
};
use simulation::distribution::sim::{
run_simulation, DistributionSimConfig, NetworkFault, NetworkTopology, NodeLocation, Partition,
SimAction,
};
fn default_config() -> DistributionSimConfig {
DistributionSimConfig {
actors_per_node: 0,
..Default::default()
}
}
// ────────────────────────────────────────────────────────────────────────────
// 1. Home/cloud topology converges via relay
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn home_cloud_topology_converges_via_relay() {
// Topology: 3 nodes — 1 Public (relay), 2 Nat("home")
// Mirrors: hpz + thinkpad + docean
let config = DistributionSimConfig {
name: "home-cloud-relay".into(),
num_nodes: 3,
num_rounds: 80,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
],
relay_nodes: vec![0],
}),
..default_config()
};
let trace = run_simulation(config);
let metrics = analyze(&trace);
let result = check_membership_accuracy(&metrics, 1.0);
assert!(
result.passed,
"all 3 nodes should converge via relay: {}",
result.actual
);
}
// ────────────────────────────────────────────────────────────────────────────
// 2. Multi-site NAT communicates via relay
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn multi_site_nat_communicates_via_relay() {
// Topology: 5 nodes — 1 Public relay, 2 Nat("home"), 2 Nat("office")
let config = DistributionSimConfig {
name: "multi-site-nat".into(),
num_nodes: 5,
num_rounds: 100,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "office".into() },
NodeLocation::Nat { group: "office".into() },
],
relay_nodes: vec![0],
}),
..default_config()
};
let trace = run_simulation(config);
let metrics = analyze(&trace);
let result = check_membership_accuracy(&metrics, 1.0);
assert!(
result.passed,
"all 5 nodes should converge via relay: {}",
result.actual
);
}
// ────────────────────────────────────────────────────────────────────────────
// 3. Relay death partitions NAT groups
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn relay_death_partitions_nat_groups() {
// Topology: 5 nodes — 1 Public relay, 2 Nat("home"), 2 Nat("office")
// Kill relay at round 30
let config = DistributionSimConfig {
name: "relay-death".into(),
num_nodes: 5,
num_rounds: 100,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "office".into() },
NodeLocation::Nat { group: "office".into() },
],
relay_nodes: vec![0],
}),
kill_schedule: vec![(30, 0)],
..default_config()
};
let trace = run_simulation(config);
// Each NAT group should still see its own peers
let home_result = check_group_convergence(&trace, &[1, 2], 50, 0);
assert!(
home_result.passed,
"home group should maintain internal connectivity: {}",
home_result.actual
);
let office_result = check_group_convergence(&trace, &[3, 4], 50, 0);
assert!(
office_result.passed,
"office group should maintain internal connectivity: {}",
office_result.actual
);
// Cross-group connectivity lost: home nodes should not see office nodes.
// After relay death and SWIM timeout, each group's member_count should drop.
let last_round = trace.snapshots_per_round.last().unwrap();
for &idx in &[1, 2] {
let snap = &last_round[idx].1;
assert!(
snap.is_alive && snap.member_count < 4,
"home node {} should see < 4 members without relay, got {}",
idx,
snap.member_count
);
}
for &idx in &[3, 4] {
let snap = &last_round[idx].1;
assert!(
snap.is_alive && snap.member_count < 4,
"office node {} should see < 4 members without relay, got {}",
idx,
snap.member_count
);
}
}
// ────────────────────────────────────────────────────────────────────────────
// 4. Rolling redeploy with reintroduction
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn rolling_redeploy_with_reintroduction() {
// Topology: 3 nodes — 1 Public, 2 Nat("home")
// Kill node 1 at round 20, revive at round 40, re-introduce via Join at 45
let config = DistributionSimConfig {
name: "rolling-redeploy".into(),
num_nodes: 3,
num_rounds: 100,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
],
relay_nodes: vec![0],
}),
kill_schedule: vec![(20, 1)],
revive_schedule: vec![(40, 1)],
action_schedule: vec![
(45, SimAction::Join { node_idx: 1, seed_idx: 0 }),
],
..default_config()
};
let trace = run_simulation(config);
// Revived node should rejoin and see >= 1 member
let last_round = trace.snapshots_per_round.last().unwrap();
let revived_snap = &last_round[1].1;
assert!(
revived_snap.is_alive && revived_snap.member_count >= 1,
"revived node should rejoin and see >= 1 member, got alive={} members={}",
revived_snap.is_alive,
revived_snap.member_count
);
}
// ────────────────────────────────────────────────────────────────────────────
// 5. Staggered startup — seed first, others join later
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn staggered_startup_seed_first() {
// Topology: 4 nodes — 1 Public (seed), 3 Nat (mixed groups)
// All non-seed nodes deferred, joined at rounds 10, 20, 30
let config = DistributionSimConfig {
name: "staggered-startup".into(),
num_nodes: 4,
num_rounds: 100,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "office".into() },
NodeLocation::Nat { group: "home".into() },
],
relay_nodes: vec![0],
}),
deferred_join: vec![1, 2, 3],
action_schedule: vec![
(10, SimAction::Join { node_idx: 1, seed_idx: 0 }),
(20, SimAction::Join { node_idx: 2, seed_idx: 0 }),
(30, SimAction::Join { node_idx: 3, seed_idx: 0 }),
],
..default_config()
};
let trace = run_simulation(config);
let result = check_staggered_join(&trace, &[0, 1, 2, 3], 4, 100);
assert!(
result.passed,
"all 4 nodes should be joined by round 100: {}",
result.actual
);
let metrics = analyze(&trace);
let acc = check_membership_accuracy(&metrics, 0.75);
assert!(
acc.passed,
"staggered cluster should converge: {}",
acc.actual
);
}
// ────────────────────────────────────────────────────────────────────────────
// 6. Firewalled node isolated — others converge
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn firewalled_node_isolated_others_converge() {
// Topology: 5 nodes — 1 Public, 3 Nat, 1 Firewalled (deferred, never joins)
let config = DistributionSimConfig {
name: "firewalled-isolated".into(),
num_nodes: 5,
num_rounds: 80,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
NodeLocation::Firewalled,
],
relay_nodes: vec![0],
}),
deferred_join: vec![4], // Firewalled node never joins
..default_config()
};
let trace = run_simulation(config);
// 4 non-firewalled nodes should converge
let last_round = trace.snapshots_per_round.last().unwrap();
let connected_count = (0..4)
.filter(|&idx| {
let snap = &last_round[idx].1;
snap.is_alive && snap.member_count >= 3
})
.count();
assert!(
connected_count >= 4,
"4 non-firewalled nodes should all see >= 3 members, only {} do",
connected_count
);
// Firewalled node sees 0 peers (it was deferred and never joined)
let fw_snap = &last_round[4].1;
assert!(
fw_snap.is_alive && fw_snap.member_count == 0,
"firewalled node should see 0 peers, got {}",
fw_snap.member_count
);
}
// ────────────────────────────────────────────────────────────────────────────
// 7. Controller-driven peer introduction
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn controller_driven_peer_introduction() {
// Topology: 4 nodes — all Public, all deferred (no automatic seed join)
// Controller introduces all 6 pairs at round 10 via SimAction::Introduce
// Models the deploy script's POST /api/peers/add flow
let config = DistributionSimConfig {
name: "controller-introduction".into(),
num_nodes: 4,
num_rounds: 80,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Public,
NodeLocation::Public,
NodeLocation::Public,
],
relay_nodes: vec![],
}),
deferred_join: vec![0, 1, 2, 3],
action_schedule: vec![
// All 6 pairs: (0,1), (0,2), (0,3), (1,2), (1,3), (2,3)
(10, SimAction::Introduce { node_a: 0, node_b: 1 }),
(10, SimAction::Introduce { node_a: 0, node_b: 2 }),
(10, SimAction::Introduce { node_a: 0, node_b: 3 }),
(10, SimAction::Introduce { node_a: 1, node_b: 2 }),
(10, SimAction::Introduce { node_a: 1, node_b: 3 }),
(10, SimAction::Introduce { node_a: 2, node_b: 3 }),
],
..default_config()
};
let trace = run_simulation(config);
let metrics = analyze(&trace);
let result = check_membership_accuracy(&metrics, 1.0);
assert!(
result.passed,
"all 4 nodes should converge via controller introduction: {}",
result.actual
);
}
// ────────────────────────────────────────────────────────────────────────────
// 8. Deploy auth race — two-pass introduction recovers
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn deploy_auth_race_recovery_via_two_pass() {
// Models the deploy auth race condition:
// - 3 nodes, all deferred (no automatic join)
// - Round 5: 100% drop rate (models auth rejection window — peer A connects
// to B but B hasn't added A yet, so the response is dropped)
// - Round 5: Introduce all pairs (membership exchanged but responses dropped)
// - Round 10: drop rate cleared (auth race resolved — all allow-lists populated)
// - Round 15: Re-introduce all pairs (models deploy retry / second pass)
//
// The second-pass introduction succeeds because all peers are now authorized.
let config = DistributionSimConfig {
name: "deploy-auth-race".into(),
num_nodes: 3,
num_rounds: 60,
ticks_per_round: 3,
deferred_join: vec![0, 1, 2],
network_faults: vec![
NetworkFault::SetDropRate { round: 5, rate: 1.0 },
NetworkFault::SetDropRate { round: 10, rate: 0.0 },
],
action_schedule: vec![
// First pass: introductions during drop window (simulates auth race)
(5, SimAction::Introduce { node_a: 0, node_b: 1 }),
(5, SimAction::Introduce { node_a: 0, node_b: 2 }),
(5, SimAction::Introduce { node_a: 1, node_b: 2 }),
// Second pass: re-introduce after auth race resolves
(15, SimAction::Introduce { node_a: 0, node_b: 1 }),
(15, SimAction::Introduce { node_a: 0, node_b: 2 }),
(15, SimAction::Introduce { node_a: 1, node_b: 2 }),
],
..default_config()
};
let trace = run_simulation(config);
let metrics = analyze(&trace);
// Zero-convergence check: should NOT be stuck at 0
let zero_check = check_zero_convergence(&trace, 20);
assert!(
zero_check.passed,
"cluster should recover from auth race via two-pass: {}",
zero_check.actual
);
// Full membership accuracy after recovery
let acc = check_membership_accuracy(&metrics, 1.0);
assert!(
acc.passed,
"all 3 nodes should see each other after two-pass introduction: {}",
acc.actual
);
}
// ────────────────────────────────────────────────────────────────────────────
// 9. Degenerate controller actions do not degrade convergence
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn degenerate_controller_actions_do_not_degrade_convergence() {
// Class: Controller-initiated actions that produce degenerate/no-op protocol messages.
// Two simulations with identical 4-node NAT topology (1 public relay + 3 NAT).
// The "clean" run has only the 6 necessary Introduce pairs.
// The "degenerate" run prepends self-joins, self-introductions, and appends
// redundant re-introductions of already-connected pairs.
let nat_topology = || NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
],
relay_nodes: vec![0],
};
// Clean run: only the 6 necessary Introduce pairs at round 10
let clean_config = DistributionSimConfig {
name: "degenerate-clean".into(),
num_nodes: 4,
num_rounds: 80,
ticks_per_round: 3,
topology: Some(nat_topology()),
deferred_join: vec![0, 1, 2, 3],
action_schedule: vec![
(10, SimAction::Introduce { node_a: 0, node_b: 1 }),
(10, SimAction::Introduce { node_a: 0, node_b: 2 }),
(10, SimAction::Introduce { node_a: 0, node_b: 3 }),
(10, SimAction::Introduce { node_a: 1, node_b: 2 }),
(10, SimAction::Introduce { node_a: 1, node_b: 3 }),
(10, SimAction::Introduce { node_a: 2, node_b: 3 }),
],
..default_config()
};
// Degenerate run: self-joins, self-introductions, then necessary pairs, then redundant re-introductions
let degenerate_config = DistributionSimConfig {
name: "degenerate-noisy".into(),
num_nodes: 4,
num_rounds: 80,
ticks_per_round: 3,
topology: Some(nat_topology()),
deferred_join: vec![0, 1, 2, 3],
action_schedule: vec![
// Self-joins (degenerate: "Connecting to ourself")
(5, SimAction::Join { node_idx: 0, seed_idx: 0 }),
(5, SimAction::Join { node_idx: 1, seed_idx: 1 }),
(5, SimAction::Join { node_idx: 2, seed_idx: 2 }),
(5, SimAction::Join { node_idx: 3, seed_idx: 3 }),
// Self-introductions (degenerate: introduce node to itself)
(7, SimAction::Introduce { node_a: 1, node_b: 1 }),
(7, SimAction::Introduce { node_a: 2, node_b: 2 }),
// Necessary pairs at round 10
(10, SimAction::Introduce { node_a: 0, node_b: 1 }),
(10, SimAction::Introduce { node_a: 0, node_b: 2 }),
(10, SimAction::Introduce { node_a: 0, node_b: 3 }),
(10, SimAction::Introduce { node_a: 1, node_b: 2 }),
(10, SimAction::Introduce { node_a: 1, node_b: 3 }),
(10, SimAction::Introduce { node_a: 2, node_b: 3 }),
// Redundant re-introductions of already-connected pairs
(15, SimAction::Introduce { node_a: 0, node_b: 1 }),
(15, SimAction::Introduce { node_a: 0, node_b: 2 }),
(15, SimAction::Introduce { node_a: 1, node_b: 3 }),
],
..default_config()
};
let clean_trace = run_simulation(clean_config);
let clean_metrics = analyze(&clean_trace);
let degenerate_trace = run_simulation(degenerate_config);
let degenerate_metrics = analyze(&degenerate_trace);
// Degenerate run converges to 100% accuracy (self-join doesn't corrupt state)
let degen_acc = check_membership_accuracy(&degenerate_metrics, 1.0);
assert!(
degen_acc.passed,
"degenerate run should converge to 100% accuracy: {}",
degen_acc.actual
);
// Degenerate run doesn't cause zero-convergence (no node stuck at 0)
let degen_zero = check_zero_convergence(&degenerate_trace, 20);
assert!(
degen_zero.passed,
"degenerate run should not cause zero-convergence: {}",
degen_zero.actual
);
// Clean run converges (sanity baseline)
let clean_acc = check_membership_accuracy(&clean_metrics, 1.0);
assert!(
clean_acc.passed,
"clean run should converge: {}",
clean_acc.actual
);
// Convergence speed gap ≤ 10 rounds
let clean_conv = clean_metrics.join_convergence_round.unwrap_or(80);
let degen_conv = degenerate_metrics.join_convergence_round.unwrap_or(80);
let gap = if degen_conv > clean_conv {
degen_conv - clean_conv
} else {
0
};
assert!(
gap <= 10,
"degenerate actions should not delay convergence by >10 rounds: clean={}, degenerate={}, gap={}",
clean_conv, degen_conv, gap
);
}
// ────────────────────────────────────────────────────────────────────────────
// 10. Relay dependency failure prevents cross-group convergence
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn relay_dependency_failure_prevents_cross_group_convergence() {
// Class: Relay dependency failures that silently prevent convergence.
// 5 nodes (1 public relay, 2 NAT "home", 2 NAT "office"), all deferred.
// Models: port 3340 blocked by firewall — NAT nodes can't reach the relay.
// Link faults block all NAT→relay traffic at round 1. Introductions at
// round 5 still attempt to connect, but messages to/from relay are dropped.
// Same-group LAN peers can still reach each other directly.
let config = DistributionSimConfig {
name: "relay-dependency-failure".into(),
num_nodes: 5,
num_rounds: 100,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "office".into() },
NodeLocation::Nat { group: "office".into() },
],
relay_nodes: vec![0],
}),
deferred_join: vec![0, 1, 2, 3, 4],
network_faults: vec![
// Block all NAT↔relay links (bidirectional) — models firewall blocking port 3340
NetworkFault::LinkFault { round: 1, from: 0, to: 1, rate: 1.0, bidirectional: true },
NetworkFault::LinkFault { round: 1, from: 0, to: 2, rate: 1.0, bidirectional: true },
NetworkFault::LinkFault { round: 1, from: 0, to: 3, rate: 1.0, bidirectional: true },
NetworkFault::LinkFault { round: 1, from: 0, to: 4, rate: 1.0, bidirectional: true },
],
action_schedule: vec![
// Attempt introductions despite firewall (deploy script doesn't know port is blocked)
(5, SimAction::Introduce { node_a: 0, node_b: 1 }),
(5, SimAction::Introduce { node_a: 0, node_b: 2 }),
(5, SimAction::Introduce { node_a: 0, node_b: 3 }),
(5, SimAction::Introduce { node_a: 0, node_b: 4 }),
(5, SimAction::Introduce { node_a: 1, node_b: 2 }),
(5, SimAction::Introduce { node_a: 1, node_b: 3 }),
(5, SimAction::Introduce { node_a: 1, node_b: 4 }),
(5, SimAction::Introduce { node_a: 2, node_b: 3 }),
(5, SimAction::Introduce { node_a: 2, node_b: 4 }),
(5, SimAction::Introduce { node_a: 3, node_b: 4 }),
],
..default_config()
};
let trace = run_simulation(config);
let metrics = analyze(&trace);
// Home group (nodes 1,2) converges via LAN despite relay failure
let home_conv = check_group_convergence(&trace, &[1, 2], 30, 0);
assert!(
home_conv.passed,
"home group should converge via LAN despite relay failure: {}",
home_conv.actual
);
// Office group (nodes 3,4) converges via LAN despite relay failure
let office_conv = check_group_convergence(&trace, &[3, 4], 30, 0);
assert!(
office_conv.passed,
"office group should converge via LAN despite relay failure: {}",
office_conv.actual
);
// Each NAT node sees ≥ 1 member (at least same-group LAN peer)
let last_round = trace.snapshots_per_round.last().unwrap();
for &idx in &[1, 2, 3, 4] {
let snap = &last_round[idx].1;
assert!(
snap.is_alive && snap.member_count >= 1,
"NAT node {} should see >= 1 member (LAN peer), got {}",
idx,
snap.member_count
);
}
// Full cluster accuracy < 1.0 (cross-group connectivity degraded, not silently "fine")
assert!(
metrics.membership_accuracy < 1.0,
"full cluster should NOT show 100% accuracy with relay failure, got {:.3}",
metrics.membership_accuracy
);
// Not zero-convergence (failure is detectable, not silent death)
let zero_check = check_zero_convergence(&trace, 20);
assert!(
zero_check.passed,
"relay failure should not cause total convergence death: {}",
zero_check.actual
);
}
// ────────────────────────────────────────────────────────────────────────────
// 11. Introduction strategy equivalence under NAT topology
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn introduction_strategy_equivalence_under_nat_topology() {
// Class: Introduction strategy equivalence under topology constraints.
// Same 5-node mixed-NAT topology (1 public relay, 2 "home" NAT, 2 "office" NAT),
// three strategies: Star, Full mesh, Chain.
let nat_topology = || NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "office".into() },
NodeLocation::Nat { group: "office".into() },
],
relay_nodes: vec![0],
};
// Star: All 4 NAT nodes Join via seed (node 0)
let star_config = DistributionSimConfig {
name: "strategy-star".into(),
num_nodes: 5,
num_rounds: 100,
ticks_per_round: 3,
topology: Some(nat_topology()),
deferred_join: vec![1, 2, 3, 4],
action_schedule: vec![
(10, SimAction::Join { node_idx: 1, seed_idx: 0 }),
(10, SimAction::Join { node_idx: 2, seed_idx: 0 }),
(10, SimAction::Join { node_idx: 3, seed_idx: 0 }),
(10, SimAction::Join { node_idx: 4, seed_idx: 0 }),
],
..default_config()
};
// Full mesh: All 10 pairs Introduced at round 10
let mesh_config = DistributionSimConfig {
name: "strategy-mesh".into(),
num_nodes: 5,
num_rounds: 100,
ticks_per_round: 3,
topology: Some(nat_topology()),
deferred_join: vec![0, 1, 2, 3, 4],
action_schedule: vec![
(10, SimAction::Introduce { node_a: 0, node_b: 1 }),
(10, SimAction::Introduce { node_a: 0, node_b: 2 }),
(10, SimAction::Introduce { node_a: 0, node_b: 3 }),
(10, SimAction::Introduce { node_a: 0, node_b: 4 }),
(10, SimAction::Introduce { node_a: 1, node_b: 2 }),
(10, SimAction::Introduce { node_a: 1, node_b: 3 }),
(10, SimAction::Introduce { node_a: 1, node_b: 4 }),
(10, SimAction::Introduce { node_a: 2, node_b: 3 }),
(10, SimAction::Introduce { node_a: 2, node_b: 4 }),
(10, SimAction::Introduce { node_a: 3, node_b: 4 }),
],
..default_config()
};
// Chain: Linear introductions staggered every 5 rounds
let chain_config = DistributionSimConfig {
name: "strategy-chain".into(),
num_nodes: 5,
num_rounds: 100,
ticks_per_round: 3,
topology: Some(nat_topology()),
deferred_join: vec![0, 1, 2, 3, 4],
action_schedule: vec![
(10, SimAction::Introduce { node_a: 0, node_b: 1 }),
(15, SimAction::Introduce { node_a: 1, node_b: 2 }),
(20, SimAction::Introduce { node_a: 2, node_b: 3 }),
(25, SimAction::Introduce { node_a: 3, node_b: 4 }),
],
..default_config()
};
let star_trace = run_simulation(star_config);
let star_metrics = analyze(&star_trace);
let mesh_trace = run_simulation(mesh_config);
let mesh_metrics = analyze(&mesh_trace);
let chain_trace = run_simulation(chain_config);
let chain_metrics = analyze(&chain_trace);
let star_acc = star_metrics.membership_accuracy;
let mesh_acc = mesh_metrics.membership_accuracy;
let chain_acc = chain_metrics.membership_accuracy;
// All three strategies reach ≥ 75% membership accuracy
assert!(
star_acc >= 0.75,
"star strategy should reach >= 75% accuracy, got {:.3}",
star_acc
);
assert!(
mesh_acc >= 0.75,
"mesh strategy should reach >= 75% accuracy, got {:.3}",
mesh_acc
);
assert!(
chain_acc >= 0.75,
"chain strategy should reach >= 75% accuracy, got {:.3}",
chain_acc
);
// No strategy produces zero-convergence
let star_zero = check_zero_convergence(&star_trace, 30);
assert!(
star_zero.passed,
"star strategy should not produce zero-convergence: {}",
star_zero.actual
);
let mesh_zero = check_zero_convergence(&mesh_trace, 30);
assert!(
mesh_zero.passed,
"mesh strategy should not produce zero-convergence: {}",
mesh_zero.actual
);
let chain_zero = check_zero_convergence(&chain_trace, 30);
assert!(
chain_zero.passed,
"chain strategy should not produce zero-convergence: {}",
chain_zero.actual
);
// Accuracy spread across strategies ≤ 0.5
let accuracies = [star_acc, mesh_acc, chain_acc];
let min_acc = accuracies.iter().cloned().fold(f64::INFINITY, f64::min);
let max_acc = accuracies.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let spread = max_acc - min_acc;
assert!(
spread <= 0.5,
"accuracy spread across strategies should be <= 0.5, got {:.3} (star={:.3}, mesh={:.3}, chain={:.3})",
spread, star_acc, mesh_acc, chain_acc
);
}
// ────────────────────────────────────────────────────────────────────────────
// 12. Mid-deploy compound fault recovery
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn mid_deploy_compound_fault_recovery() {
// Class: Mid-deploy fault recovery patterns.
// 5 nodes, all deferred, compound faults during join window:
// - Round 5: 80% drop rate + first introduction attempt (most messages lost)
// - Round 10: Seed (node 0) killed
// - Round 15: Partition home/office groups
// - Round 20: Seed revived
// - Round 25: All faults healed (drop=0, partition healed)
// - Round 30: Full re-introduction of all 10 pairs (deploy retry)
let config = DistributionSimConfig {
name: "compound-fault-recovery".into(),
num_nodes: 5,
num_rounds: 80,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "office".into() },
NodeLocation::Nat { group: "office".into() },
],
relay_nodes: vec![0],
}),
deferred_join: vec![0, 1, 2, 3, 4],
network_faults: vec![
NetworkFault::SetDropRate { round: 5, rate: 0.8 },
NetworkFault::Partition {
round: 15,
partition: Partition {
side_a: vec![1, 2],
side_b: vec![3, 4],
asymmetric: false,
},
},
NetworkFault::SetDropRate { round: 25, rate: 0.0 },
NetworkFault::Heal { round: 25 },
],
kill_schedule: vec![(10, 0)],
revive_schedule: vec![(20, 0)],
action_schedule: vec![
// First introduction attempt during 80% drop window
(5, SimAction::Introduce { node_a: 0, node_b: 1 }),
(5, SimAction::Introduce { node_a: 0, node_b: 2 }),
(5, SimAction::Introduce { node_a: 0, node_b: 3 }),
(5, SimAction::Introduce { node_a: 0, node_b: 4 }),
(5, SimAction::Introduce { node_a: 1, node_b: 2 }),
(5, SimAction::Introduce { node_a: 1, node_b: 3 }),
(5, SimAction::Introduce { node_a: 1, node_b: 4 }),
(5, SimAction::Introduce { node_a: 2, node_b: 3 }),
(5, SimAction::Introduce { node_a: 2, node_b: 4 }),
(5, SimAction::Introduce { node_a: 3, node_b: 4 }),
// Full re-introduction after all faults healed (deploy retry)
(30, SimAction::Introduce { node_a: 0, node_b: 1 }),
(30, SimAction::Introduce { node_a: 0, node_b: 2 }),
(30, SimAction::Introduce { node_a: 0, node_b: 3 }),
(30, SimAction::Introduce { node_a: 0, node_b: 4 }),
(30, SimAction::Introduce { node_a: 1, node_b: 2 }),
(30, SimAction::Introduce { node_a: 1, node_b: 3 }),
(30, SimAction::Introduce { node_a: 1, node_b: 4 }),
(30, SimAction::Introduce { node_a: 2, node_b: 3 }),
(30, SimAction::Introduce { node_a: 2, node_b: 4 }),
(30, SimAction::Introduce { node_a: 3, node_b: 4 }),
],
..default_config()
};
let trace = run_simulation(config);
let metrics = analyze(&trace);
// Not stuck at zero after recovery (primary "permanent degradation" check)
let zero_check = check_zero_convergence(&trace, 35);
assert!(
zero_check.passed,
"cluster should not be stuck at zero after compound fault recovery: {}",
zero_check.actual
);
// ≥ 75% accuracy after fault recovery
let acc = check_membership_accuracy(&metrics, 0.75);
assert!(
acc.passed,
"cluster should reach >= 75% accuracy after fault recovery: {}",
acc.actual
);
// All 5 nodes alive at end
let last_round = trace.snapshots_per_round.last().unwrap();
let alive_count = last_round.iter().filter(|(_, s)| s.is_alive).count();
assert_eq!(
alive_count, 5,
"all 5 nodes should be alive at end, got {}",
alive_count
);
// Global convergence (tolerance=1) achieved by round 60
let global_conv = check_convergence(&trace, 60, 1);
assert!(
global_conv.passed,
"global convergence should be achieved by round 60: {}",
global_conv.actual
);
// Both home and office groups converge independently by round 60
let home_conv = check_group_convergence(&trace, &[1, 2], 60, 0);
assert!(
home_conv.passed,
"home group should converge by round 60: {}",
home_conv.actual
);
let office_conv = check_group_convergence(&trace, &[3, 4], 60, 0);
assert!(
office_conv.passed,
"office group should converge by round 60: {}",
office_conv.actual
);
}
// ════════════════════════════════════════════════════════════════════════════
// Bug-class regression: verify tests catch the actual deploy bugs
// ════════════════════════════════════════════════════════════════════════════
// ────────────────────────────────────────────────────────────────────────────
// 13. Bug replay: deploy script sends join_seed to the seed node itself
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn bug_replay_self_join_only_deploy_fails_to_converge() {
// Replicates the real bug: deploy script sent `join_seed` to the seed node
// itself (iroh rejected "Connecting to ourself"). The bug meant the seed
// never learned about other nodes — it only tried to join itself.
//
// This test models a "buggy deploy" where the controller ONLY sends
// self-joins (the bug) and never sends the correct cross-node introductions.
// The test passes if the buggy deploy FAILS to converge — proving that
// test 9's assertions would catch this class of bug.
let config = DistributionSimConfig {
name: "bug-replay-self-join".into(),
num_nodes: 3,
num_rounds: 60,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
],
relay_nodes: vec![0],
}),
deferred_join: vec![0, 1, 2],
action_schedule: vec![
// Buggy deploy: only self-joins, never cross-node introductions
(5, SimAction::Join { node_idx: 0, seed_idx: 0 }),
(5, SimAction::Join { node_idx: 1, seed_idx: 1 }),
(5, SimAction::Join { node_idx: 2, seed_idx: 2 }),
],
..default_config()
};
let trace = run_simulation(config);
let metrics = analyze(&trace);
// The buggy deploy MUST fail: nodes stuck at 0 members
let zero_check = check_zero_convergence(&trace, 10);
assert!(
!zero_check.passed,
"buggy self-join-only deploy should produce zero-convergence (stuck at 0 members), \
but somehow nodes converged: {}",
zero_check.actual
);
// Accuracy must be 0 — no node sees any other
assert!(
metrics.membership_accuracy < 0.5,
"buggy self-join-only deploy should have < 50% accuracy, got {:.3}",
metrics.membership_accuracy
);
}
// ────────────────────────────────────────────────────────────────────────────
// 14. Bug replay: firewall blocks relay port — NAT nodes silently isolated
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn bug_replay_firewall_blocks_relay_port_silent_isolation() {
// Replicates the real bug: port 3340 blocked by firewall. All NAT nodes
// couldn't reach the relay, so the cluster was stuck at 0 peers.
//
// This models a deploy where the firewall rule blocks the relay port BEFORE
// any connections are established. NAT nodes attempt to join via the relay
// but all traffic is dropped. The test passes if this scenario FAILS to
// achieve full convergence — proving that test 10's assertions would
// detect this failure mode.
let config = DistributionSimConfig {
name: "bug-replay-firewall".into(),
num_nodes: 3,
num_rounds: 60,
ticks_per_round: 3,
topology: Some(NetworkTopology {
locations: vec![
NodeLocation::Public,
NodeLocation::Nat { group: "home".into() },
NodeLocation::Nat { group: "home".into() },
],
relay_nodes: vec![0],
}),
deferred_join: vec![0, 1, 2],
network_faults: vec![
// Firewall blocks all NAT↔relay traffic for the ENTIRE simulation
NetworkFault::LinkFault { round: 1, from: 0, to: 1, rate: 1.0, bidirectional: true },
NetworkFault::LinkFault { round: 1, from: 0, to: 2, rate: 1.0, bidirectional: true },
],
action_schedule: vec![
// Deploy script tries to introduce all pairs, but relay traffic is blocked
(5, SimAction::Introduce { node_a: 0, node_b: 1 }),
(5, SimAction::Introduce { node_a: 0, node_b: 2 }),
(5, SimAction::Introduce { node_a: 1, node_b: 2 }),
],
..default_config()
};
let trace = run_simulation(config);
let metrics = analyze(&trace);
// Full accuracy MUST NOT be achieved — relay is the only path for NAT→Public,
// and it's blocked. NAT nodes in the same group can still talk via LAN,
// but nobody can reach the public relay node.
assert!(
metrics.membership_accuracy < 1.0,
"firewall-blocked deploy should NOT achieve 100% accuracy, got {:.3}",
metrics.membership_accuracy
);
// The LAN peers (nodes 1,2) should still see each other
let home_conv = check_group_convergence(&trace, &[1, 2], 20, 0);
assert!(
home_conv.passed,
"same-group NAT nodes should still converge via LAN: {}",
home_conv.actual
);
// But relay node should be isolated — member_count == 0
let last_round = trace.snapshots_per_round.last().unwrap();
let relay_snap = &last_round[0].1;
assert!(
relay_snap.is_alive && relay_snap.member_count == 0,
"relay node should be isolated with 0 members (firewall blocks all NAT traffic), got {}",
relay_snap.member_count
);
}