warning: in the working copy of 'crates/spt/tests/er_brief_once_per_session_e2e.rs', LF will be replaced by CRLF the next time Git touches it diff --git a/crates/spt/tests/er_brief_once_per_session_e2e.rs b/crates/spt/tests/er_brief_once_per_session_e2e.rs index 13ad6186..98ff2a2e 100644 --- a/crates/spt/tests/er_brief_once_per_session_e2e.rs +++ b/crates/spt/tests/er_brief_once_per_session_e2e.rs @@ -100,7 +100,9 @@ fn seed_subnet_and_code(name: &str) -> String { fn code_for(subnet: &str) -> String { let store = spt_store::subnet::SubnetStore::load(); let rec = store.find(subnet).expect("the subnet seeded by this home"); - let seed = rec.seed_bytes().expect("a created subnet carries a member seed"); + let seed = rec + .seed_bytes() + .expect("a created subnet carries a member seed"); let now = spt_net::net::pairing::ntp::ceremony_now_secs(); spt_net::net::pairing::totp::TotpSeed::from_bytes(seed).code_at(now) } @@ -237,7 +239,9 @@ fn reap(home: &Path, spt_bin: &Path, broker: &mut Child, brain_pid: u32, extra: } let _ = { let mut cmd = Command::new(spt_bin); - cmd.no_window().args(["daemon", "stop"]).env("SPT_HOME", home); + cmd.no_window() + .args(["daemon", "stop"]) + .env("SPT_HOME", home); common::output_bounded(cmd, Duration::from_secs(20)) }; kill_pid(brain_pid); @@ -256,7 +260,9 @@ fn run_ceremony(spt_bin: &Path, home: &Path, subnet: &str, adapter: &str) -> boo for marker in ["OWL_SESSION_ID", "SPT_AGENT_ID", "SPT_ENDPOINT_ID"] { cmd.env_remove(marker); } - common::output_bounded(cmd, Duration::from_secs(30)).status.success() + common::output_bounded(cmd, Duration::from_secs(30)) + .status + .success() } /// Every briefing row the perch holds, delivered or not — the audit read, not a @@ -275,6 +281,46 @@ fn session_id_at(perch_path: &Path) -> Option { .filter(|s| !s.is_empty()) } +fn wait_for_controller_release(perch_path: &Path, budget: Duration) -> Result { + let started = Instant::now(); + let deadline = started + budget; + let mut observed = None; + while Instant::now() < deadline { + observed = spt_store::info::read_info(perch_path).map(|i| (i.driven_by, i.controlled)); + if matches!(observed, Some((None, false))) { + return Ok(started.elapsed()); + } + std::thread::sleep(Duration::from_millis(75)); + } + Err(format!( + "the controller seat never released after rc1's death: \ + observed={observed:?} elapsed={:?}. The broker must clear driven_by and controlled \ + on the detach edge (broker.rs:3356 -> :2560 -> :3398); this means the release \ + genuinely did not happen, not that rc2 raced its propagation", + started.elapsed() + )) +} + +#[test] +fn controller_release_barrier_rejects_a_latched_seat() { + let home = tempfile::tempdir().expect("temp home"); + let perch_path = home.path().join("latched"); + std::fs::create_dir_all(&perch_path).expect("perch dir"); + let mut info = spt_store::info::InfoJson::new("latched", "now", 1, "sid", "live_agent"); + info.driven_by = Some("still-seated".to_string()); + info.controlled = true; + spt_store::info::write_info(&perch_path, &info).expect("write latched seat"); + + let failure = wait_for_controller_release(&perch_path, Duration::from_millis(25)) + .expect_err("a deliberately unreleased seat must make the barrier red"); + assert!( + failure.contains("the controller seat never released after rc1's death") + && failure.contains("still-seated") + && failure.contains("controlled"), + "the red control must carry the named failure and observed seat: {failure}" + ); +} + // [int->REQ-ER-SESSION-BRIEFING] #[test] fn a_second_seat_on_a_running_engine_room_is_not_re_briefed() { @@ -298,14 +344,13 @@ fn a_second_seat_on_a_running_engine_room_is_not_re_briefed() { // ══ ARM 1: the first seat. The session opens and IS briefed. ══ let rc1_out = home.path().join("rc1.stdout.log"); + let rc1_err = home.path().join("rc1.stderr.log"); let mut rc1: Child = Command::new(&spt_bin) .no_window() .args(["rc", id, "--code", &code1]) .env("SPT_HOME", home.path()) .stdout(Stdio::from(std::fs::File::create(&rc1_out).unwrap())) - .stderr(Stdio::from( - std::fs::File::create(home.path().join("rc1.stderr.log")).unwrap(), - )) + .stderr(Stdio::from(std::fs::File::create(&rc1_err).unwrap())) .stdin(Stdio::piped()) .spawn() .expect("spawn spt rc engine-room (first seat)"); @@ -336,16 +381,28 @@ fn a_second_seat_on_a_running_engine_room_is_not_re_briefed() { let _ = rc1.kill(); let _ = rc1.wait(); + // `wait` reaps rc1, but the broker observes its socket closing in another + // process and only then persists the free seat. Sixty seconds is deliberately + // a large multiple of this local file-write path: a long bound costs only a + // true failure, while a short one recreates the load-sensitive race. + let release_elapsed = wait_for_controller_release(&perch_path, Duration::from_secs(60)) + .unwrap_or_else(|e| { + panic!("PRECONDITION: {e}"); + }); + eprintln!( + "ER177 controller-seat release propagated in {:.3}s", + release_elapsed.as_secs_f64() + ); + let code2 = code_for(subnet); let rc2_out = home.path().join("rc2.stdout.log"); + let rc2_err = home.path().join("rc2.stderr.log"); let mut rc2: Child = Command::new(&spt_bin) .no_window() .args(["rc", id, "--code", &code2]) .env("SPT_HOME", home.path()) .stdout(Stdio::from(std::fs::File::create(&rc2_out).unwrap())) - .stderr(Stdio::from( - std::fs::File::create(home.path().join("rc2.stderr.log")).unwrap(), - )) + .stderr(Stdio::from(std::fs::File::create(&rc2_err).unwrap())) .stdin(Stdio::piped()) .spawn() .expect("spawn spt rc engine-room (second seat)"); @@ -373,12 +430,14 @@ fn a_second_seat_on_a_running_engine_room_is_not_re_briefed() { let session_second = session_id_at(&perch_path); let pending_after = spt_store::spool::pending_count_at(&perch_path).unwrap_or(0); let brain_stderr = daemon_stderr(home.path(), &brain_log); - let rc1_stdout = std::fs::read_to_string(&rc1_out).unwrap_or_default(); - let rc2_stdout = std::fs::read_to_string(&rc2_out).unwrap_or_default(); let rc2_pid = rc2.id(); let _ = rc2.kill(); let _ = rc2.wait(); + let rc1_stdout = std::fs::read_to_string(&rc1_out).unwrap_or_default(); + let rc1_stderr = std::fs::read_to_string(&rc1_err).unwrap_or_default(); + let rc2_stdout = std::fs::read_to_string(&rc2_out).unwrap_or_default(); + let rc2_stderr = std::fs::read_to_string(&rc2_err).unwrap_or_default(); reap( home.path(), &spt_bin, @@ -395,11 +454,15 @@ fn a_second_seat_on_a_running_engine_room_is_not_re_briefed() { first: rows={first_rows:?} session={session_first:?} conns={conns_before_second:?}\n\ second: seat_conn={second_seat_conn:?} rows={second_rows:?} session={session_second:?} \ pending={pending_after}\n\ - === rc stdout (1) ===\n{rc1_stdout}\n=== rc stdout (2) ===\n{rc2_stdout}\n\ + === rc stdout (1) ===\n{rc1_stdout}\n=== rc stderr (1) ===\n{rc1_stderr}\n\ + === rc stdout (2) ===\n{rc2_stdout}\n=== rc stderr (2) ===\n{rc2_stderr}\n\ === brain stderr ===\n{brain_stderr}" ); - assert!(ceremony_ok, "PRECONDITION: the ceremony must provision the record"); + assert!( + ceremony_ok, + "PRECONDITION: the ceremony must provision the record" + ); // POSITIVE CONTROL — in this same run, before any absence is read. assert_eq!(