swactor/crates/simulation/tests/swim_integration.rs

158 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! End-to-end integration: scenario loader → engine →
//! SwimHostFactory → network → bundle writer → assertion evaluator.
//!
//! Two scenarios live here:
//!
//! - `swim_integration.toml` — the wiring smoke test for the §6.2
//! SWIM host. Both assertions pass; this verifies the pipeline
//! reaches a clean bundle.
//! - `gossip_flap.toml` — the §1 "done" reproduction of the N3
//! deployment SWIM gossip-flap bug. Asserts that at least one
//! verdict comes back Fail on the current production SWIM source.
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use simulation::bundle_file::FileBundleWriter;
use simulation::engine::{Engine, TerminationReason};
use simulation::evaluator::evaluate_bundle;
use simulation::network::Network;
use simulation::scenario::{HostKindRegistry, Scenario, load_from_path};
use simulation::swim_host::SwimHostFactory;
fn registry() -> HostKindRegistry {
HostKindRegistry::with_swim()
}
fn load(rel: &str) -> Scenario {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel);
load_from_path(&path, &registry()).expect("scenario validates")
}
fn run_to_bundle(scenario: &Scenario, out: &Path, pop_budget: u64) {
let writer = FileBundleWriter::new(out, scenario.clone());
let network = Network::new(scenario);
let mut engine = Engine::new(scenario, network, writer);
engine.register_factory(Box::new(SwimHostFactory));
engine.auto_install_hosts();
engine.set_pop_budget(pop_budget);
let term = engine.run();
assert!(
matches!(
term,
TerminationReason::DurationReached | TerminationReason::EarlyAllAssertionsResolved
),
"unexpected termination {term:?}"
);
let writer = engine.into_writer();
writer.finalize().expect("finalize bundle");
}
#[test]
fn swim_integration_smoke_writes_a_bundle_with_swim_events_and_passes_assertions() {
let scen = load("scenarios/reproduction/swim_integration.toml");
let tmp = TempDir::new().unwrap();
let out = tmp.path().join("bundle");
// ~100 ticks * 3 hosts ~= 300 tick pops + delivers; 20k is generous.
run_to_bundle(&scen, &out, 20_000);
// Bundle layout exists.
assert!(out.join("events.ndjson").is_file());
assert!(out.join("manifest.json").is_file());
assert!(out.join("snapshots").is_dir());
// SWIM hosts actually emitted RecordEvents.
let events_text = fs::read_to_string(out.join("events.ndjson")).unwrap();
let mut swim_kind_tags = 0u64;
let mut state_transitions = 0u64;
for line in events_text.lines() {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
if v["kind_tag"] == "swim" {
swim_kind_tags += 1;
}
if v["event"]["kind"] == "state_transition" {
state_transitions += 1;
}
}
assert!(
swim_kind_tags > 0,
"expected SWIM-tagged events in the bundle; events.ndjson has {} bytes",
events_text.len()
);
// Every membership change rides production's own `SwimTransition`
// diagnostic, which the sim records as `state_transition`. The
// smoke scenario shouldn't trigger a flap, so we don't assert on
// the count.
let _ = state_transitions;
// Snapshots are well-formed.
let alpha_snap = fs::read_to_string(out.join("snapshots/alpha/0.json")).unwrap();
let snap_v: serde_json::Value = serde_json::from_str(&alpha_snap).unwrap();
assert!(snap_v["snapshot"]["members"].is_object());
assert!(snap_v["snapshot"]["tier2"].is_object());
// Run the assertion evaluator on the bundle. Both declared
// assertions are generous bounds; they should pass.
let verdicts = evaluate_bundle(&out).expect("evaluator runs");
assert_eq!(verdicts.len(), 2);
for v in &verdicts {
use simulation::evaluator::Outcome;
assert!(
matches!(v.outcome, Outcome::Pass | Outcome::Inconclusive),
"smoke assertion {v:?} unexpectedly failed"
);
}
}
#[test]
fn gossip_flap_repro_fails_self_incarnation_bounded_on_current_swim_source() {
// SIM_SPEC §1 "done": a property test reproduces the gossip-flap
// bug deterministically against the current SWIM source. We run
// the canonical reproduction scenario end-to-end and assert that
// at least one declared verdict comes back Fail. Inside that
// failure set, `self_incarnation_bounded` is the one that
// actually reproduces the production symptom — the orchestrator's
// self_incarnation runs away from rebutted Suspect piggybacks.
use simulation::evaluator::Outcome;
let scen = load("scenarios/reproduction/gossip_flap.toml");
let tmp = TempDir::new().unwrap();
let out = tmp.path().join("bundle");
// Generous: 20s sim @ 50ms ticks * 3 hosts = 1200 tick pops, plus
// ~3-4× that in message deliveries. 200k caps a runaway loop
// without truncating a healthy run.
run_to_bundle(&scen, &out, 200_000);
let verdicts = evaluate_bundle(&out).expect("evaluator runs");
assert_eq!(
verdicts.len(),
3,
"gossip_flap.toml declares three assertions"
);
let fails: Vec<&_> = verdicts
.iter()
.filter(|v| matches!(v.outcome, Outcome::Fail))
.collect();
assert!(
!fails.is_empty(),
"expected at least one Fail verdict on the current SWIM source; got {verdicts:#?}"
);
let self_inc = verdicts
.iter()
.find(|v| v.kind == "self_incarnation_bounded")
.expect("gossip_flap.toml declares self_incarnation_bounded");
assert_eq!(
self_inc.outcome,
Outcome::Fail,
"self_incarnation_bounded should Fail (the production bug fingerprint); \
got {self_inc:#?}"
);
assert!(
!self_inc.evidence.is_empty(),
"Fail verdict must carry evidence per §10.5"
);
}