diff --git a/crates/spt-daemon/tests/restart_replay_lifetime.rs b/crates/spt-daemon/tests/restart_replay_lifetime.rs index 9d44ff55..11602994 100644 --- a/crates/spt-daemon/tests/restart_replay_lifetime.rs +++ b/crates/spt-daemon/tests/restart_replay_lifetime.rs @@ -216,7 +216,7 @@ fn read_info(id: &str) -> spt_store::info::InfoJson { /// One REAL dispatcher generation: the `dispatch_fixture` child running /// production `run_dispatch_loop` against `broker_name`. Killed = brain death /// (worker conns drop) — no in-process thread can simulate that. Stderr lands -/// in `log` for the `DISPATCH::` replay evidence. +/// in `log` for diagnostics; DISPATCH lines report anomalies, not successful replay. struct Generation { child: Child, log: std::path::PathBuf, @@ -237,8 +237,10 @@ impl Generation { fn stderr_log(&self) -> String { std::fs::read_to_string(&self.log).unwrap_or_default() } +} - fn kill(mut self) { +impl Drop for Generation { + fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } @@ -352,8 +354,8 @@ fn render_until( /// generation's claim of the retained row. /// /// The invariant asserted: the fresh generation does not resurrect the seat. -/// Concretely — the control stamps do not come back, and gen2's log carries no -/// `DISPATCH::` replay line for the dead opener's stream. +/// Concretely — after the old controller's stamps clear, they do not come back. +/// Dispatcher log absence is not a replay oracle; the fresh sibling proves liveness. /// /// GREEN = the composition (sweep → finished → `finished_row_is_terminal` /// retire-unserved) already closes this, and no filter is owed. RED = the @@ -463,7 +465,7 @@ fn a_raw_dead_connection_bound_opener_is_not_re_served_by_the_next_generation() // ── The residual ordering ────────────────────────────────────────────── // 1. the generation dies FIRST: no serve worker survives to see an EOF, // and no Served report can retire anything. - gen1.kill(); + drop(gen1); // 2. and only THEN the rc opener dies RAW: its IPC conn closes with no // detach frame. The conn-exit sweep is now the sole mechanism. drop(operator); @@ -493,6 +495,26 @@ fn a_raw_dead_connection_bound_opener_is_not_re_served_by_the_next_generation() thread::sleep(Duration::from_millis(100)); } + // Opener retirement and old serve-connection cleanup run on different + // handlers. The latter clears driven_by and controlled in separate writes. + // Establish an unseated baseline BEFORE gen2 exists: an old/intermediate + // level (including controlled=true, driven_by=None) is not resurrection. + let end = Instant::now() + Duration::from_secs(20); + loop { + let info = read_info(&id); + if !info.controlled && info.driven_by.is_none() { + break; + } + assert!( + Instant::now() < end, + "CONTROL CLEANUP FAILED before gen2 started: controlled={} driven_by={:?}; \ + no replay has been attempted. {table_after_kill}", + info.controlled, + info.driven_by + ); + thread::sleep(Duration::from_millis(50)); + } + // ── The fresh generation: empty claim map, production rediscovery. ───── let gen2 = Generation::spawn(&name, dir.path().join("gen2.log")); @@ -511,6 +533,8 @@ fn a_raw_dead_connection_bound_opener_is_not_re_served_by_the_next_generation() } thread::sleep(Duration::from_millis(100)); } + let table_after_gen2 = render_rows(&all_rows(&broker)); + let gen2_log_before_sibling = gen2.stderr_log(); // ── SIBLING PROBE (mandatory, absence-needs-a-sibling-probe / the W3 // vacuous-probe lesson): everything above is an ABSENCE claim — "gen2 @@ -556,8 +580,6 @@ fn a_raw_dead_connection_bound_opener_is_not_re_served_by_the_next_generation() } let gen2_log = gen2.stderr_log(); - let table_after_gen2 = render_rows(&all_rows(&broker)); - let replayed = gen2_log.contains(&format!("DISPATCH:{claim_row}:")); // ── The transcript. On RED this text names the row that was re-served, // its class, its conn and its finished state — the evidence that picks @@ -567,10 +589,10 @@ fn a_raw_dead_connection_bound_opener_is_not_re_served_by_the_next_generation() \n T1 table while the seat was live: {table_live}\ \n T2 table after gen1 death + RAW opener death: {table_after_kill}\ \n T2 claimable view (what gen2 enumerates from): {claimable_after_kill}\ - \n T3 table after gen2 ran: {table_after_gen2}\ - \n gen2 replayed the row: {replayed}\ + \n T3 after gen2, before the fresh sibling: {table_after_gen2}\ + \n gen2 stderr before the fresh sibling: {gen2_log_before_sibling}\ \n sibling probe (gen2 served a FRESH opener): stamped={gen2_stamped}\ - \n gen2 stderr: {gen2_log}", + \n gen2 stderr including the fresh sibling: {gen2_log}", conn.conn_id ); @@ -599,13 +621,7 @@ fn a_raw_dead_connection_bound_opener_is_not_re_served_by_the_next_generation() REQ-STREAM-LIFETIME-CLASS owns.{transcript}", resurrected.unwrap_or_default() ); - assert!( - !replayed, - "RESTART-REPLAY: generation 2 claimed and re-served the dead opener's row \ - {claim_row}. Even with the stamps not (yet) back, the row was eligible when \ - its declared lifetime had already ended.{transcript}" - ); - gen2.kill(); + drop(gen2); let _ = probe.kill_session(); } diff --git a/crates/spt/tests/rc_attach_truth.rs b/crates/spt/tests/rc_attach_truth.rs index 3f9fc3f2..528dc5f3 100644 --- a/crates/spt/tests/rc_attach_truth.rs +++ b/crates/spt/tests/rc_attach_truth.rs @@ -524,6 +524,13 @@ fn harness_only_refuses_truthfully_pre_stream() { let home = tempfile::tempdir().unwrap(); std::env::set_var("SPT_HOME", home.path()); let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt")); + // This fixture injects a snapshot, not a peer heartbeat. A live pump may + // hydrate it and correctly evict the unheard foreign node before rc reads. + std::fs::write( + home.path().join("daemon.json"), + br#"{"relay":"disabled","peer_pump":false}"#, + ) + .unwrap(); let (mut broker, brain_pid, _brain_log) = spawn_broker(home.path(), &spt_bin); // LOCAL: the reproduced ONLINE - HARNESS ONLY row (api-listen shape).