diff --git a/crates/spt-daemon/src/bin/xlate_choreo_fixture.rs b/crates/spt-daemon/src/bin/xlate_choreo_fixture.rs index 64411a37..dbad14a7 100644 --- a/crates/spt-daemon/src/bin/xlate_choreo_fixture.rs +++ b/crates/spt-daemon/src/bin/xlate_choreo_fixture.rs @@ -43,6 +43,7 @@ const PAYLOAD: &str = "XLATE_OK"; fn main() { let mode = std::env::var("XLATE_FIXTURE_MODE").unwrap_or_else(|_| "choreo".to_string()); let stdin_log = std::env::var("XLATE_FIXTURE_STDIN_LOG").ok(); + let generation_log = std::env::args_os().nth(1); let stdin = io::stdin(); let mut out = io::stdout(); @@ -71,6 +72,20 @@ fn main() { Ok(v) => v, Err(_) => continue, }; + // [int->REQ-HAZARD-TRANSLATE-FAULT-PERMANENT-DEATH] + // Optional per-test argv path: record this generation's identity while alive. + if v.get("type").and_then(|t| t.as_str()) == Some("init") { + if let Some(path) = &generation_log { + let pid = std::process::id(); + let identity = serde_json::json!([pid, spt_store::proc::process_started_at(pid)]); + let mut log = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("open generation identity log"); + writeln!(log, "{identity}").expect("record generation identity"); + } + } if v.get("type").and_then(|t| t.as_str()) != Some("event") { continue; } diff --git a/crates/spt-daemon/tests/inject_control_wedge.rs b/crates/spt-daemon/tests/inject_control_wedge.rs index e545bd80..0913e2d5 100644 --- a/crates/spt-daemon/tests/inject_control_wedge.rs +++ b/crates/spt-daemon/tests/inject_control_wedge.rs @@ -2030,6 +2030,7 @@ fn c1_strike_fault_stamps_perch_and_bounded_respawns_then_gives_up() { let _home = init_wedge_home(); let dir = tempfile::tempdir().expect("tempdir"); let stdin_log = dir.path().join("c1b-stdin.log"); + let generation_log = dir.path().join("c1b-generations.jsonl"); // Every event misses; strike budget 1 → each miss faults; respawn budget 2. std::env::set_var("XLATE_FIXTURE_MODE", "nocommit"); std::env::set_var("SPT_INJECT_COMMIT_DEADLINE_MS", "300"); @@ -2040,26 +2041,61 @@ fn c1_strike_fault_stamps_perch_and_bounded_respawns_then_gives_up() { let name = unique_name(); let endpoint = "xlate-c1b-ep"; establish_endpoint_perch(endpoint); - let (broker, sid, _controller) = spawn_xlate_session(&name, &dir.path().join("c1b"), endpoint); + let mut req = xlate_spawn_req(endpoint); + req.translation_binary + .as_mut() + .expect("translation fixture") + .push(generation_log.to_str().expect("generation log path").to_string()); + let (broker, sid, mut controller) = + spawn_session_with(&name, &dir.path().join("c1b"), req); set_endpoint_idle(endpoint); - // Deliver events with > deadline spacing so each fresh binary faults BEFORE the - // next delivery hits it (so the next delivery observes `faulted` → respawn). Loop - // until a spool (give-up) is observed or a bounded number of attempts. - let mut saw_spool = false; - let mut attempts = 0u32; - for i in 0..8 { - attempts += 1; - let env = format!(r#"c1b-miss-{i}"#); - let delivered = - deliver_endpoint_event(&name, endpoint, env.as_bytes(), Duration::from_secs(3)); - if !delivered { - saw_spool = true; // give-up reached → spool (delivered=false) - break; + // A queue acknowledgment is not a completed fault. The fixture stamps each + // generation while alive; its native death follows the broker's faulted store. + // One shared watchdog bounds all three generations without spacing guesses. + let fault_deadline = Instant::now() + attach_gate_watchdog(); + for generation in 0..3 { + let event = format!(r#"c1b-miss-{generation}"#); + let (delivered, _) = controller + .inject_endpoint(endpoint, event.as_bytes(), false) + .expect("generation dispatch must return a real broker reply"); + assert!(delivered, "generation {generation} must accept its faulting event"); + let pin = loop { + let log = match std::fs::read_to_string(&generation_log) { + Ok(log) => log, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => panic!("read generation identities: {err}"), + }; + let rows: Vec<_> = log + .split_inclusive('\n') + .filter(|line| line.ends_with('\n')) + .collect(); + assert!(rows.len() <= generation + 1, "unexpected extra translation generation"); + if let Some(row) = rows.get(generation) { + let (pid, born): (u32, Option) = + serde_json::from_str(row).expect("complete generation identity"); + #[cfg(any(windows, target_os = "linux"))] + assert!(born.is_some(), "fixture must stamp its live process"); + break spt_store::proc::PinnedProc::from_stamp(pid, born); + } + assert!(Instant::now() < fault_deadline, "generation {generation} never initialized"); + thread::sleep(Duration::from_millis(10)); + }; + while !pin.provably_gone() { + assert!( + Instant::now() < fault_deadline, + "generation {generation} pid {} did not complete its fault", + pin.pid() + ); + thread::sleep(Duration::from_millis(10)); } - // Let this binary's 300ms commit deadline fire (fault) before the next event. - thread::sleep(Duration::from_millis(600)); } + // All initial+two respawn generations are now dead. Transport errors, malformed + // replies and IPC timeouts must fail, never masquerade as the give-up response. + let (delivered, _) = controller + .inject_endpoint(endpoint, br#"c1b-after-budget"#, false) + .expect("exhausted-budget dispatch must return a real broker reply"); + let saw_spool = !delivered; // The perch fault surface must be stamped (poll briefly — the last fault after // give-up leaves it set). @@ -2080,8 +2116,8 @@ fn c1_strike_fault_stamps_perch_and_bounded_respawns_then_gives_up() { eprintln!( "=== C-1 B FAULT/RESPAWN/GIVEUP GATE: stamped={stamped:?} inits_logged={inits_logged} \ - saw_spool={saw_spool} attempts={attempts} \ - (fixed = stamped Some, inits in 2..=3 (initial + bounded respawns), give-up spool; \ + saw_spool={saw_spool} fault_generations=3 \ + (fixed = stamped Some, exactly three generations, give-up spool; \ pre-C-1 = no stamp + never respawns + permanent death) ===" ); @@ -2091,15 +2127,9 @@ fn c1_strike_fault_stamps_perch_and_bounded_respawns_then_gives_up() { "a REAL fault must STAMP the harness-reachable perch translation_fault surface \ (not only the F-019 daemon-stderr channel)" ); - assert!( - inits_logged >= 2, - "a faulted binary must be BOUNDED-EAGER-RESPAWNED (>= 1 extra spawn), not left \ - permanently dead (ADR-0022's original never-respawn premise) — inits_logged={inits_logged}" - ); - assert!( - inits_logged <= 3, - "respawn must be BOUNDED by the give-up budget (initial + 2 respawns = 3 spawns \ - max) — inits_logged={inits_logged} exceeds the budget (respawn storm)" + assert_eq!( + inits_logged, 3, + "initial + two bounded respawns must fault; neither permanent death nor a respawn storm" ); assert!( saw_spool,