diff --git a/crates/spt-daemon/tests/input_ack_deadlock.rs b/crates/spt-daemon/tests/input_ack_deadlock.rs index 92b06fbc..278580b0 100644 --- a/crates/spt-daemon/tests/input_ack_deadlock.rs +++ b/crates/spt-daemon/tests/input_ack_deadlock.rs @@ -101 +101 @@ static SEQ: AtomicU32 = AtomicU32::new(0); -const RETAINED_OUTPUT: &[u8] = b"ACKDL_OUTPUT"; +const POST_FLOOD_OUTPUT: &[u8] = b"ACKDL_LIVE_OUTPUT"; @@ -118,7 +118,6 @@ fn kill_pid(pid: u32) { -/// A SEEDED-THEN-QUIET child: it writes exactly one retained-output marker before -/// the flood, then neither reads stdin nor writes again. The seed makes the later -/// bounded replay diagnostic deterministic; waiting for the broker's output seq -/// before starting the flood keeps ALL child output outside the deadlock substrate. -/// During the flood, input is consumed by the PTY writer with NO echo, so the only -/// thing that can back up the brain↔broker conn is the pre-fix APPLIED-ACK stream. -fn seeded_quiet_spawn_req(endpoint: &str) -> SpawnReq { +/// A SEEDED-THEN-QUIET child: it emits one pre-flood marker, ignores stdin, and +/// emits a second marker only after the test creates `live_trigger`. The file is +/// created after the viewer has subscribed, so the second marker can only arrive +/// as live fan-out, never in the cold-attach repaint. +fn seeded_quiet_spawn_req(endpoint: &str, live_trigger: &std::path::Path) -> SpawnReq { + let trigger = live_trigger.display(); @@ -130 +129,3 @@ fn seeded_quiet_spawn_req(endpoint: &str) -> SpawnReq { - "printf 'ACKDL_OUTPUT\\n'; exec sleep 600".to_string(), + format!( + "printf 'ACKDL_OUTPUT\\n'; while [ ! -f '{trigger}' ]; do sleep 0.02; done; printf 'ACKDL_LIVE_OUTPUT\\n'; exec sleep 600" + ), @@ -135 +136 @@ fn seeded_quiet_spawn_req(endpoint: &str) -> SpawnReq { - "cmd".to_string(), + "powershell".to_string(), @@ -137,2 +138,5 @@ fn seeded_quiet_spawn_req(endpoint: &str) -> SpawnReq { - "/C".to_string(), - "echo ACKDL_OUTPUT & ping -n 600 127.0.0.1 >nul".to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + format!( + "$Host.UI.RawUI.WindowTitle='ackdl'; Write-Output 'ACKDL_OUTPUT'; while (-not (Test-Path -LiteralPath '{trigger}')) {{ Start-Sleep -Milliseconds 20 }}; Write-Output 'ACKDL_LIVE_OUTPUT'; Start-Sleep -Seconds 600" + ), @@ -269,3 +273,3 @@ fn probe(name: &str, cmd_kind: &'static str, want_kind: &'static str, deadline: -/// viewer attach still OPENS + is serviced + receives the flood child's output, and -/// the flood controller's detach registers. Pre-fix (ack=true) the return direction -/// fills, the handler deadlocks, and the concurrent attach is never serviced. +/// viewer attach still OPENS, receives a decodable repaint, and receives post-flood +/// live output. Pre-fix (ack=true) the return direction fills, the handler +/// deadlocks, and the concurrent attach is never serviced. @@ -281,0 +286 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { + let live_trigger = dir.path().join("emit-live-output"); @@ -285 +290 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - // ── The seeded-then-quiet child: emit ONE retained marker, then go silent. + // ── The seeded-then-quiet child: emit ONE marker, then wait on a file trigger. @@ -291 +296 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - .spawn_session(seeded_quiet_spawn_req(endpoint)) + .spawn_session(seeded_quiet_spawn_req(endpoint, &live_trigger)) @@ -306,2 +311 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - // concurrent output can confound the applied-ack deadlock substrate. The - // bounded receive diagnostic replays its retained pre-flood seed below. + // concurrent output can confound the applied-ack deadlock substrate. @@ -484,4 +488,6 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - // subscribes, and replays the retained pre-flood output. Pre-fix the per-conn - // handler is deadlocked on the flood's ack stream and this subscribe is never - // serviced. Run on its own thread; the outer recv_timeout is the hard ceiling. ── - let (result_tx, result_rx) = std::sync::mpsc::channel::<(bool, bool)>(); + // subscribes, decodes the cold repaint, then receives output emitted only + // after that subscription. Pre-fix the per-conn handler is deadlocked on the + // flood's ack stream and this subscribe is never serviced. Run on its own + // thread; the outer recv_timeout is the hard ceiling. ── + let (result_tx, result_rx) = std::sync::mpsc::channel::<(bool, bool, bool)>(); + let (viewer_ready_tx, viewer_ready_rx) = std::sync::mpsc::channel::(); @@ -502 +508 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - let _ = result_tx.send((false, false)); + let _ = result_tx.send((false, false, false)); @@ -509 +515 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - let _ = result_tx.send((false, false)); + let _ = result_tx.send((false, false, false)); @@ -524 +530 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - let _ = result_tx.send((false, false)); + let _ = result_tx.send((false, false, false)); @@ -536 +542 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - let _ = result_tx.send((subscribed, false)); + let _ = result_tx.send((subscribed, false, false)); @@ -555,5 +561,3 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - // Retained-replay contract: the pre-flood ACKDL_OUTPUT record is already in - // the broker ring, and this VIEWER asks from_seq=0. It MUST remain a viewer: - // a same-origin equal-lease CONTROL retake deliberately does not replay - // history or self-displace, making from_seq=0 inert. Resize is only an - // explicit post-subscribe stream wake. + // The cold VIEWER asks from_seq=0. It MUST remain a viewer: a same-origin + // equal-lease CONTROL retake deliberately does not self-displace. Resize is + // an explicit post-subscribe stream wake; its first output is a repaint. @@ -561 +565,2 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - let _ = result_tx.send((subscribed, false)); + let _ = viewer_ready_tx.send(false); + let _ = result_tx.send((subscribed, false, false)); @@ -564,8 +569,8 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - - // The VIEWER must receive and decode the retained ACKDL_OUTPUT seeded before - // the flood. The child remains silent throughout the flood, so output cannot - // confound the deadlock substrate. Only enter the bounded Split-carrier read - // loop if the subscribe was SERVICED — if it was not (the deadlock face), - // report (false,false) immediately. A Whole carrier would refuse this deadline - // by name rather than silently converting it to an unbounded read. - let mut got_output = false; + let _ = viewer_ready_tx.send(subscribed); + + // A cold attach is entitled to a repaint, not retained ring bytes. First + // prove that repaint is non-empty and decodable; then keep reading until + // the marker emitted only after the main thread's post-subscribe trigger + // arrives as live fan-out. + let mut got_decodable_output = false; + let mut got_live_output = false; @@ -584,0 +590 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { + got_decodable_output |= !chunk.is_empty(); @@ -586,3 +592,3 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - got_output = received - .windows(RETAINED_OUTPUT.len()) - .any(|window| window == RETAINED_OUTPUT); + got_live_output = received + .windows(POST_FLOOD_OUTPUT.len()) + .any(|window| window == POST_FLOOD_OUTPUT); @@ -592 +598 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - if got_output { + if got_live_output { @@ -608 +614 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - let _ = result_tx.send((subscribed, got_output)); + let _ = result_tx.send((subscribed, got_decodable_output, got_live_output)); @@ -614 +620,8 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - let (subscribed, got_output) = result_rx + let viewer_ready = viewer_ready_rx + .recv_timeout(Duration::from_secs(20)) + .unwrap_or(false); + if viewer_ready { + std::fs::write(&live_trigger, b"go").expect("release post-flood live output"); + } + + let (subscribed, got_decodable_output, got_live_output) = result_rx @@ -616 +629 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - .unwrap_or((false, false)); + .unwrap_or((false, false, false)); @@ -637,5 +650,5 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - concurrent_attach_subscribed={subscribed} attach_received_output={got_output} \ - (fixed = flood_verdict=Sent + broker_alive + sessions_answered true; pre-fix \ - on a small-pipe/forkpty platform: the applied-ack stream fills the return \ - direction → the per-conn handler deadlocks → flood watchdog times out. The \ - loopback leg [subscribed/got_output] is a captured diagnostic, not asserted.) ===" + concurrent_attach_subscribed={subscribed} repaint_decodable={got_decodable_output} \ + post_flood_live_output={got_live_output} (fixed = flood_verdict=Sent + broker_alive + \ + sessions_answered + subscribed + repaint_decodable + post_flood_live_output true; \ + pre-fix on a small-pipe/forkpty platform: the applied-ack stream fills the return \ + direction → the per-conn handler deadlocks → flood watchdog times out.) ===" @@ -682,5 +695,5 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - // (4) The post-flood VIEWER is serviced and its bounded receive path decodes - // ACKDL_OUTPUT from the retained pre-flood ring record. The old two-dial - // demux-race hedge manifested as stale first-row stream selection; - // wait_for_latest_stream selects the later VIEWER, making this assert sound. - // Resize above is only a wake; the asserted seed sequence is the stimulus. + // (4) The post-flood VIEWER is serviced, receives a non-empty decodable cold + // repaint, then receives ACKDL_LIVE_OUTPUT as live fan-out after its + // subscription. The repaint proves the served viewer path is alive without + // assuming that a cold attach replays retained ring bytes; the second marker + // proves output still flows after the flood. @@ -688,3 +701,4 @@ fn input_flood_through_serve_attach_does_not_deadlock_broker() { - subscribed && got_output, - "the concurrent VIEWER must subscribe and replay retained ACKDL_OUTPUT; \ - subscribed={subscribed} got_output={got_output}" + subscribed && got_decodable_output && got_live_output, + "the concurrent VIEWER must subscribe, decode its repaint, and receive \ + post-flood live output; subscribed={subscribed} \ + got_decodable_output={got_decodable_output} got_live_output={got_live_output}"