120	            op_id: None,
121	        })
122	        .unwrap(),
123	    );
124	    let echoed = loop {
125	        if String::from_utf8_lossy(&out).contains("INJECT-ALPHA") {
126	            break true;
127	        }
128	        match read_frame(&mut conn) {
129	            Ok(f) if f.kind == KIND_OUTPUT => accumulate_output(&f, &mut out),
130	            Ok(_) => {}
131	            Err(_) => break false, // recv timeout → echo never arrived
132	        }
133	    };
134
135	    // Kill the child and read until the `exit` event.
136	    send(
137	        &mut conn,
138	        KIND_KILL,
139	        serde_json::to_value(KillReq { session_id: sid }).unwrap(),
140	    );
141	    let mut exit: Option<ExitEvent> = None;
142	    loop {
143	        match read_frame(&mut conn) {
144	            Ok(f) if f.kind == KIND_EXIT => {
145	                exit = Some(serde_json::from_value(f.payload).unwrap());
146	                break;
147	            }
148	            Ok(f) if f.kind == KIND_OUTPUT => accumulate_output(&f, &mut out),
149	            Ok(_) => {}
150	            Err(_) => break, // timeout
151	        }
152	    }
153
154	    // Disconnect: dropping the single Stream closes the fd, so the broker's
155	    // command loop hits EOF and tears the session down. Joining the handler
156	    // (via the server thread) lets us assert the teardown happened.
157	    drop(conn);
158	    server.join().expect("server thread");
159
160	    assert!(
161	        echoed,
162	        "injected input must round-trip through the PTY: {:?}",
163	        String::from_utf8_lossy(&out)
164	    );
165	    let exit = exit.expect("exit event after kill");
166	    assert_eq!(exit.session_id, sid);
167	    assert_eq!(broker.session_count(), 0, "session torn down on disconnect");
168	}
169
170	fn accumulate_output(frame: &Envelope, out: &mut Vec<u8>) {
171	    let ev: OutputEvent = serde_json::from_value(frame.payload.clone()).unwrap();
172	    out.extend_from_slice(&decode_bytes(&ev.data_b64).unwrap());
173	}
174
175	#[test]
176	fn broker_hosts_pty_child_streams_output_and_accepts_input() {
177	    main_test();
178	}
179
180	/// REQ-SEND-SPT-HOSTED: an inbound message addressed by ENDPOINT ID
181	/// (`KIND_ENDPOINT_INPUT`) is injected into the PTY of the session hosting that
182	/// endpoint — the spt-hosted delivery path a `spt send` rides when there is no
183	/// `api listen` relay. Spawn an echo child LABELLED with an endpoint id; an
184	/// endpoint-keyed inject round-trips through the real PTY and the broker replies
185	/// `delivered=true`. An inject for an UNKNOWN endpoint replies `delivered=false`
186	/// and writes nothing (the caller then falls back to the spool).
187	// [int->REQ-SEND-SPT-HOSTED]
188	fn endpoint_input_test() {
189	    let name = unique_name();
190	    let broker = Broker::bind(&name).expect("bind broker");
191	    let server_broker = Arc::clone(&broker);
192	    let server = thread::spawn(move || {
193	        let handle = server_broker.serve_once().expect("accept brain");
194	        handle.join().expect("handler thread");
195	    });
196
197	    let mut conn = None;
198	    for _ in 0..200 {
199	        match LocalSocketTransport::connect(&name) {
200	            Ok(c) => {
201	                conn = Some(c);
202	                break;
203	            }
204	            Err(_) => thread::sleep(Duration::from_millis(10)),
205	        }
206	    }
207	    let mut conn = conn.expect("connect to broker");
208	    send_hello(&mut conn, Role::Brain).expect("send hello");
209
210	    // Spawn the echo child LABELLED with an endpoint id (the spawn.endpoint the
211	    // broker tracks per session — what KIND_ENDPOINT_INPUT resolves against).
212	    let mut spawn = echo_spawn_req();
213	    spawn.endpoint = "wall-b".to_string();
214	    send(&mut conn, KIND_SPAWN, serde_json::to_value(spawn).unwrap());
215	    let mut out = Vec::<u8>::new();
216	    loop {
217	        let f = read_frame(&mut conn).expect("frame before spawned");
218	        match f.kind.as_str() {
219	            KIND_SPAWNED => break,
220	            KIND_OUTPUT => accumulate_output(&f, &mut out),
221	            _ => {}
222	        }
223	    }
224
225	    // Endpoint-keyed inject (NO session_id — resolved by endpoint): round-trips
226	    // through the real PTY, and the broker acks delivered=true.
227	    send(
228	        &mut conn,
229	        KIND_ENDPOINT_INPUT,
230	        serde_json::to_value(EndpointInputReq {
231	            endpoint: "wall-b".to_string(),
232	            data_b64: encode_bytes(b"ENDPOINT-INJECT-BETA\r"),
233	        })
234	        .unwrap(),
235	    );
236	    let mut delivered: Option<bool> = None;
237	    let mut echoed = false;
238	    loop {
239	        if String::from_utf8_lossy(&out).contains("ENDPOINT-INJECT-BETA") {

            op_id: None,
        })
        .unwrap(),
    );
    let echoed = loop {
        if String::from_utf8_lossy(&out).contains("INJECT-ALPHA") {
            break true;
        }
        match read_frame(&mut conn) {
            Ok(f) if f.kind == KIND_OUTPUT => accumulate_output(&f, &mut out),
            Ok(_) => {}
            Err(_) => break false, // recv timeout → echo never arrived
        }
    };

    // Kill the child and read until the `exit` event.
    send(
        &mut conn,
        KIND_KILL,
        serde_json::to_value(KillReq { session_id: sid }).unwrap(),
    );
    let mut exit: Option<ExitEvent> = None;
    loop {
        match read_frame(&mut conn) {
            Ok(f) if f.kind == KIND_EXIT => {
                exit = Some(serde_json::from_value(f.payload).unwrap());
                break;
            }
            Ok(f) if f.kind == KIND_OUTPUT => accumulate_output(&f, &mut out),
            Ok(_) => {}
            Err(_) => break, // timeout
        }
    }

    // Disconnect: dropping the single Stream closes the fd, so the broker's
    // command loop hits EOF and tears the session down. Joining the handler
    // (via the server thread) lets us assert the teardown happened.
    drop(conn);
    server.join().expect("server thread");

    assert!(
        echoed,
        "injected input must round-trip through the PTY: {:?}",
        String::from_utf8_lossy(&out)
    );
    let exit = exit.expect("exit event after kill");
    assert_eq!(exit.session_id, sid);
    assert_eq!(broker.session_count(), 0, "session torn down on disconnect");
}

fn accumulate_output(frame: &Envelope, out: &mut Vec<u8>) {
    let ev: OutputEvent = serde_json::from_value(frame.payload.clone()).unwrap();
    out.extend_from_slice(&decode_bytes(&ev.data_b64).unwrap());
}

#[test]
fn broker_hosts_pty_child_streams_output_and_accepts_input() {
    main_test();
}

/// REQ-SEND-SPT-HOSTED: an inbound message addressed by ENDPOINT ID
/// (`KIND_ENDPOINT_INPUT`) is injected into the PTY of the session hosting that
/// endpoint — the spt-hosted delivery path a `spt send` rides when there is no
/// `api listen` relay. Spawn an echo child LABELLED with an endpoint id; an
/// endpoint-keyed inject round-trips through the real PTY and the broker replies
/// `delivered=true`. An inject for an UNKNOWN endpoint replies `delivered=false`
/// and writes nothing (the caller then falls back to the spool).
// [int->REQ-SEND-SPT-HOSTED]
fn endpoint_input_test() {
    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");
    let server_broker = Arc::clone(&broker);
    let server = thread::spawn(move || {
        let handle = server_broker.serve_once().expect("accept brain");
        handle.join().expect("handler thread");
    });

    let mut conn = None;
    for _ in 0..200 {
        match LocalSocketTransport::connect(&name) {
            Ok(c) => {
                conn = Some(c);
                break;
            }
            Err(_) => thread::sleep(Duration::from_millis(10)),
        }
    }
    let mut conn = conn.expect("connect to broker");
    send_hello(&mut conn, Role::Brain).expect("send hello");

    // Spawn the echo child LABELLED with an endpoint id (the spawn.endpoint the
    // broker tracks per session — what KIND_ENDPOINT_INPUT resolves against).
    let mut spawn = echo_spawn_req();
    spawn.endpoint = "wall-b".to_string();
    send(&mut conn, KIND_SPAWN, serde_json::to_value(spawn).unwrap());
    let mut out = Vec::<u8>::new();
    loop {
        let f = read_frame(&mut conn).expect("frame before spawned");
        match f.kind.as_str() {
            KIND_SPAWNED => break,
            KIND_OUTPUT => accumulate_output(&f, &mut out),
            _ => {}
        }
    }

    // Endpoint-keyed inject (NO session_id — resolved by endpoint): round-trips
    // through the real PTY, and the broker acks delivered=true.
    send(
        &mut conn,
        KIND_ENDPOINT_INPUT,
        serde_json::to_value(EndpointInputReq {
            endpoint: "wall-b".to_string(),
            data_b64: encode_bytes(b"ENDPOINT-INJECT-BETA\r"),
        })
        .unwrap(),
    );
    let mut delivered: Option<bool> = None;
    let mut echoed = false;
    loop {
        if String::from_utf8_lossy(&out).contains("ENDPOINT-INJECT-BETA") {


The differential needs care: `session_output_seq` holds the *sessions* lock while blocking on the log lock, so it'd hang `session_count` too. Better wedge proof — a fresh brain conn: `KIND_NET_STATUS` replies (broker process alive) but `KIND_SESSIONS` hangs (enumeration blocks on the parked session's log lock at broker.rs:782). Plus the `APPEND_CTRL_ENTER`-with-no-`EXIT` instrumentation. Write it.


Write

//! v0.13.0 W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) — the CONTROLLER-DELIVERY park
//! repro, on a REAL broker hosting a REAL PTY child over the real local IPC (no
//! mocks of the PTY or the transport).
//!
//! ## What this proves (park (a), the output-stall / wedge root)
//!
//! [`OutputLog::append`] (broker.rs) fans each output chunk to the CONTROLLER via
//! a SYNCHRONOUS, BLOCKING `write_frame` held INLINE on the session's single
//! drain thread, while the `Mutex<OutputLog>` is locked. VIEWERS instead get a
//! dedicated writer thread + a bounded, evicting `sync_channel` and can never
//! stall the drain.
//!
//! So when the controller's socket backs up (a slow / non-draining operator —
//! here a controller connection that simply stops reading while the child
//! floods), the drain thread BLOCKS inside `append` STILL HOLDING the log lock.
//! That is the wedge: output stalls, and any path that needs that session's log
//! lock (a fresh attach, or even the global `KIND_SESSIONS` enumeration, which
//! reads each log's `delivered_through`) blocks forever — while the broker
//! PROCESS is otherwise alive (a fresh connection's `KIND_NET_STATUS`, which
//! touches neither the sessions nor the log lock, still answers).
//!
//! Run with `SPT_BROKER_DEBUG_PARK=1 cargo test -p spt-daemon --test
//! inject_control_wedge -- --nocapture` to see the park point on stderr: an
//! `APPEND_CTRL_ENTER sid=.. seq=N` with NO matching `APPEND_CTRL_EXIT` = the
//! drain thread parked on the controller `write_frame` at broker.rs:223.
//!
//! Pre-fix this test asserts the wedge (the bug). The W1 fix moves controller
//! delivery off the drain thread (dedicated writer, deadline-bounded) — at which
//! point this flips to "no wedge" and folds into the int gate.

use std::sync::Arc;
use std::thread;
use std::time::Duration;

use interprocess::local_socket::Stream;
use spt_daemon::codec::{read_frame, write_frame};
use spt_daemon::frame::{Envelope, Role};
use spt_daemon::msg::{
    SpawnReq, Spawned, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY,
    KIND_SPAWN, KIND_SPAWNED,
};
use spt_daemon::transport::{send_hello, LocalSocketTransport};
use spt_daemon::Broker;

use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-w1wedge-{}-{}.sock", std::process::id(), n)
}

fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = std::process::Command::new("taskkill")
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = std::process::Command::new("kill")
        .args(["-9", &pid.to_string()])
        .output();
}

/// A child that FLOODS stdout forever (never reads stdin) — the heavy-output
/// case (a real TUI redraw burst). Cross-OS, same shape as the broker B1 test's
/// `cat`/`findstr` echo child.
fn flood_spawn_req(endpoint: &str) -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("yes".to_string(), vec!["FLOODFLOODFLOODFLOOD".to_string()]);
    #[cfg(windows)]
    let (program, args) = (
        "cmd".to_string(),
        vec![
            "/c".to_string(),
            "for /l %x in (0,0,1) do @echo FLOODFLOODFLOODFLOODFLOODFLOODFLOOD".to_string(),
        ],
    );
    SpawnReq {
        program,
        args,
        rows: 24,
        cols: 80,
        endpoint: endpoint.to_string(),
        cwd: None,
        env: Default::default(),
    }
}

fn send(conn: &mut Stream, kind: &str, payload: serde_json::Value) {
    write_frame(conn, &Envelope::new(kind, payload)).expect("send frame");
}

/// Open a fresh brain connection, send one command, and return whether the
/// expected reply kind arrives within `deadline`. Runs the whole exchange on a
/// spawned thread so a BLOCKED `read_frame` (the wedge) surfaces as a timeout
/// instead of hanging the test. A `false` return = "did not answer in time".
fn probe(name: &str, cmd_kind: &'static str, want_kind: &'static str, deadline: Duration) -> bool {
    let name = name.to_string();
    let (tx, rx) = std::sync::mpsc::channel::<bool>();
    thread::spawn(move || {
        let mut conn = match LocalSocketTransport::connect(&name) {
            Ok(c) => c,
            Err(_) => {
                let _ = tx.send(false);
                return;
            }
        };
        if send_hello(&mut conn, Role::Brain).is_err() {
            let _ = tx.send(false);
            return;
        }
        send(&mut conn, cmd_kind, serde_json::json!({}));
        loop {
            match read_frame(&mut conn) {
                Ok(f) if f.kind == want_kind => {
                    let _ = tx.send(true);
                    return;
                }
                Ok(_) => continue, // ignore interleaved frames
                Err(_) => {
                    let _ = tx.send(false);
                    return;
                }
            }
        }
    });
    rx.recv_timeout(deadline).unwrap_or(false)
}

#[test]
fn controller_backpressure_parks_the_drain_thread_and_wedges_the_session() {
    // Make the park instrumentation visible when run with --nocapture (cached on
    // first append; this test is the first/only appender in this binary).
    std::env::set_var("SPT_BROKER_DEBUG_PARK", "1");

    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");
    let serve_broker = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve_broker.serve(); // accept every connection on its own thread
    });

    // ── Controller connection A: spawn the flooding child. dispatch_spawn
    //    pre-attaches A as the CONTROLLER (become_controller, by=None). ──
    let mut a = None;
    for _ in 0..200 {
        match LocalSocketTransport::connect(&name) {
            Ok(c) => {
                a = Some(c);
                break;
            }
            Err(_) => thread::sleep(Duration::from_millis(10)),
        }
    }
    let mut a = a.expect("connect controller A");
    send_hello(&mut a, Role::Brain).expect("hello A");
    send(
        &mut a,
        KIND_SPAWN,
        serde_json::to_value(flood_spawn_req("wedge-ep")).unwrap(),
    );
    let sid = loop {
        let f = read_frame(&mut a).expect("frame before spawned");
        if f.kind == KIND_SPAWNED {
            break serde_json::from_value::<Spawned>(f.payload).unwrap().session_id;
        }
    };

    // ── A now STOPS reading. The child floods; the broker's drain thread calls
    //    append → write_frame(controller=A); once A's socket recv buffer fills,
    //    that write blocks → the drain parks inside append HOLDING the log lock.
    //    Give the flood time to fill the buffer. ──
    thread::sleep(Duration::from_secs(4));

    // ── Differential proof. A fresh connection's KIND_NET_STATUS touches neither
    //    the sessions nor the log lock → the broker PROCESS is alive and answers
    //    fast. KIND_SESSIONS enumerates every session's log (`delivered_through`
    //    under the log lock) → it blocks on the parked session's held log lock →
    //    WEDGE. ──
    let broker_alive = probe(&name, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, Duration::from_secs(5));
    let sessions_answered =
        probe(&name, KIND_SESSIONS, KIND_SESSIONS_REPLY, Duration::from_secs(5));

    eprintln!(
        "=== W1 DIAGNOSTIC: broker_alive(net_status)={broker_alive} \
         sessions_answered={sessions_answered} (expect alive=true, sessions=false = WEDGE) ==="
    );

    // Reap the flood child (scoped) + tear the broker down.
    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }
    drop(a);

    assert!(
        broker_alive,
        "the broker PROCESS must still answer a fresh connection's net-status \
         (the wedge is per-session, not a dead process)"
    );
    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !sessions_answered,
        "REPRO: a backed-up controller parks the drain thread inside append() \
         HOLDING the log lock, so KIND_SESSIONS (which reads each log's \
         delivered_through) hangs — the session is wedged. \
         When this assertion FLIPS (sessions answered), the W1 fix has moved \
         controller delivery off the drain thread."
    );
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\inject_control_wedge.rs (file state is current in your context — no need to Read it back)

//! v0.13.0 W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) — the CONTROLLER-DELIVERY park
//! repro, on a REAL broker hosting a REAL PTY child over the real local IPC (no
//! mocks of the PTY or the transport).
//!
//! ## What this proves (park (a), the output-stall / wedge root)
//!
//! [`OutputLog::append`] (broker.rs) fans each output chunk to the CONTROLLER via
//! a SYNCHRONOUS, BLOCKING `write_frame` held INLINE on the session's single
//! drain thread, while the `Mutex<OutputLog>` is locked. VIEWERS instead get a
//! dedicated writer thread + a bounded, evicting `sync_channel` and can never
//! stall the drain.
//!
//! So when the controller's socket backs up (a slow / non-draining operator —
//! here a controller connection that simply stops reading while the child
//! floods), the drain thread BLOCKS inside `append` STILL HOLDING the log lock.
//! That is the wedge: output stalls, and any path that needs that session's log
//! lock (a fresh attach, or even the global `KIND_SESSIONS` enumeration, which
//! reads each log's `delivered_through`) blocks forever — while the broker
//! PROCESS is otherwise alive (a fresh connection's `KIND_NET_STATUS`, which
//! touches neither the sessions nor the log lock, still answers).
//!
//! Run with `SPT_BROKER_DEBUG_PARK=1 cargo test -p spt-daemon --test
//! inject_control_wedge -- --nocapture` to see the park point on stderr: an
//! `APPEND_CTRL_ENTER sid=.. seq=N` with NO matching `APPEND_CTRL_EXIT` = the
//! drain thread parked on the controller `write_frame` at broker.rs:223.
//!
//! Pre-fix this test asserts the wedge (the bug). The W1 fix moves controller
//! delivery off the drain thread (dedicated writer, deadline-bounded) — at which
//! point this flips to "no wedge" and folds into the int gate.

use std::sync::Arc;
use std::thread;
use std::time::Duration;

use interprocess::local_socket::Stream;
use spt_daemon::codec::{read_frame, write_frame};
use spt_daemon::frame::{Envelope, Role};
use spt_daemon::msg::{
    SpawnReq, Spawned, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY,
    KIND_SPAWN, KIND_SPAWNED,
};
use spt_daemon::transport::{send_hello, LocalSocketTransport};
use spt_daemon::Broker;

use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-w1wedge-{}-{}.sock", std::process::id(), n)
}

fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = std::process::Command::new("taskkill")
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = std::process::Command::new("kill")
        .args(["-9", &pid.to_string()])
        .output();
}

/// A child that FLOODS stdout forever (never reads stdin) — the heavy-output
/// case (a real TUI redraw burst). Cross-OS, same shape as the broker B1 test's
/// `cat`/`findstr` echo child.
fn flood_spawn_req(endpoint: &str) -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("yes".to_string(), vec!["FLOODFLOODFLOODFLOOD".to_string()]);
    #[cfg(windows)]
    let (program, args) = (
        "cmd".to_string(),
        vec![
            "/c".to_string(),
            "for /l %x in (0,0,1) do @echo FLOODFLOODFLOODFLOODFLOODFLOODFLOOD".to_string(),
        ],
    );
    SpawnReq {
        program,
        args,
        rows: 24,
        cols: 80,
        endpoint: endpoint.to_string(),
        cwd: None,
        env: Default::default(),
    }
}

fn send(conn: &mut Stream, kind: &str, payload: serde_json::Value) {
    write_frame(conn, &Envelope::new(kind, payload)).expect("send frame");
}

/// Open a fresh brain connection, send one command, and return whether the
/// expected reply kind arrives within `deadline`. Runs the whole exchange on a
/// spawned thread so a BLOCKED `read_frame` (the wedge) surfaces as a timeout
/// instead of hanging the test. A `false` return = "did not answer in time".
fn probe(name: &str, cmd_kind: &'static str, want_kind: &'static str, deadline: Duration) -> bool {
    let name = name.to_string();
    let (tx, rx) = std::sync::mpsc::channel::<bool>();
    thread::spawn(move || {
        let mut conn = match LocalSocketTransport::connect(&name) {
            Ok(c) => c,
            Err(_) => {
                let _ = tx.send(false);
                return;
            }
        };
        if send_hello(&mut conn, Role::Brain).is_err() {
            let _ = tx.send(false);
            return;
        }
        send(&mut conn, cmd_kind, serde_json::json!({}));
        loop {
            match read_frame(&mut conn) {
                Ok(f) if f.kind == want_kind => {
                    let _ = tx.send(true);
                    return;
                }
                Ok(_) => continue, // ignore interleaved frames
                Err(_) => {
                    let _ = tx.send(false);
                    return;
                }
            }
        }
    });
    rx.recv_timeout(deadline).unwrap_or(false)
}

#[test]
fn controller_backpressure_parks_the_drain_thread_and_wedges_the_session() {
    // Make the park instrumentation visible when run with --nocapture (cached on
    // first append; this test is the first/only appender in this binary).
    std::env::set_var("SPT_BROKER_DEBUG_PARK", "1");

    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");
    let serve_broker = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve_broker.serve(); // accept every connection on its own thread
    });

    // ── Controller connection A: spawn the flooding child. dispatch_spawn
    //    pre-attaches A as the CONTROLLER (become_controller, by=None). ──
    let mut a = None;
    for _ in 0..200 {
        match LocalSocketTransport::connect(&name) {
            Ok(c) => {
                a = Some(c);
                break;
            }
            Err(_) => thread::sleep(Duration::from_millis(10)),
        }
    }
    let mut a = a.expect("connect controller A");
    send_hello(&mut a, Role::Brain).expect("hello A");
    send(
        &mut a,
        KIND_SPAWN,
        serde_json::to_value(flood_spawn_req("wedge-ep")).unwrap(),
    );
    let sid = loop {
        let f = read_frame(&mut a).expect("frame before spawned");
        if f.kind == KIND_SPAWNED {
            break serde_json::from_value::<Spawned>(f.payload).unwrap().session_id;
        }
    };

    // ── A now STOPS reading. The child floods; the broker's drain thread calls
    //    append → write_frame(controller=A); once A's socket recv buffer fills,
    //    that write blocks → the drain parks inside append HOLDING the log lock.
    //    Give the flood time to fill the buffer. ──
    thread::sleep(Duration::from_secs(4));

    // ── Differential proof. A fresh connection's KIND_NET_STATUS touches neither
    //    the sessions nor the log lock → the broker PROCESS is alive and answers
    //    fast. KIND_SESSIONS enumerates every session's log (`delivered_through`
    //    under the log lock) → it blocks on the parked session's held log lock →
    //    WEDGE. ──
    let broker_alive = probe(&name, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, Duration::from_secs(5));
    let sessions_answered =
        probe(&name, KIND_SESSIONS, KIND_SESSIONS_REPLY, Duration::from_secs(5));

    eprintln!(
        "=== W1 DIAGNOSTIC: broker_alive(net_status)={broker_alive} \
         sessions_answered={sessions_answered} (expect alive=true, sessions=false = WEDGE) ==="
    );

    // Reap the flood child (scoped) + tear the broker down.
    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }
    drop(a);

    assert!(
        broker_alive,
        "the broker PROCESS must still answer a fresh connection's net-status \
         (the wedge is per-session, not a dead process)"
    );
    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !sessions_answered,
        "REPRO: a backed-up controller parks the drain thread inside append() \
         HOLDING the log lock, so KIND_SESSIONS (which reads each log's \
         delivered_through) hangs — the session is wedged. \
         When this assertion FLIPS (sessions answered), the W1 fix has moved \
         controller delivery off the drain thread."
    );
}


Build + run the repro (background, stay reachable).


Bash

SPT_BROKER_DEBUG_PARK=1 cargo test -p spt-daemon --test inject_control_wedge -- --nocapture --test-threads=1 2>&1 | tail -80

Build + run W1 controller-backpressure wedge repro


Command running in background with ID: b930xhhyb. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\b930xhhyb.output. You will be notified when it completes. To check interim output, use Read on that file path.


Test building in background. Meanwhile, prep W7 part-2: read `spawn_stdin_reader` (rc.rs:~152) to add the `SPT_RC_DEBUG_KEYS` hexdump so the operator can capture the Backspace byte.


120	/// byte passes through. Pure so it unit-tests without a terminal. On detach the
121	/// bytes BEFORE the keybind are returned to forward; bytes after are dropped (the
122	/// viewport is ending).
123	fn parse_stdin_chunk(armed: &mut bool, input: &[u8]) -> DetachParse {
124	    let mut forward = Vec::with_capacity(input.len());
125	    for &b in input {
126	        if *armed {
127	            *armed = false;
128	            match b {
129	                DETACH_KEY => return DetachParse { forward, detach: true },
130	                DETACH_PREFIX => forward.push(DETACH_PREFIX), // literal ctrl-b
131	                other => {
132	                    forward.push(DETACH_PREFIX);
133	                    forward.push(other);
134	                }
135	            }
136	        } else if b == DETACH_PREFIX {
137	            *armed = true;
138	        } else {
139	            forward.push(b);
140	        }
141	    }
142	    DetachParse {
143	        forward,
144	        detach: false,
145	    }
146	}
147
148	/// Spawn the stdin reader: raw bytes → `StdinMsg::Bytes`, watching for the
149	/// detach keybind ([`parse_stdin_chunk`]). Detaching sends `StdinMsg::Detach`
150	/// and ends the thread; the leaked blocking `read` (if any) dies with the
151	/// process on exit.
152	fn spawn_stdin_reader(tx: mpsc::Sender<StdinMsg>) {
153	    std::thread::spawn(move || {
154	        let mut stdin = std::io::stdin().lock();
155	        let mut buf = [0u8; 1024];
156	        let mut armed = false; // saw the detach prefix, awaiting the next byte
157	        loop {
158	            let n = match stdin.read(&mut buf) {
159	                Ok(0) => break, // stdin closed
160	                Ok(n) => n,
161	                Err(_) => break,
162	            };
163	            let parsed = parse_stdin_chunk(&mut armed, &buf[..n]);
164	            if !parsed.forward.is_empty() && tx.send(StdinMsg::Bytes(parsed.forward)).is_err() {
165	                break;
166	            }
167	            if parsed.detach {
168	                let _ = tx.send(StdinMsg::Detach);
169	                return;
170	            }
171	        }
172	    });
173	}
174
175	/// Attach a local terminal to the broker-held session for `endpoint_id` under
176	/// `intent` (REQ-RCVIEW-1 / REQ-KICK-1): `Control` drives a free endpoint (and is
177	/// busy-refused with guidance on a controlled one), `Viewer` watches read-only,
178	/// `Take` kicks the incumbent controller (loud `Displaced` notice to them) and
179	/// takes control. Returns when the session exits, the operator detaches, or an
180	/// error occurs.
181	// [impl->REQ-RC-1] [impl->REQ-RCVIEW-1] [impl->REQ-KICK-1]
182	pub fn run_attach(endpoint_id: &str, intent: AttachIntent) -> Result<(), String> {
183	    let view = intent == AttachIntent::Viewer;
184	    // Busy-refuse rendered CLIENT-SIDE (REQ-RCVIEW-1): a plain `Control` attach to
185	    // an endpoint a DIFFERENT node already controls gets actionable guidance here
186	    // (the broker would also refuse on the wire — this is the friendly path). A
187	    // `--view` / `--take` bypasses it (watching coexists; taking displaces).
188	    if intent == AttachIntent::Control {
189	        if let Some(node) = current_driver(endpoint_id) {
190	            println!("'{endpoint_id}' is controlled by {node}.");
191	            println!("  spt rc {endpoint_id} --view   to watch read-only");
192	            println!("  spt rc {endpoint_id} --take   to take control (kicks {node})");
193	            return Ok(());
194	        }
195	    }
196	    // (a) STATUS-GATE (REQ-HAZARD-RC-ATTACH-FAILFAST): a perch with an EXPLICIT
197	    // `status=offline` is not attachable — short-circuit with clean guidance
198	    // [impl->REQ-HAZARD-RC-ATTACH-FAILFAST]
199	    // instead of the broker dance that would block on a dead session (the
200	    // infinite-blank hang). The B2 pull-reconcile / `endpoint stop` stamp a dead
201	    // spt-hosted perch offline, so this catches the common case before any IPC. An
202	    // ABSENT or status-less perch falls through to the broker path, which already
203	    // ends cleanly ("no live session for endpoint") — not a hang. (is_perch_alive
204	    // is NOT used here: it returns true for an absent info.json by interim parity.)
205	    let perch_path =
206	        spt_store::perch::resolve_perch_path(endpoint_id, spt_store::perch::ParentHint::Infer);
207	    if spt_store::info::read_info(&perch_path)
208	        .and_then(|i| i.status)
209	        .as_deref()

/// byte passes through. Pure so it unit-tests without a terminal. On detach the
/// bytes BEFORE the keybind are returned to forward; bytes after are dropped (the
/// viewport is ending).
fn parse_stdin_chunk(armed: &mut bool, input: &[u8]) -> DetachParse {
    let mut forward = Vec::with_capacity(input.len());
    for &b in input {
        if *armed {
            *armed = false;
            match b {
                DETACH_KEY => return DetachParse { forward, detach: true },
                DETACH_PREFIX => forward.push(DETACH_PREFIX), // literal ctrl-b
                other => {
                    forward.push(DETACH_PREFIX);
                    forward.push(other);
                }
            }
        } else if b == DETACH_PREFIX {
            *armed = true;
        } else {
            forward.push(b);
        }
    }
    DetachParse {
        forward,
        detach: false,
    }
}

/// Spawn the stdin reader: raw bytes → `StdinMsg::Bytes`, watching for the
/// detach keybind ([`parse_stdin_chunk`]). Detaching sends `StdinMsg::Detach`
/// and ends the thread; the leaked blocking `read` (if any) dies with the
/// process on exit.
fn spawn_stdin_reader(tx: mpsc::Sender<StdinMsg>) {
    std::thread::spawn(move || {
        let mut stdin = std::io::stdin().lock();
        let mut buf = [0u8; 1024];
        let mut armed = false; // saw the detach prefix, awaiting the next byte
        loop {
            let n = match stdin.read(&mut buf) {
                Ok(0) => break, // stdin closed
                Ok(n) => n,
                Err(_) => break,
            };
            let parsed = parse_stdin_chunk(&mut armed, &buf[..n]);
            if !parsed.forward.is_empty() && tx.send(StdinMsg::Bytes(parsed.forward)).is_err() {
                break;
            }
            if parsed.detach {
                let _ = tx.send(StdinMsg::Detach);
                return;
            }
        }
    });
}

/// Attach a local terminal to the broker-held session for `endpoint_id` under
/// `intent` (REQ-RCVIEW-1 / REQ-KICK-1): `Control` drives a free endpoint (and is
/// busy-refused with guidance on a controlled one), `Viewer` watches read-only,
/// `Take` kicks the incumbent controller (loud `Displaced` notice to them) and
/// takes control. Returns when the session exits, the operator detaches, or an
/// error occurs.
// [impl->REQ-RC-1] [impl->REQ-RCVIEW-1] [impl->REQ-KICK-1]
pub fn run_attach(endpoint_id: &str, intent: AttachIntent) -> Result<(), String> {
    let view = intent == AttachIntent::Viewer;
    // Busy-refuse rendered CLIENT-SIDE (REQ-RCVIEW-1): a plain `Control` attach to
    // an endpoint a DIFFERENT node already controls gets actionable guidance here
    // (the broker would also refuse on the wire — this is the friendly path). A
    // `--view` / `--take` bypasses it (watching coexists; taking displaces).
    if intent == AttachIntent::Control {
        if let Some(node) = current_driver(endpoint_id) {
            println!("'{endpoint_id}' is controlled by {node}.");
            println!("  spt rc {endpoint_id} --view   to watch read-only");
            println!("  spt rc {endpoint_id} --take   to take control (kicks {node})");
            return Ok(());
        }
    }
    // (a) STATUS-GATE (REQ-HAZARD-RC-ATTACH-FAILFAST): a perch with an EXPLICIT
    // `status=offline` is not attachable — short-circuit with clean guidance
    // [impl->REQ-HAZARD-RC-ATTACH-FAILFAST]
    // instead of the broker dance that would block on a dead session (the
    // infinite-blank hang). The B2 pull-reconcile / `endpoint stop` stamp a dead
    // spt-hosted perch offline, so this catches the common case before any IPC. An
    // ABSENT or status-less perch falls through to the broker path, which already
    // ends cleanly ("no live session for endpoint") — not a hang. (is_perch_alive
    // is NOT used here: it returns true for an absent info.json by interim parity.)
    let perch_path =
        spt_store::perch::resolve_perch_path(endpoint_id, spt_store::perch::ParentHint::Infer);
    if spt_store::info::read_info(&perch_path)
        .and_then(|i| i.status)
        .as_deref()


W7 hexdump — add env-gated `SPT_RC_DEBUG_KEYS` dump of raw stdin bytes in `spawn_stdin_reader`.


Edit

            let n = match stdin.read(&mut buf) {
                Ok(0) => break, // stdin closed
                Ok(n) => n,
                Err(_) => break,
            };
            let parsed = parse_stdin_chunk(&mut armed, &buf[..n]);

            let n = match stdin.read(&mut buf) {
                Ok(0) => break, // stdin closed
                Ok(n) => n,
                Err(_) => break,
            };
            // W7 (REQ-HAZARD-RC-INPUT-KEY-ENCODING) confirm-first hexdump: with
            // SPT_RC_DEBUG_KEYS set, print the RAW bytes the console delivered for
            // each keystroke. Press Backspace then ctrl+Backspace in a real rc
            // session — a Backspace that arrives as 0x08 (^H) instead of 0x7f
            // (VT DEL) confirms the legacy-console root (ENABLE_VIRTUAL_TERMINAL_INPUT
            // off). Gate the root before the fix.
            if std::env::var_os("SPT_RC_DEBUG_KEYS").is_some() {
                let hex: Vec<String> = buf[..n].iter().map(|b| format!("{b:02x}")).collect();
                eprintln!("RC_KEYS n={n} bytes=[{}]", hex.join(" "));
            }
            let parsed = parse_stdin_chunk(&mut armed, &buf[..n]);


The file C:\Users\decid\Documents\projects\spt-core\crates\spt\src\rc.rs has been updated successfully. (file state is current in your context — no need to Read it back)

            let n = match stdin.read(&mut buf) {
                Ok(0) => break, // stdin closed
                Ok(n) => n,
                Err(_) => break,
            };
            let parsed = parse_stdin_chunk(&mut armed, &buf[..n]);

            let n = match stdin.read(&mut buf) {
                Ok(0) => break, // stdin closed
                Ok(n) => n,
                Err(_) => break,
            };
            // W7 (REQ-HAZARD-RC-INPUT-KEY-ENCODING) confirm-first hexdump: with
            // SPT_RC_DEBUG_KEYS set, print the RAW bytes the console delivered for
            // each keystroke. Press Backspace then ctrl+Backspace in a real rc
            // session — a Backspace that arrives as 0x08 (^H) instead of 0x7f
            // (VT DEL) confirms the legacy-console root (ENABLE_VIRTUAL_TERMINAL_INPUT
            // off). Gate the root before the fix.
            if std::env::var_os("SPT_RC_DEBUG_KEYS").is_some() {
                let hex: Vec<String> = buf[..n].iter().map(|b| format!("{b:02x}")).collect();
                eprintln!("RC_KEYS n={n} bytes=[{}]", hex.join(" "));
            }
            let parsed = parse_stdin_chunk(&mut armed, &buf[..n]);

+            // W7 (REQ-HAZARD-RC-INPUT-KEY-ENCODING) confirm-first hexdump: with

+            // SPT_RC_DEBUG_KEYS set, print the RAW bytes the console delivered for

+            // each keystroke. Press Backspace then ctrl+Backspace in a real rc

+            // session — a Backspace that arrives as 0x08 (^H) instead of 0x7f

+            // (VT DEL) confirms the legacy-console root (ENABLE_VIRTUAL_TERMINAL_INPUT

+            // off). Gate the root before the fix.

+            if std::env::var_os("SPT_RC_DEBUG_KEYS").is_some() {

+                let hex: Vec<String> = buf[..n].iter().map(|b| format!("{b:02x}")).collect();

+                eprintln!("RC_KEYS n={n} bytes=[{}]", hex.join(" "));

if !parsed.forward.is_empty() && tx.send(StdinMsg::Bytes(parsed.forward)).is_err() {


<task-notification>
<task-id>b930xhhyb</task-id>
<tool-use-id>toolu_0137SCiMP9x2ppJnbVWnoxYP</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\b930xhhyb.output</output-file>
<status>completed</status>
<summary>Background command "Build + run W1 controller-backpressure wedge repro" completed (exit code 0)</summary>
</task-notification>


Check repro build/run progress.


1	   Compiling spt-term v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
2	   Compiling spt-daemon v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
3	    Finished `test` profile [unoptimized + debuginfo] target(s) in 17.27s
4	     Running tests\inject_control_wedge.rs (target\debug\deps\inject_control_wedge-237ab1e3f78e76cc.exe)
5
6	running 1 test
7	test controller_backpressure_parks_the_drain_thread_and_wedges_the_session ... APPEND_CTRL_ENTER sid=1 seq=0
8	APPEND_CTRL_EXIT sid=1 seq=0 ok=true
9	APPEND_CTRL_ENTER sid=1 seq=1
10	APPEND_CTRL_EXIT sid=1 seq=1 ok=true
11	APPEND_CTRL_ENTER sid=1 seq=2
12	=== W1 DIAGNOSTIC: broker_alive(net_status)=true sessions_answered=false (expect alive=true, sessions=false = WEDGE) ===
13	APPEND_CTRL_EXIT sid=1 seq=2 ok=false
14	ok
15
16	test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.19s
17
18

   Compiling spt-term v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
   Compiling spt-daemon v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 17.27s
     Running tests\inject_control_wedge.rs (target\debug\deps\inject_control_wedge-237ab1e3f78e76cc.exe)

running 1 test
test controller_backpressure_parks_the_drain_thread_and_wedges_the_session ... APPEND_CTRL_ENTER sid=1 seq=0
APPEND_CTRL_EXIT sid=1 seq=0 ok=true
APPEND_CTRL_ENTER sid=1 seq=1
APPEND_CTRL_EXIT sid=1 seq=1 ok=true
APPEND_CTRL_ENTER sid=1 seq=2
=== W1 DIAGNOSTIC: broker_alive(net_status)=true sessions_answered=false (expect alive=true, sessions=false = WEDGE) ===
APPEND_CTRL_EXIT sid=1 seq=2 ok=false
ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.19s


Prompt:
<task-notification>
<task-id>b930xhhyb</task-id>
<tool-use-id>toolu_0137SCiMP9x2ppJnbVWnoxYP</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\b930xhhyb.output</output-file>
<status>completed</status>
<summary>Background command "Build + run W1 controller-backpressure wedge repro" completed (exit code 0)</summary>
</task-notification>


🎯 **Park (a) CONFIRMED** on a real broker + real PTY. The instrumentation is the smoking gun:

```
APPEND_CTRL_ENTER sid=1 seq=0
APPEND_CTRL_EXIT  sid=1 seq=0 ok=true     ← delivers
APPEND_CTRL_ENTER sid=1 seq=1
APPEND_CTRL_EXIT  sid=1 seq=1 ok=true     ← delivers
APPEND_CTRL_ENTER sid=1 seq=2             ← PARKS here (no EXIT)
=== broker_alive(net_status)=true  sessions_answered=false = WEDGE ===
APPEND_CTRL_EXIT  sid=1 seq=2 ok=false    ← only unblocks after child reaped
```

"One chunk succeeds, the next wedges" — exactly doyle's signal. The drain parked in `append`'s controller `write_frame` (broker.rs:223) holding `Mutex<OutputLog>`; `KIND_SESSIONS` hung on that held lock while the process stayed alive.

Now confirm park (b) is real (injection-side), so the fix bounds it too. Add a second repro: controller drains normally (so (a) won't fire), inject a large payload into a stdin-ignoring child → `write_input`'s `write_all` parks the dispatch thread.


use spt_daemon::msg::{
    SpawnReq, Spawned, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY,
    KIND_SPAWN, KIND_SPAWNED,
};

use spt_daemon::msg::{
    encode_bytes, EndpointInputReq, SpawnReq, Spawned, KIND_ENDPOINT_INJECTED, KIND_ENDPOINT_INPUT,
    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
    KIND_SPAWNED,
};


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\inject_control_wedge.rs has been updated successfully. (file state is current in your context — no need to Read it back)

use spt_daemon::msg::{
    encode_bytes, EndpointInputReq, SpawnReq, Spawned, KIND_ENDPOINT_INJECTED, KIND_ENDPOINT_INPUT,
    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
    KIND_SPAWNED,
};

//! v0.13.0 W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) — the CONTROLLER-DELIVERY park
//! repro, on a REAL broker hosting a REAL PTY child over the real local IPC (no
//! mocks of the PTY or the transport).
//!
//! ## What this proves (park (a), the output-stall / wedge root)
//!
//! [`OutputLog::append`] (broker.rs) fans each output chunk to the CONTROLLER via
//! a SYNCHRONOUS, BLOCKING `write_frame` held INLINE on the session's single
//! drain thread, while the `Mutex<OutputLog>` is locked. VIEWERS instead get a
//! dedicated writer thread + a bounded, evicting `sync_channel` and can never
//! stall the drain.
//!
//! So when the controller's socket backs up (a slow / non-draining operator —
//! here a controller connection that simply stops reading while the child
//! floods), the drain thread BLOCKS inside `append` STILL HOLDING the log lock.
//! That is the wedge: output stalls, and any path that needs that session's log
//! lock (a fresh attach, or even the global `KIND_SESSIONS` enumeration, which
//! reads each log's `delivered_through`) blocks forever — while the broker
//! PROCESS is otherwise alive (a fresh connection's `KIND_NET_STATUS`, which
//! touches neither the sessions nor the log lock, still answers).
//!
//! Run with `SPT_BROKER_DEBUG_PARK=1 cargo test -p spt-daemon --test
//! inject_control_wedge -- --nocapture` to see the park point on stderr: an
//! `APPEND_CTRL_ENTER sid=.. seq=N` with NO matching `APPEND_CTRL_EXIT` = the
//! drain thread parked on the controller `write_frame` at broker.rs:223.
//!
//! Pre-fix this test asserts the wedge (the bug). The W1 fix moves controller
//! delivery off the drain thread (dedicated writer, deadline-bounded) — at which
//! point this flips to "no wedge" and folds into the int gate.

use std::sync::Arc;
use std::thread;
use std::time::Duration;

use interprocess::local_socket::Stream;
use spt_daemon::codec::{read_frame, write_frame};
use spt_daemon::frame::{Envelope, Role};
use spt_daemon::msg::{
    SpawnReq, Spawned, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY,
    KIND_SPAWN, KIND_SPAWNED,
};
use spt_daemon::transport::{send_hello, LocalSocketTransport};
use spt_daemon::Broker;

use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-w1wedge-{}-{}.sock", std::process::id(), n)
}

fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = std::process::Command::new("taskkill")
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = std::process::Command::new("kill")
        .args(["-9", &pid.to_string()])
        .output();
}

/// A child that FLOODS stdout forever (never reads stdin) — the heavy-output
/// case (a real TUI redraw burst). Cross-OS, same shape as the broker B1 test's
/// `cat`/`findstr` echo child.
fn flood_spawn_req(endpoint: &str) -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("yes".to_string(), vec!["FLOODFLOODFLOODFLOOD".to_string()]);
    #[cfg(windows)]
    let (program, args) = (
        "cmd".to_string(),
        vec![
            "/c".to_string(),
            "for /l %x in (0,0,1) do @echo FLOODFLOODFLOODFLOODFLOODFLOODFLOOD".to_string(),
        ],
    );
    SpawnReq {
        program,
        args,
        rows: 24,
        cols: 80,
        endpoint: endpoint.to_string(),
        cwd: None,
        env: Default::default(),
    }
}

fn send(conn: &mut Stream, kind: &str, payload: serde_json::Value) {
    write_frame(conn, &Envelope::new(kind, payload)).expect("send frame");
}

/// Open a fresh brain connection, send one command, and return whether the
/// expected reply kind arrives within `deadline`. Runs the whole exchange on a
/// spawned thread so a BLOCKED `read_frame` (the wedge) surfaces as a timeout
/// instead of hanging the test. A `false` return = "did not answer in time".
fn probe(name: &str, cmd_kind: &'static str, want_kind: &'static str, deadline: Duration) -> bool {
    let name = name.to_string();
    let (tx, rx) = std::sync::mpsc::channel::<bool>();
    thread::spawn(move || {
        let mut conn = match LocalSocketTransport::connect(&name) {
            Ok(c) => c,
            Err(_) => {
                let _ = tx.send(false);
                return;
            }
        };
        if send_hello(&mut conn, Role::Brain).is_err() {
            let _ = tx.send(false);
            return;
        }
        send(&mut conn, cmd_kind, serde_json::json!({}));
        loop {
            match read_frame(&mut conn) {
                Ok(f) if f.kind == want_kind => {
                    let _ = tx.send(true);
                    return;
                }
                Ok(_) => continue, // ignore interleaved frames
                Err(_) => {
                    let _ = tx.send(false);
                    return;
                }
            }
        }
    });
    rx.recv_timeout(deadline).unwrap_or(false)
}

#[test]
fn controller_backpressure_parks_the_drain_thread_and_wedges_the_session() {
    // Make the park instrumentation visible when run with --nocapture (cached on
    // first append; this test is the first/only appender in this binary).
    std::env::set_var("SPT_BROKER_DEBUG_PARK", "1");

    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");
    let serve_broker = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve_broker.serve(); // accept every connection on its own thread
    });

    // ── Controller connection A: spawn the flooding child. dispatch_spawn
    //    pre-attaches A as the CONTROLLER (become_controller, by=None). ──
    let mut a = None;
    for _ in 0..200 {
        match LocalSocketTransport::connect(&name) {
            Ok(c) => {
                a = Some(c);
                break;
            }
            Err(_) => thread::sleep(Duration::from_millis(10)),
        }
    }
    let mut a = a.expect("connect controller A");
    send_hello(&mut a, Role::Brain).expect("hello A");
    send(
        &mut a,
        KIND_SPAWN,
        serde_json::to_value(flood_spawn_req("wedge-ep")).unwrap(),
    );
    let sid = loop {
        let f = read_frame(&mut a).expect("frame before spawned");
        if f.kind == KIND_SPAWNED {
            break serde_json::from_value::<Spawned>(f.payload).unwrap().session_id;
        }
    };

    // ── A now STOPS reading. The child floods; the broker's drain thread calls
    //    append → write_frame(controller=A); once A's socket recv buffer fills,
    //    that write blocks → the drain parks inside append HOLDING the log lock.
    //    Give the flood time to fill the buffer. ──
    thread::sleep(Duration::from_secs(4));

    // ── Differential proof. A fresh connection's KIND_NET_STATUS touches neither
    //    the sessions nor the log lock → the broker PROCESS is alive and answers
    //    fast. KIND_SESSIONS enumerates every session's log (`delivered_through`
    //    under the log lock) → it blocks on the parked session's held log lock →
    //    WEDGE. ──
    let broker_alive = probe(&name, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, Duration::from_secs(5));
    let sessions_answered =
        probe(&name, KIND_SESSIONS, KIND_SESSIONS_REPLY, Duration::from_secs(5));

    eprintln!(
        "=== W1 DIAGNOSTIC: broker_alive(net_status)={broker_alive} \
         sessions_answered={sessions_answered} (expect alive=true, sessions=false = WEDGE) ==="
    );

    // Reap the flood child (scoped) + tear the broker down.
    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }
    drop(a);

    assert!(
        broker_alive,
        "the broker PROCESS must still answer a fresh connection's net-status \
         (the wedge is per-session, not a dead process)"
    );
    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !sessions_answered,
        "REPRO: a backed-up controller parks the drain thread inside append() \
         HOLDING the log lock, so KIND_SESSIONS (which reads each log's \
         delivered_through) hangs — the session is wedged. \
         When this assertion FLIPS (sessions answered), the W1 fix has moved \
         controller delivery off the drain thread."
    );
}

-    SpawnReq, Spawned, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY,

+    encode_bytes, EndpointInputReq, SpawnReq, Spawned, KIND_ENDPOINT_INJECTED, KIND_ENDPOINT_INPUT,

+    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,


    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !sessions_answered,
        "REPRO: a backed-up controller parks the drain thread inside append() \
         HOLDING the log lock, so KIND_SESSIONS (which reads each log's \
         delivered_through) hangs — the session is wedged. \
         When this assertion FLIPS (sessions answered), the W1 fix has moved \
         controller delivery off the drain thread."
    );
}

    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !sessions_answered,
        "REPRO: a backed-up controller parks the drain thread inside append() \
         HOLDING the log lock, so KIND_SESSIONS (which reads each log's \
         delivered_through) hangs — the session is wedged. \
         When this assertion FLIPS (sessions answered), the W1 fix has moved \
         controller delivery off the drain thread."
    );
}

#[test]
fn injecting_into_a_stdin_ignoring_child_parks_the_dispatch_thread_on_write_all() {
    // Park (b): write_input's blocking write_all on a full PTY input buffer.
    // Run with SPT_TERM_DEBUG_PARK=1 --nocapture to see PTY_WRITE_ENTER /
    // PTY_WRITE_LOCKED with NO PTY_WRITE_EXIT = the dispatch thread parked in
    // write_all at pty.rs:238.
    std::env::set_var("SPT_TERM_DEBUG_PARK", "1");

    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");
    let serve_broker = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve_broker.serve();
    });

    // Controller A spawns the flood child (which IGNORES stdin) and then DRAINS
    // its frames forever on a background thread — so the output-side park (a)
    // does NOT fire and we isolate the input-side park (b).
    let mut a = None;
    for _ in 0..200 {
        match LocalSocketTransport::connect(&name) {
            Ok(c) => {
                a = Some(c);
                break;
            }
            Err(_) => thread::sleep(Duration::from_millis(10)),
        }
    }
    let mut a = a.expect("connect controller A");
    send_hello(&mut a, Role::Brain).expect("hello A");
    send(
        &mut a,
        KIND_SPAWN,
        serde_json::to_value(flood_spawn_req("inj-ep")).unwrap(),
    );
    let sid = loop {
        let f = read_frame(&mut a).expect("frame before spawned");
        if f.kind == KIND_SPAWNED {
            break serde_json::from_value::<Spawned>(f.payload).unwrap().session_id;
        }
    };
    // Keep the controller socket drained so append never parks.
    thread::spawn(move || while read_frame(&mut a).is_ok() {});

    // Inject a LARGE payload into the stdin-ignoring child via a SECOND brain
    // connection. dispatch_endpoint_input → write_input → write_all on the PTY
    // master; the child never reads stdin, so the input buffer fills and the
    // write parks — the KIND_ENDPOINT_INJECTED reply never comes.
    let big = vec![b'K'; 4 * 1024 * 1024]; // 4 MiB of keystrokes
    let name_inj = name.clone();
    let (tx, rx) = std::sync::mpsc::channel::<bool>();
    thread::spawn(move || {
        let mut b = match LocalSocketTransport::connect(&name_inj) {
            Ok(c) => c,
            Err(_) => {
                let _ = tx.send(false);
                return;
            }
        };
        if send_hello(&mut b, Role::Brain).is_err() {
            let _ = tx.send(false);
            return;
        }
        send(
            &mut b,
            KIND_ENDPOINT_INPUT,
            serde_json::to_value(EndpointInputReq {
                endpoint: "inj-ep".to_string(),
                data_b64: encode_bytes(&big),
            })
            .unwrap(),
        );
        loop {
            match read_frame(&mut b) {
                Ok(f) if f.kind == KIND_ENDPOINT_INJECTED => {
                    let _ = tx.send(true);
                    return;
                }
                Ok(_) => continue,
                Err(_) => {
                    let _ = tx.send(false);
                    return;
                }
            }
        }
    });
    let inject_acked = rx.recv_timeout(Duration::from_secs(8)).unwrap_or(false);

    // Broker process still alive on a fresh connection (the park is the dispatch
    // thread serving the inject, not the whole process).
    let broker_alive = probe(&name, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, Duration::from_secs(5));

    eprintln!(
        "=== W1 PARK-B DIAGNOSTIC: inject_acked={inject_acked} broker_alive={broker_alive} \
         (expect inject_acked=false = write_input parked on write_all) ==="
    );

    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }

    assert!(broker_alive, "broker process must stay alive (per-session park)");
    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !inject_acked,
        "REPRO (park b): injecting into a child that never reads stdin parks \
         write_input's write_all on the full PTY input buffer — the inject is \
         never acked. The W1 fix (bounded / fail-fast PTY write) flips this."
    );
}


    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !sessions_answered,
        "REPRO: a backed-up controller parks the drain thread inside append() \
         HOLDING the log lock, so KIND_SESSIONS (which reads each log's \
         delivered_through) hangs — the session is wedged. \
         When this assertion FLIPS (sessions answered), the W1 fix has moved \
         controller delivery off the drain thread."
    );
}

#[test]
fn injecting_into_a_stdin_ignoring_child_parks_the_dispatch_thread_on_write_all() {
    // Park (b): write_input's blocking write_all on a full PTY input buffer.
    // Run with SPT_TERM_DEBUG_PARK=1 --nocapture to see PTY_WRITE_ENTER /
    // PTY_WRITE_LOCKED with NO PTY_WRITE_EXIT = the dispatch thread parked in
    // write_all at pty.rs:238.
    std::env::set_var("SPT_TERM_DEBUG_PARK", "1");

    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");
    let serve_broker = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve_broker.serve();
    });

    // Controller A spawns the flood child (which IGNORES stdin) and then DRAINS
    // its frames forever on a background thread — so the output-side park (a)
    // does NOT fire and we isolate the input-side park (b).
    let mut a = None;
    for _ in 0..200 {
        match LocalSocketTransport::connect(&name) {
            Ok(c) => {
                a = Some(c);
                break;
            }
            Err(_) => thread::sleep(Duration::from_millis(10)),
        }
    }
    let mut a = a.expect("connect controller A");
    send_hello(&mut a, Role::Brain).expect("hello A");
    send(
        &mut a,
        KIND_SPAWN,
        serde_json::to_value(flood_spawn_req("inj-ep")).unwrap(),
    );
    let sid = loop {
        let f = read_frame(&mut a).expect("frame before spawned");
        if f.kind == KIND_SPAWNED {
            break serde_json::from_value::<Spawned>(f.payload).unwrap().session_id;
        }
    };
    // Keep the controller socket drained so append never parks.
    thread::spawn(move || while read_frame(&mut a).is_ok() {});

    // Inject a LARGE payload into the stdin-ignoring child via a SECOND brain
    // connection. dispatch_endpoint_input → write_input → write_all on the PTY
    // master; the child never reads stdin, so the input buffer fills and the
    // write parks — the KIND_ENDPOINT_INJECTED reply never comes.
    let big = vec![b'K'; 4 * 1024 * 1024]; // 4 MiB of keystrokes
    let name_inj = name.clone();
    let (tx, rx) = std::sync::mpsc::channel::<bool>();
    thread::spawn(move || {
        let mut b = match LocalSocketTransport::connect(&name_inj) {
            Ok(c) => c,
            Err(_) => {
                let _ = tx.send(false);
                return;
            }
        };
        if send_hello(&mut b, Role::Brain).is_err() {
            let _ = tx.send(false);
            return;
        }
        send(
            &mut b,
            KIND_ENDPOINT_INPUT,
            serde_json::to_value(EndpointInputReq {
                endpoint: "inj-ep".to_string(),
                data_b64: encode_bytes(&big),
            })
            .unwrap(),
        );
        loop {
            match read_frame(&mut b) {
                Ok(f) if f.kind == KIND_ENDPOINT_INJECTED => {
                    let _ = tx.send(true);
                    return;
                }
                Ok(_) => continue,
                Err(_) => {
                    let _ = tx.send(false);
                    return;
                }
            }
        }
    });
    let inject_acked = rx.recv_timeout(Duration::from_secs(8)).unwrap_or(false);

    // Broker process still alive on a fresh connection (the park is the dispatch
    // thread serving the inject, not the whole process).
    let broker_alive = probe(&name, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, Duration::from_secs(5));

    eprintln!(
        "=== W1 PARK-B DIAGNOSTIC: inject_acked={inject_acked} broker_alive={broker_alive} \
         (expect inject_acked=false = write_input parked on write_all) ==="
    );

    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }

    assert!(broker_alive, "broker process must stay alive (per-session park)");
    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !inject_acked,
        "REPRO (park b): injecting into a child that never reads stdin parks \
         write_input's write_all on the full PTY input buffer — the inject is \
         never acked. The W1 fix (bounded / fail-fast PTY write) flips this."
    );
}

//! v0.13.0 W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) — the CONTROLLER-DELIVERY park
//! repro, on a REAL broker hosting a REAL PTY child over the real local IPC (no
//! mocks of the PTY or the transport).
//!
//! ## What this proves (park (a), the output-stall / wedge root)
//!
//! [`OutputLog::append`] (broker.rs) fans each output chunk to the CONTROLLER via
//! a SYNCHRONOUS, BLOCKING `write_frame` held INLINE on the session's single
//! drain thread, while the `Mutex<OutputLog>` is locked. VIEWERS instead get a
//! dedicated writer thread + a bounded, evicting `sync_channel` and can never
//! stall the drain.
//!
//! So when the controller's socket backs up (a slow / non-draining operator —
//! here a controller connection that simply stops reading while the child
//! floods), the drain thread BLOCKS inside `append` STILL HOLDING the log lock.
//! That is the wedge: output stalls, and any path that needs that session's log
//! lock (a fresh attach, or even the global `KIND_SESSIONS` enumeration, which
//! reads each log's `delivered_through`) blocks forever — while the broker
//! PROCESS is otherwise alive (a fresh connection's `KIND_NET_STATUS`, which
//! touches neither the sessions nor the log lock, still answers).
//!
//! Run with `SPT_BROKER_DEBUG_PARK=1 cargo test -p spt-daemon --test
//! inject_control_wedge -- --nocapture` to see the park point on stderr: an
//! `APPEND_CTRL_ENTER sid=.. seq=N` with NO matching `APPEND_CTRL_EXIT` = the
//! drain thread parked on the controller `write_frame` at broker.rs:223.
//!
//! Pre-fix this test asserts the wedge (the bug). The W1 fix moves controller
//! delivery off the drain thread (dedicated writer, deadline-bounded) — at which
//! point this flips to "no wedge" and folds into the int gate.

use std::sync::Arc;
use std::thread;
use std::time::Duration;

use interprocess::local_socket::Stream;
use spt_daemon::codec::{read_frame, write_frame};
use spt_daemon::frame::{Envelope, Role};
use spt_daemon::msg::{
    encode_bytes, EndpointInputReq, SpawnReq, Spawned, KIND_ENDPOINT_INJECTED, KIND_ENDPOINT_INPUT,
    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
    KIND_SPAWNED,
};
use spt_daemon::transport::{send_hello, LocalSocketTransport};
use spt_daemon::Broker;

use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-w1wedge-{}-{}.sock", std::process::id(), n)
}

fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = std::process::Command::new("taskkill")
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = std::process::Command::new("kill")
        .args(["-9", &pid.to_string()])
        .output();
}

/// A child that FLOODS stdout forever (never reads stdin) — the heavy-output
/// case (a real TUI redraw burst). Cross-OS, same shape as the broker B1 test's
/// `cat`/`findstr` echo child.
fn flood_spawn_req(endpoint: &str) -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("yes".to_string(), vec!["FLOODFLOODFLOODFLOOD".to_string()]);
    #[cfg(windows)]
    let (program, args) = (
        "cmd".to_string(),
        vec![
            "/c".to_string(),
            "for /l %x in (0,0,1) do @echo FLOODFLOODFLOODFLOODFLOODFLOODFLOOD".to_string(),
        ],
    );
    SpawnReq {
        program,
        args,
        rows: 24,
        cols: 80,
        endpoint: endpoint.to_string(),
        cwd: None,
        env: Default::default(),
    }
}

fn send(conn: &mut Stream, kind: &str, payload: serde_json::Value) {
    write_frame(conn, &Envelope::new(kind, payload)).expect("send frame");
}

/// Open a fresh brain connection, send one command, and return whether the
/// expected reply kind arrives within `deadline`. Runs the whole exchange on a
/// spawned thread so a BLOCKED `read_frame` (the wedge) surfaces as a timeout
/// instead of hanging the test. A `false` return = "did not answer in time".
fn probe(name: &str, cmd_kind: &'static str, want_kind: &'static str, deadline: Duration) -> bool {
    let name = name.to_string();
    let (tx, rx) = std::sync::mpsc::channel::<bool>();
    thread::spawn(move || {
        let mut conn = match LocalSocketTransport::connect(&name) {
            Ok(c) => c,
            Err(_) => {
                let _ = tx.send(false);
                return;
            }
        };
        if send_hello(&mut conn, Role::Brain).is_err() {
            let _ = tx.send(false);
            return;
        }
        send(&mut conn, cmd_kind, serde_json::json!({}));
        loop {
            match read_frame(&mut conn) {
                Ok(f) if f.kind == want_kind => {
                    let _ = tx.send(true);
                    return;
                }
                Ok(_) => continue, // ignore interleaved frames
                Err(_) => {
                    let _ = tx.send(false);
                    return;
                }
            }
        }
    });
    rx.recv_timeout(deadline).unwrap_or(false)
}

#[test]
fn controller_backpressure_parks_the_drain_thread_and_wedges_the_session() {
    // Make the park instrumentation visible when run with --nocapture (cached on
    // first append; this test is the first/only appender in this binary).
    std::env::set_var("SPT_BROKER_DEBUG_PARK", "1");

    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");
    let serve_broker = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve_broker.serve(); // accept every connection on its own thread
    });

    // ── Controller connection A: spawn the flooding child. dispatch_spawn
    //    pre-attaches A as the CONTROLLER (become_controller, by=None). ──
    let mut a = None;
    for _ in 0..200 {
        match LocalSocketTransport::connect(&name) {
            Ok(c) => {
                a = Some(c);
                break;
            }
            Err(_) => thread::sleep(Duration::from_millis(10)),
        }
    }
    let mut a = a.expect("connect controller A");
    send_hello(&mut a, Role::Brain).expect("hello A");
    send(
        &mut a,
        KIND_SPAWN,
        serde_json::to_value(flood_spawn_req("wedge-ep")).unwrap(),
    );
    let sid = loop {
        let f = read_frame(&mut a).expect("frame before spawned");
        if f.kind == KIND_SPAWNED {
            break serde_json::from_value::<Spawned>(f.payload).unwrap().session_id;
        }
    };

    // ── A now STOPS reading. The child floods; the broker's drain thread calls
    //    append → write_frame(controller=A); once A's socket recv buffer fills,
    //    that write blocks → the drain parks inside append HOLDING the log lock.
    //    Give the flood time to fill the buffer. ──
    thread::sleep(Duration::from_secs(4));

    // ── Differential proof. A fresh connection's KIND_NET_STATUS touches neither
    //    the sessions nor the log lock → the broker PROCESS is alive and answers
    //    fast. KIND_SESSIONS enumerates every session's log (`delivered_through`
    //    under the log lock) → it blocks on the parked session's held log lock →
    //    WEDGE. ──
    let broker_alive = probe(&name, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, Duration::from_secs(5));
    let sessions_answered =
        probe(&name, KIND_SESSIONS, KIND_SESSIONS_REPLY, Duration::from_secs(5));

    eprintln!(
        "=== W1 DIAGNOSTIC: broker_alive(net_status)={broker_alive} \
         sessions_answered={sessions_answered} (expect alive=true, sessions=false = WEDGE) ==="
    );

    // Reap the flood child (scoped) + tear the broker down.
    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }
    drop(a);

    assert!(
        broker_alive,
        "the broker PROCESS must still answer a fresh connection's net-status \
         (the wedge is per-session, not a dead process)"
    );
    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !sessions_answered,
        "REPRO: a backed-up controller parks the drain thread inside append() \
         HOLDING the log lock, so KIND_SESSIONS (which reads each log's \
         delivered_through) hangs — the session is wedged. \
         When this assertion FLIPS (sessions answered), the W1 fix has moved \
         controller delivery off the drain thread."
    );
}

+    // Park (b): write_input's blocking write_all on a full PTY input buffer.

+    // Run with SPT_TERM_DEBUG_PARK=1 --nocapture to see PTY_WRITE_ENTER /

+    // PTY_WRITE_LOCKED with NO PTY_WRITE_EXIT = the dispatch thread parked in

+    std::env::set_var("SPT_TERM_DEBUG_PARK", "1");

+    let broker = Broker::bind(&name).expect("bind broker");

+    let serve_broker = Arc::clone(&broker);

+    // Controller A spawns the flood child (which IGNORES stdin) and then DRAINS

+    // its frames forever on a background thread — so the output-side park (a)

+    // does NOT fire and we isolate the input-side park (b).

+        match LocalSocketTransport::connect(&name) {

+            Err(_) => thread::sleep(Duration::from_millis(10)),

+    let mut a = a.expect("connect controller A");

+    send_hello(&mut a, Role::Brain).expect("hello A");

+        serde_json::to_value(flood_spawn_req("inj-ep")).unwrap(),

+        let f = read_frame(&mut a).expect("frame before spawned");

+            break serde_json::from_value::<Spawned>(f.payload).unwrap().session_id;

+    // Keep the controller socket drained so append never parks.

+    thread::spawn(move || while read_frame(&mut a).is_ok() {});

+    // Inject a LARGE payload into the stdin-ignoring child via a SECOND brain

+    // connection. dispatch_endpoint_input → write_input → write_all on the PTY

+    // master; the child never reads stdin, so the input buffer fills and the

+    // write parks — the KIND_ENDPOINT_INJECTED reply never comes.

+    let big = vec![b'K'; 4 * 1024 * 1024]; // 4 MiB of keystrokes

+    let (tx, rx) = std::sync::mpsc::channel::<bool>();

+        let mut b = match LocalSocketTransport::connect(&name_inj) {

+                let _ = tx.send(false);

+        if send_hello(&mut b, Role::Brain).is_err() {

+            serde_json::to_value(EndpointInputReq {

+                endpoint: "inj-ep".to_string(),

+                data_b64: encode_bytes(&big),

+                Ok(f) if f.kind == KIND_ENDPOINT_INJECTED => {

+                    let _ = tx.send(true);

+                    let _ = tx.send(false);

+    let inject_acked = rx.recv_timeout(Duration::from_secs(8)).unwrap_or(false);

+    // Broker process still alive on a fresh connection (the park is the dispatch

+    // thread serving the inject, not the whole process).

+    let broker_alive = probe(&name, KIND_NET_STATUS, KIND_NET_STATUS_REPLY, Duration::from_secs(5));

+        "=== W1 PARK-B DIAGNOSTIC: inject_acked={inject_acked} broker_alive={broker_alive} \

+         (expect inject_acked=false = write_input parked on write_all) ==="

+    if let Some(pid) = broker.session_pid(sid) {

+    assert!(broker_alive, "broker process must stay alive (per-session park)");

+    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]

+        "REPRO (park b): injecting into a child that never reads stdin parks \

+         write_input's write_all on the full PTY input buffer — the inject is \

+         never acked. The W1 fix (bounded / fail-fast PTY write) flips this."


Bash

SPT_TERM_DEBUG_PARK=1 SPT_BROKER_DEBUG_PARK=1 cargo test -p spt-daemon --test inject_control_wedge injecting_into -- --nocapture --test-threads=1 2>&1 | tail -40

Run park (b) injection repro


Command running in background with ID: bvl21dyky. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\bvl21dyky.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>bvl21dyky</task-id>
<tool-use-id>toolu_019Ymki4awSEEX4WJqiJXhFs</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\bvl21dyky.output</output-file>
<status>completed</status>
<summary>Background command "Run park (b) injection repro" completed (exit code 0)</summary>
</task-notification>


Park (b) running. Let me check the result.


1	APPEND_CTRL_ENTER sid=1 seq=6888
2	APPEND_CTRL_EXIT sid=1 seq=6888 ok=true
3	APPEND_CTRL_ENTER sid=1 seq=6889
4	APPEND_CTRL_EXIT sid=1 seq=6889 ok=true
5	APPEND_CTRL_ENTER sid=1 seq=6890
6	APPEND_CTRL_EXIT sid=1 seq=6890 ok=true
7	APPEND_CTRL_ENTER sid=1 seq=6891
8	APPEND_CTRL_EXIT sid=1 seq=6891 ok=true
9	APPEND_CTRL_ENTER sid=1 seq=6892
10	APPEND_CTRL_EXIT sid=1 seq=6892 ok=true
11	APPEND_CTRL_ENTER sid=1 seq=6893
12	APPEND_CTRL_EXIT sid=1 seq=6893 ok=true
13	APPEND_CTRL_ENTER sid=1 seq=6894
14	APPEND_CTRL_EXIT sid=1 seq=6894 ok=true
15	APPEND_CTRL_ENTER sid=1 seq=6895
16	APPEND_CTRL_EXIT sid=1 seq=6895 ok=true
17	APPEND_CTRL_ENTER sid=1 seq=6896
18	APPEND_CTRL_EXIT sid=1 seq=6896 ok=true
19	APPEND_CTRL_ENTER sid=1 seq=6897
20	APPEND_CTRL_EXIT sid=1 seq=6897 ok=true
21	APPEND_CTRL_ENTER sid=1 seq=6898
22	APPEND_CTRL_EXIT sid=1 seq=6898 ok=true
23	APPEND_CTRL_ENTER sid=1 seq=6899
24	APPEND_CTRL_EXIT sid=1 seq=6899 ok=true
25	APPEND_CTRL_ENTER sid=1 seq=6900
26	APPEND_CTRL_EXIT sid=1 seq=6900 ok=true
27
28	thread 'injecting_into_a_stdin_ignoring_child_parks_the_dispatch_thread_on_write_all' (169900) panicked at crates\spt-daemon\tests\inject_control_wedge.rs:314:5:
29	REPRO (park b): injecting into a child that never reads stdin parks write_input's write_all on the full PTY input buffer — the inject is never acked. The W1 fix (bounded / fail-fast PTY write) flips this.
30	note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
31	FAILED
32
33	failures:
34
35	failures:
36	    injecting_into_a_stdin_ignoring_child_parks_the_dispatch_thread_on_write_all
37
38	test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 1 filtered out; finished in 5.45s
39
40	error: test failed, to rerun pass `-p spt-daemon --test inject_control_wedge`
41

APPEND_CTRL_ENTER sid=1 seq=6888
APPEND_CTRL_EXIT sid=1 seq=6888 ok=true
APPEND_CTRL_ENTER sid=1 seq=6889
APPEND_CTRL_EXIT sid=1 seq=6889 ok=true
APPEND_CTRL_ENTER sid=1 seq=6890
APPEND_CTRL_EXIT sid=1 seq=6890 ok=true
APPEND_CTRL_ENTER sid=1 seq=6891
APPEND_CTRL_EXIT sid=1 seq=6891 ok=true
APPEND_CTRL_ENTER sid=1 seq=6892
APPEND_CTRL_EXIT sid=1 seq=6892 ok=true
APPEND_CTRL_ENTER sid=1 seq=6893
APPEND_CTRL_EXIT sid=1 seq=6893 ok=true
APPEND_CTRL_ENTER sid=1 seq=6894
APPEND_CTRL_EXIT sid=1 seq=6894 ok=true
APPEND_CTRL_ENTER sid=1 seq=6895
APPEND_CTRL_EXIT sid=1 seq=6895 ok=true
APPEND_CTRL_ENTER sid=1 seq=6896
APPEND_CTRL_EXIT sid=1 seq=6896 ok=true
APPEND_CTRL_ENTER sid=1 seq=6897
APPEND_CTRL_EXIT sid=1 seq=6897 ok=true
APPEND_CTRL_ENTER sid=1 seq=6898
APPEND_CTRL_EXIT sid=1 seq=6898 ok=true
APPEND_CTRL_ENTER sid=1 seq=6899
APPEND_CTRL_EXIT sid=1 seq=6899 ok=true
APPEND_CTRL_ENTER sid=1 seq=6900
APPEND_CTRL_EXIT sid=1 seq=6900 ok=true

thread 'injecting_into_a_stdin_ignoring_child_parks_the_dispatch_thread_on_write_all' (169900) panicked at crates\spt-daemon\tests\inject_control_wedge.rs:314:5:
REPRO (park b): injecting into a child that never reads stdin parks write_input's write_all on the full PTY input buffer — the inject is never acked. The W1 fix (bounded / fail-fast PTY write) flips this.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
FAILED

failures:

failures:
    injecting_into_a_stdin_ignoring_child_parks_the_dispatch_thread_on_write_all

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 1 filtered out; finished in 5.45s

error: test failed, to rerun pass `-p spt-daemon --test inject_control_wedge`


Prompt:
<task-notification>
<task-id>bvl21dyky</task-id>
<tool-use-id>toolu_019Ymki4awSEEX4WJqiJXhFs</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\bvl21dyky.output</output-file>
<status>completed</status>
<summary>Background command "Run park (b) injection repro" completed (exit code 0)</summary>
</task-notification>


Crucial finding: **park (b) did NOT reproduce on Windows ConPTY** — a 4 MiB inject into a stdin-ignoring child was *absorbed and acked* (`inject_acked=true`), the controller drained fine the whole time. So on the operator's platform, the input-side `write_all` does **not** park; ConPTY swallows the input. That means (b)/(c) are **not** the Windows operator wedge — (a) is. (b)/(c) are likely a Unix-forkpty-only hazard (small input queue). doyle's "don't fix a benign one" — this is the catch.

Make the (b) test honest per-platform (Windows = absorbed/benign; Unix = expected park, pending Linux CI confirm) so it's not a red test.


Edit

    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }

    assert!(broker_alive, "broker process must stay alive (per-session park)");
    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !inject_acked,
        "REPRO (park b): injecting into a child that never reads stdin parks \
         write_input's write_all on the full PTY input buffer — the inject is \
         never acked. The W1 fix (bounded / fail-fast PTY write) flips this."
    );
}

    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }

    assert!(broker_alive, "broker process must stay alive (per-session park)");

    // PLATFORM-DEPENDENT FINDING (W1 repro, 2026-06-19):
    // - Windows ConPTY ABSORBS a large inject into a stdin-ignoring child without
    //   blocking write_all — `inject_acked` is true, park (b) does NOT fire. So on
    //   the operator's platform the input-side write is BENIGN; the operator wedge
    //   is park (a) (controller output delivery), reached on injection via the
    //   injection-INDUCED output backing up the controller — not the inject write.
    // - Unix forkpty has a small kernel input queue, so the same inject is
    //   EXPECTED to park write_all (park b real there). Confirmed by CI on Linux.
    // The W1 fix still bounds the PTY write defensively (Unix + W2's atomic-write
    // coordination), but this test records WHICH park is the Windows root.
    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    #[cfg(windows)]
    assert!(
        inject_acked,
        "Windows ConPTY is expected to ABSORB the inject (park b benign here); \
         if this flips, the input buffer began blocking — re-examine park (b)."
    );
    #[cfg(unix)]
    assert!(
        !inject_acked,
        "REPRO (park b, Unix forkpty): injecting into a child that never reads \
         stdin parks write_input's write_all on the full kernel input queue — the \
         inject is never acked. The W1 fix (bounded / fail-fast PTY write) flips this."
    );
}


    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }

    assert!(broker_alive, "broker process must stay alive (per-session park)");
    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    assert!(
        !inject_acked,
        "REPRO (park b): injecting into a child that never reads stdin parks \
         write_input's write_all on the full PTY input buffer — the inject is \
         never acked. The W1 fix (bounded / fail-fast PTY write) flips this."
    );
}

    if let Some(pid) = broker.session_pid(sid) {
        kill_pid(pid);
    }

    assert!(broker_alive, "broker process must stay alive (per-session park)");

    // PLATFORM-DEPENDENT FINDING (W1 repro, 2026-06-19):
    // - Windows ConPTY ABSORBS a large inject into a stdin-ignoring child without
    //   blocking write_all — `inject_acked` is true, park (b) does NOT fire. So on
    //   the operator's platform the input-side write is BENIGN; the operator wedge
    //   is park (a) (controller output delivery), reached on injection via the
    //   injection-INDUCED output backing up the controller — not the inject write.
    // - Unix forkpty has a small kernel input queue, so the same inject is
    //   EXPECTED to park write_all (park b real there). Confirmed by CI on Linux.
    // The W1 fix still bounds the PTY write defensively (Unix + W2's atomic-write
    // coordination), but this test records WHICH park is the Windows root.
    // [int->REQ-HAZARD-INJECT-CONTROL-COEXIST]
    #[cfg(windows)]
    assert!(
        inject_acked,
        "Windows ConPTY is expected to ABSORB the inject (park b benign here); \
         if this flips, the input buffer began blocking — re-examine park (b)."
    );
    #[cfg(unix)]
    assert!(
        !inject_acked,
        "REPRO (park b, Unix forkpty): injecting into a child that never reads \
         stdin parks write_input's write_all on the full kernel input queue — the \
         inject is never acked. The W1 fix (bounded / fail-fast PTY write) flips this."
    );
}

+    // PLATFORM-DEPENDENT FINDING (W1 repro, 2026-06-19):

+    // - Windows ConPTY ABSORBS a large inject into a stdin-ignoring child without

+    //   blocking write_all — `inject_acked` is true, park (b) does NOT fire. So on

+    //   the operator's platform the input-side write is BENIGN; the operator wedge

+    //   is park (a) (controller output delivery), reached on injection via the

+    //   injection-INDUCED output backing up the controller — not the inject write.

+    // - Unix forkpty has a small kernel input queue, so the same inject is

+    //   EXPECTED to park write_all (park b real there). Confirmed by CI on Linux.

+    // The W1 fix still bounds the PTY write defensively (Unix + W2's atomic-write

+    // coordination), but this test records WHICH park is the Windows root.

+        "Windows ConPTY is expected to ABSORB the inject (park b benign here); \

+         if this flips, the input buffer began blocking — re-examine park (b)."

-        "REPRO (park b): injecting into a child that never reads stdin parks \

-         write_input's write_all on the full PTY input buffer — the inject is \

-         never acked. The W1 fix (bounded / fail-fast PTY write) flips this."

+        "REPRO (park b, Unix forkpty): injecting into a child that never reads \

+         stdin parks write_input's write_all on the full kernel input queue — the \

+         inject is never acked. The W1 fix (bounded / fail-fast PTY write) flips this."


Verify both tests green on Windows now.


Bash

cargo test -p spt-daemon --test inject_control_wedge -- --test-threads=1 2>&1 | tail -25

Run full W1 repro test file


Command running in background with ID: bslm3wgt3. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\bslm3wgt3.output. You will be notified when it completes. To check interim output, use Read on that file path.


Test running. Kick off the `spt` binary build in parallel (operator needs it for the W7 key capture), then I'll compose the doyle ping.


cargo build -p spt --bin spt 2>&1 | tail -8

Build spt binary with rc hexdump for operator W7 capture


Command running in background with ID: bk31ddeyk. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\bk31ddeyk.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>bslm3wgt3</task-id>
<tool-use-id>toolu_017NnCWheiCWtt2Skg1mnFbG</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\bslm3wgt3.output</output-file>
<status>completed</status>
<summary>Background command "Run full W1 repro test file" completed (exit code 0)</summary>
</task-notification>


1	   Compiling spt-daemon v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
2	    Finished `test` profile [unoptimized + debuginfo] target(s) in 3.70s
3	     Running tests\inject_control_wedge.rs (target\debug\deps\inject_control_wedge-237ab1e3f78e76cc.exe)
4
5	running 2 tests
6	test controller_backpressure_parks_the_drain_thread_and_wedges_the_session ... ok
7	test injecting_into_a_stdin_ignoring_child_parks_the_dispatch_thread_on_write_all ... ok
8
9	test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 14.60s
10
11

   Compiling spt-daemon v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 3.70s
     Running tests\inject_control_wedge.rs (target\debug\deps\inject_control_wedge-237ab1e3f78e76cc.exe)

running 2 tests
test controller_backpressure_parks_the_drain_thread_and_wedges_the_session ... ok
test injecting_into_a_stdin_ignoring_child_parks_the_dispatch_thread_on_write_all ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 14.60s


Prompt:
<task-notification>
<task-id>bslm3wgt3</task-id>
<tool-use-id>toolu_017NnCWheiCWtt2Skg1mnFbG</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\bslm3wgt3.output</output-file>
<status>completed</status>
<summary>Background command "Run full W1 repro test file" completed (exit code 0)</summary>
</task-notification>


<task-notification>
<task-id>bk31ddeyk</task-id>
<tool-use-id>toolu_014w1RA2xgvRiifzQox2rxVj</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\bk31ddeyk.output</output-file>
<status>completed</status>
<summary>Background command "Build spt binary with rc hexdump for operator W7 capture" completed (exit code 0)</summary>
</task-notification>


Both repro tests green. W1 root nailed. Ping doyle with the confirmed, labeled root + the Windows-negative on (b) + fix design.


$OWL send doyle <<'EOF'
W1 ROOT CONFIRMED on a REAL broker + REAL PTY child (in-proc Broker, real local IPC, no PTY/transport mocks). New test: crates/spt-daemon/tests/inject_control_wedge.rs (2 tests, both green). Labeled-park instrumentation added (env-gated, zero-cost off): SPT_BROKER_DEBUG_PARK (append) + SPT_TERM_DEBUG_PARK (pty/reader).

PARK (a) = THE ROOT (your code-trace, proven). Flood child + a controller conn that stops reading. Instrumentation captured the exact park:
  APPEND_CTRL_ENTER sid=1 seq=0 / EXIT ok=true
  APPEND_CTRL_ENTER sid=1 seq=1 / EXIT ok=true
  APPEND_CTRL_ENTER sid=1 seq=2        <-- PARKED, no EXIT
  broker_alive(net_status)=true  sessions_answered=false  = WEDGE
  APPEND_CTRL_EXIT sid=1 seq=2 ok=false  <-- only unblocks after the child is reaped (write errors out)
So: append's controller write_frame (broker.rs:223) blocks the drain thread INLINE while holding Mutex<OutputLog>. "One chunk delivers, the next wedges" = seq0/1 ok, seq2 parked. Differential proof: a fresh conn's KIND_NET_STATUS still answers (process alive) but KIND_SESSIONS hangs (it reads every log's delivered_through under the held log lock). This is the output-stall / echo-lag / unrecoverable wedge.

PARK (b) = BENIGN ON WINDOWS (important negative — your "don't fix a benign one"). Controller draining normally, inject 4 MiB into a stdin-ignoring child via KIND_ENDPOINT_INPUT: write_input's write_all did NOT park — ConPTY ABSORBED the inject and acked it (inject_acked=true). So the operator-platform wedge is NOT the input write. (b) is a Unix-forkpty hazard only (small kernel input queue) — the test asserts inject-absorbed on Windows, parks on Unix (to confirm on Linux CI).

PARK (c) follows (b): on Windows the DSR auto-answer never contends because write_input doesn't hold the writer parked. Unix-only, same as (b).

=> RECONCILED PICTURE: (a) is the single operator-visible root for BOTH triggers. Heavy output (TUI redraw) overruns the 64KB loopback duplex → (a). Injection wedges via the injection-INDUCED output burst overrunning the controller → still (a), not the inject write. (b)/(c) are real only on Unix forkpty; the fix should still bound them defensively (Linux + W2's atomic-write coordination), but they are NOT the Windows symptom.

FIX PLAN (your gate-approved direction, refined):
1. (a) move controller delivery off the drain thread onto a dedicated writer thread + bounded channel (the viewer_writer pattern) — BUT preserve the authoritative delivered_through cursor (the writer thread advances it on successful write, monotonic, never the unsent tail). Block the WRITER thread, never the drain. Bound the wedge: a deadline on the controller write → on expiry, detach/mark-gone (driven_by cleared, ONLINE+CONTROLLED unlatched) — NEVER park forever and NEVER silently evict the operator's authoritative view (unlike viewers which drop). Controller channel deeper than viewers' 256 + non-evicting (block the writer, deadline-bounded) so we don't drop authoritative output.
2. (b) bounded/fail-fast PTY write in write_input so it can't park forever on Unix (deadline or non-blocking + buffer) — also the substrate W2 reuses.
3. (c) the DSR auto-answer must not hold the SharedWriter mutex across a blocking write (lock → copy/queue → release, or bounded write) — the atomic-write coordination W2 needs; design once here.

Question before I patch: do you want me to (i) confirm (b)/(c) actually park on gravity-linux before touching the PTY write, or (ii) proceed with the (a) fix as the keystone + add the (b)/(c) defensive bounds (CI-gated on Linux) in the same wave? My lean = (ii): (a) is the confirmed root, ship it; bound (b)/(c) defensively with the Linux int as the gate. Holding for your gate on the root + the fix shape before any patch.

W7 part-2 byte: needs operator HITL (real Backspace in an rc session). spt rebuilt with the SPT_RC_DEBUG_KEYS hexdump; asking the operator to capture now — will relay the byte before the W7 patch (won't patch W7 until you have it).
EOF

Ping doyle: W1 root confirmed + (b) Windows-negative + fix plan


➜ SENT:doyle


Prompt:
<task-notification>
<task-id>bk31ddeyk</task-id>
<tool-use-id>toolu_014w1RA2xgvRiifzQox2rxVj</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\bk31ddeyk.output</output-file>
<status>completed</status>
<summary>Background command "Build spt binary with rc hexdump for operator W7 capture" completed (exit code 0)</summary>
</task-notification>


W1 root confirmed + pinged doyle. `spt` rebuilt with the hexdump. Now I need the operator for the W7 byte.

## Status

**W1 (keystone) — root CONFIRMED, repro-first, no patch yet (gating with doyle):**
- Park **(a)** = THE root. `append`'s controller `write_frame` (broker.rs:223) blocks the drain thread inline while holding `Mutex<OutputLog>`. Proven on a real broker+PTY — instrumentation caught `APPEND_CTRL_ENTER seq=2` with no `EXIT`, `KIND_SESSIONS` hung while the process stayed alive. New test `inject_control_wedge.rs` (2 tests, green).
- Park **(b)** = **benign on Windows** — ConPTY absorbed a 4 MiB inject without parking `write_input`. It's a Unix-forkpty-only hazard. So the operator wedge is (a), reached on injection via the injection-*induced output* overrunning the controller — not the inject write.
- Pinged doyle the labeled root + fix plan; holding for his gate before patching.

**W7 (sibling, independent) — needs your hands (HITL).** I added an env-gated hexdump to `spt rc`'s stdin reader and rebuilt the debug binary. To confirm doyle's byte-level root (Backspace arriving as `0x08`/^H instead of `0x7f`/VT DEL):

In a terminal, against any **live spt-hosted endpoint** you can `rc` into:
```
$env:SPT_RC_DEBUG_KEYS=1
C:\Users\decid\Documents\projects\spt-core\target\debug\spt.exe rc <id> 2> rckeys.log
```
Then press, in order: **Backspace** once, then **Ctrl+Backspace** once. Detach (Ctrl-b then your detach key), then send me `rckeys.log` (or paste the `RC_KEYS ...` lines).

Expected if doyle's right: plain Backspace → `RC_KEYS n=1 bytes=[08]`, Ctrl+Backspace → `[7f]` or `[17]`. That byte gates the W7 fix (enable `ENABLE_VIRTUAL_TERMINAL_INPUT` on Windows).

Want me to spin up a throwaway dummy spt-hosted endpoint for the capture, or do you have a live one handy?


<task-notification>
<task-id>bv8r78fk1</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/7" id="b3881a88" type="msg" from="doyle">GATE-PASS on the W1 root (a). I read inject_control_wedge.rs in the shared checkout + traced append under the held lock myself — not gating on the relay. Verdict:

(a) PROVEN non-vacuously on a REAL broker + REAL PTY, no mocks. The differential is the clincher: NET_STATUS answers (process alive) while KIND_SESSI</EVENT-PART>
<EVENT-PART seq="2/7" id="b3881a88">ONS hangs because append parks on the controller write_frame STILL HOLDING Mutex&lt;OutputLog&gt; → per-session lock-held wedge, not a dead process. &quot;seq0/1 ok, seq2 parks&quot; matches the operator's 'one chunk then wedge'. THE Windows operator-visible root.
(b)/(c) correctly scoped BENIGN on Windows (ConPTY absorbs, inject_acked=tr</EVENT-PART>
<EVENT-PART seq="3/7" id="b3881a88">ue), real only on Unix forkpty — the cfg-split asserts capture that cleanly. Good &quot;don't fix a benign one&quot; discipline.

PROCEED to the fix, gate-approved direction confirmed: (a) controller off the drain thread onto a dedicated writer + bounded channel (viewer_writer pattern) BUT preserve authoritative delivered_through (bloc</EVENT-PART>
<EVENT-PART seq="4/7" id="b3881a88">k the writer not the drain; deadline → detach/mark-gone; NEVER silent-evict the operator's view). Bound (b)/(c) defensively for Unix + reuse as W2's atomic-write coordination.

ONE GATE CONDITION on the committed int test: right now it asserts the WEDGE (the bug, pre-fix). The COMMITTED gate must assert the FIXED behavior — sessions_</EVENT-PART>
<EVENT-PART seq="5/7" id="b3881a88">answered=true / echo bounded / no wedge — with the pre-fix repro kept as a comment or a separate #[ignore]'d capture. Don't ship a gate that's green only because the bug still exists. You already flagged the flip (line 206-208) — just make sure the landed version asserts no-wedge.

TWO ASKS (relay truncated at part 8/13, owl ~9-part </EVENT-PART>
<EVENT-PART seq="6/7" id="b3881a88">cap):
1. Part-2 backspace BYTE — is it CAPTURED yet, or still HITL-pending (needs the operator to run SPT_RC_DEBUG_KEYS rc + press Backspace)? If pending, say so and I'll tee the operator up. Don't guess the byte.
2. Anything material in the truncated fix-plan tail (parts 9-13) beyond the above — send it SHORT or drop it in a file (.</EVENT-PART>
<EVENT-PART seq="7/7" id="b3881a88">claude/todlando-w1-plan.md) I can read. Keep relays under ~8 parts.</EVENT-PART></event>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


doyle GATE-PASSED root (a). Proceed to fix. Reply short, then build.


$OWL send doyle <<'EOF'
Gate-pass received. Proceeding to W1 fix.

ASK 1 — part-2 byte: HITL-PENDING, not captured, not guessing. I rebuilt the debug spt with the SPT_RC_DEBUG_KEYS hexdump and asked the operator to run `spt rc <id>` + press Backspace then ctrl+Backspace + send rckeys.log. Awaiting their capture. Tee them up if you like — no double-work risk, it's just a paste-back. I won't touch the W7 patch until the byte lands.

ASK 2 — nothing material beyond your summary. That WAS my full fix plan; owl split it, your parts 1-7 captured all of it. No tail.

W1 fix, landing shape (matches your gate):
- (a) controller delivery off the drain thread → dedicated writer thread + bounded channel (viewer_writer pattern), but authoritative: writer advances delivered_through on successful write (monotonic, never unsent tail); block the WRITER not the drain; deadline on the controller write → on expiry detach + mark-gone (clear driven_by, unlatch ONLINE+CONTROLLED); NEVER silent-evict the operator's view.
- (b)/(c) defensive bounds on the PTY write + DSR-answer-doesn't-hold-writer-across-blocking-write (Unix; = W2 atomic-write substrate).
- Committed int gate asserts FIXED behavior: sessions_answered=true, echo latency bounded, no wedge, new attach serves, daemon stop bounded. Pre-fix wedge repro kept as a separate #[ignore]'d capture (not the green gate). Will ping when the fix is up for your gate.
EOF

Reply doyle: proceeding to fix, byte pending


doyle gate-passed root (a). Now implement the fix. The core is (a): move controller delivery off the drain thread onto a dedicated writer thread + bounded channel (viewer_writer pattern), preserving the authoritative `delivered_through` cursor, with a deadline→mark-gone instead of permanent park.

Starting the OutputLog refactor in broker.rs. First imports + the `ControllerSink` + writer.


Edit

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{sync_channel, Receiver, SendTimeoutError, SyncSender, TrySendError};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;