14:- **broker** owns the *sole* PTY master reader+writer, the spawned child, the listening sockets, and every accepted client socket. Forwards PTY output to all sinks; routes brain `INPUT:` control lines to the PTY writer it owns. Never restarts.
19:Invariants: **A** child alive whole run (no PTY EOF) · **B** `tick N` counter contiguous · **C** input reaches child after brain restart · **D** client stream contiguous across restart.
24:child pid 33880 survived | pty ticks 0..39 contiguous, no EOF
29:The ownership model works: brain death is just a closed forwarding socket; the broker's held handles keep the child and the live client stream intact, and the broker-owned writer still delivers input after the brain is replaced. This is the *brain-only* update class — the one ADR-0004 must preserve, and the routine case.
31:## Key finding — ConPTY DSR stall (not in any doc before)
33:ConPTY emits a cursor-position query `ESC [ 6 n` at startup and **withholds all child stdout until the terminal answers it**. First runs captured exactly 4 bytes (`\e[6n`) and zero program output — the child looked dead but was ticking fine standalone. Replying `ESC [ 1;1 R` from the reader immediately unblocked full output (`bytes=4` → `bytes=93`, HELLO/DONE present). A `cmd.exe` probe reproduced it identically, proving it's a ConPTY plumbing requirement, not a workload bug. → new hazard `REQ-HAZARD-CONPTY-DSR`; every ConPTY reader must auto-answer DSR.
35:Secondary gotcha: a ConPTY master does **not** EOF while the broker holds the writer, so any read loop must drain on a thread and never gate exit on a blocking `read()` returning. (Cost a build cycle — three hung `spike.exe` instances locked the binary against relink.)
46:ADR-0004's broker/brain split is **not falsified** — the hardest OS path (ConPTY) passes, and the DSR stall that would have bitten the real terminal layer is now caught and documented. The split survives Stage A. Promote the four open gaps into the M3 daemon plan rather than re-opening the decision.


Bash

find . -path ./target -prune -o -iname "*.rs" -print 2>/dev/null | xargs grep -ln "6n\|1;1R\|\\\\x1b\[6n\|\\[6n" 2>/dev/null | head; echo "---spike code dir---"; find . -path ./target -prune -o -type d -iname "*spike*" -print 2>/dev/null; ls docs/spikes/

Find spike source code with DSR


---spike code dir---
./docs/spikes
SPIKE-01-broker-handoff.md
SPIKE-02-iroh-smoke.md
SPIKE-03-quic-survival.md
SPIKE-04-forkpty-parity.md
SPIKE-05-restart-stress.md
SPIKE-06-idempotent-boundary.md


Spike code wasn't retained — only the documented contract (`ESC[6n` → withhold; answer `ESC[1;1R`; no-EOF-while-writer-held → drain on thread, never gate exit on blocking read). Implement A2 fresh from that. Re-add the shared-writer accessor and write the drain pump.


Edit

    /// Poll for child exit without blocking.
    pub fn try_wait(&self) -> Result<Option<ExitStatus>, SurfaceError> {

    /// The shared writer handle — cloned to the A2 DSR drain thread so its
    /// auto-answer goes through the same serialized writer the caller injects
    /// input through (no torn writes).
    pub(crate) fn shared_writer(&self) -> SharedWriter {
        Arc::clone(&self.writer)
    }

    /// Poll for child exit without blocking.
    pub fn try_wait(&self) -> Result<Option<ExitStatus>, SurfaceError> {


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

    /// The shared writer handle — cloned to the A2 DSR drain thread so its
    /// auto-answer goes through the same serialized writer the caller injects
    /// input through (no torn writes).
    pub(crate) fn shared_writer(&self) -> SharedWriter {
        Arc::clone(&self.writer)
    }

    /// Poll for child exit without blocking.
    pub fn try_wait(&self) -> Result<Option<ExitStatus>, SurfaceError> {

//! Native-PTY session backend — ConPTY on Windows 10+, `forkpty(3)` on Unix,
//! both via `portable-pty` (the exact crate + handoff shape Spikes #1 / #4
//! proved).
//!
//! [`PtySession`] spawns a child under a real PTY, owns the master reader and
//! writer, and serves the [`SessionSurface`] contract. It is **single-surface
//! per handle** — one child's PTY, not the multiplexed fleet (that supervisor is
//! the M3b broker).
//!
//! ## Concurrency discipline (Spike #5, binding)
//!
//! `resize()` races the reader thread on Windows; the master is therefore held
//! behind a `Mutex` and every `resize` takes that lock. The writer is a shared
//! `Arc<Mutex<…>>` so the A2 DSR drain thread can answer ConPTY's startup
//! cursor query through the same serialized writer the caller injects input
//! through — no torn writes.

use std::io::{Read, Write};
use std::sync::{Arc, Mutex};

use portable_pty::{
    native_pty_system, Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty,
};

use crate::surface::{SessionSurface, SurfaceError, SurfaceSize};

/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// A child process running under a native PTY, exposed as a [`SessionSurface`].
///
/// Build one with [`PtySession::spawn`]; drive input via [`SessionSurface::write_input`]
/// (or the A3 `send_keys`/`send_line` helpers), drain output via
/// [`PtySession::take_reader`], and observe lifecycle via [`PtySession::try_wait`] /
/// [`PtySession::wait`] / [`PtySession::kill`].
pub struct PtySession {
    /// The master end, behind a lock so `resize` never races the reader (Spike #5).
    master: Mutex<Box<dyn MasterPty + Send>>,
    /// The single PTY writer, shared so the A2 drain thread can answer DSR.
    writer: SharedWriter,
    /// The spawned child; behind a lock for `wait`/`try_wait`.
    child: Mutex<Box<dyn Child + Send + Sync>>,
    /// A kill handle split from the child so we can terminate from another
    /// thread even while `wait` blocks (`portable-pty` `ChildKiller`).
    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
    /// The master reader, taken at most once (`take_reader`).
    reader: Mutex<Option<Box<dyn Read + Send>>>,
    /// The child OS pid, captured at spawn.
    pid: Option<u32>,
}

impl PtySession {
    /// Spawn `cmd` under a fresh native PTY sized to `size`.
    ///
    /// The slave end is dropped immediately after the child is spawned: the
    /// child holds the only remaining slave handle, which lets EOF propagate on
    /// the Unix raw-pipe backend once the child exits. (On Windows the no-EOF
    /// behaviour is governed by the writer, handled by the A2 drain discipline.)
    //
    // [impl->REQ-TERM-1]
    pub fn spawn(cmd: CommandBuilder, size: SurfaceSize) -> Result<Self, SurfaceError> {
        let pty_system = native_pty_system();
        let pair = pty_system
            .openpty(size.into())
            .map_err(|e| SurfaceError::OpenPty(e.to_string()))?;

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(|e| SurfaceError::Spawn(e.to_string()))?;
        // Drop the slave so the child owns the only slave-side handle.
        drop(pair.slave);

        let reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let writer = pair
            .master
            .take_writer()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let killer = child.clone_killer();
        let pid = child.process_id();

        Ok(Self {
            master: Mutex::new(pair.master),
            writer: Arc::new(Mutex::new(writer)),
            child: Mutex::new(child),
            killer: Mutex::new(killer),
            reader: Mutex::new(Some(reader)),
            pid,
        })
    }

    /// Convenience: spawn `program` with `args` under a PTY sized to `size`.
    pub fn spawn_program<P, A, S>(
        program: P,
        args: A,
        size: SurfaceSize,
    ) -> Result<Self, SurfaceError>
    where
        P: AsRef<std::ffi::OsStr>,
        A: IntoIterator<Item = S>,
        S: AsRef<std::ffi::OsStr>,
    {
        let mut cmd = CommandBuilder::new(program);
        for a in args {
            cmd.arg(a);
        }
        Self::spawn(cmd, size)
    }

    /// Take the master reader. Returns [`SurfaceError::ReaderTaken`] on the
    /// second call — output is drained by exactly one consumer (the A2 drain
    /// thread in the hosted path; a test consumer otherwise).
    pub fn take_reader(&self) -> Result<Box<dyn Read + Send>, SurfaceError> {
        self.reader
            .lock()
            .unwrap()
            .take()
            .ok_or(SurfaceError::ReaderTaken)
    }

    /// Poll for child exit without blocking.
    pub fn try_wait(&self) -> Result<Option<ExitStatus>, SurfaceError> {
        self.child.lock().unwrap().try_wait().map_err(SurfaceError::Io)
    }

    /// Block until the child exits, yielding its status.
    pub fn wait(&self) -> Result<ExitStatus, SurfaceError> {
        self.child.lock().unwrap().wait().map_err(SurfaceError::Io)
    }

    /// Terminate the child. Safe to call from another thread while `wait`
    /// blocks (uses the split `ChildKiller`). Confirm the reap via
    /// [`PtySession::wait`] / [`PtySession::try_wait`].
    pub fn kill(&self) -> Result<(), SurfaceError> {
        let res = self.killer.lock().unwrap().kill();
        #[cfg(windows)]
        {
            // portable-pty 0.8's Windows `ChildKiller` returns the spurious
            // `last_os_error` even when `TerminateProcess` *succeeds* (the
            // upstream success check is inverted; `WinChild::kill` swallows it,
            // the cloned killer does not). Its Result is therefore untrustworthy
            // — kill is best-effort; confirm death by reaping.
            let _ = res;
            Ok(())
        }
        #[cfg(unix)]
        {
            res.map_err(SurfaceError::Io)
        }
    }

    /// The child's OS process id, if the backend exposes one.
    pub fn process_id(&self) -> Option<u32> {
        self.pid
    }
}

// [impl->REQ-TERM-1]
impl SessionSurface for PtySession {
    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
        let mut w = self.writer.lock().unwrap();
        w.write_all(bytes).map_err(SurfaceError::Io)?;
        w.flush().map_err(SurfaceError::Io)
    }

    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError> {
        // Hold the master lock so resize never races a concurrent reader/resize
        // (Spike #5: resize races the reader on Windows).
        self.master
            .lock()
            .unwrap()
            .resize(size.into())
            .map_err(|e| SurfaceError::Io(io_other(e)))
    }
}

/// Fold a `portable-pty` error (a private `anyhow::Error` alias) into a
/// `std::io::Error` so the crate's public surface never leaks `anyhow`. Generic
/// over `Display` to avoid naming the un-exported error type.
fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::other(e.to_string())
}

+    /// The shared writer handle — cloned to the A2 DSR drain thread so its

+    /// auto-answer goes through the same serialized writer the caller injects

+    /// input through (no torn writes).

+    pub(crate) fn shared_writer(&self) -> SharedWriter {


Write

//! Output drain pump — the ConPTY-DSR auto-answer reader (M3a A2).
//!
//! ConPTY emits a cursor-position query `ESC [ 6 n` at startup and **withholds
//! all child stdout until the terminal answers it** (Spike #1: first runs
//! captured exactly the 4 bytes `\e[6n` and zero program output — the child
//! looked dead but was ticking fine). Replying `ESC [ 1;1 R` immediately
//! unblocks the full stream. A real terminal *is* expected to answer DSR, so the
//! pump answers every `ESC[6n` it sees on every platform (harmless correct CPR
//! on the Unix raw-pipe backend; load-bearing on ConPTY).
//!
//! **No-EOF-while-writer-held (Spike #1 secondary gotcha):** a ConPTY master
//! does *not* EOF while the writer is held, so the read loop runs on its own
//! thread and shutdown **never gates on a blocking `read()` returning**. The
//! broker stops a surface by terminating the child (which tears the PTY down and
//! unblocks the read); the [`Drain`] stop flag is a cooperative secondary signal
//! observed whenever a read does return. Deterministic, ordered teardown (drop
//! the writer, then the master) is the M3b broker's lifecycle responsibility.

use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;

use crate::pty::{PtySession, SharedWriter};
use crate::surface::SurfaceError;

/// The ConPTY startup cursor-position query (DSR — Device Status Report). All
/// child stdout is withheld until this is answered (REQ-HAZARD-CONPTY-DSR).
const DSR_QUERY: &[u8] = b"\x1b[6n";
/// The reply: a Cursor Position Report placing the cursor at row 1, col 1. Any
/// valid CPR unblocks ConPTY; `1;1` is the minimal answer Spike #1 proved.
const DSR_REPLY: &[u8] = b"\x1b[1;1R";

/// Streaming matcher for [`DSR_QUERY`] that survives chunk boundaries (the 4-byte
/// query can split across two `read()`s). `DSR_QUERY` has no proper prefix that
/// is also a suffix, so a single "bytes matched so far" counter is sufficient.
#[derive(Default)]
struct DsrMatcher {
    matched: usize,
}

impl DsrMatcher {
    /// Feed a chunk; return how many complete DSR queries it completed.
    fn scan(&mut self, chunk: &[u8]) -> u64 {
        let mut hits = 0;
        for &b in chunk {
            if b == DSR_QUERY[self.matched] {
                self.matched += 1;
                if self.matched == DSR_QUERY.len() {
                    hits += 1;
                    self.matched = 0;
                }
            } else {
                // Restart — but `b` may itself begin a fresh match (e.g. a lone ESC).
                self.matched = usize::from(b == DSR_QUERY[0]);
            }
        }
        hits
    }
}

/// A running drain pump over a surface's output. Holds live counters and a
/// cooperative stop flag; see the module docs for the teardown contract.
pub struct Drain {
    stop: Arc<AtomicBool>,
    dsr_answered: Arc<AtomicU64>,
    bytes: Arc<AtomicU64>,
    handle: Option<thread::JoinHandle<()>>,
}

impl Drain {
    /// Total DSR queries answered so far (live).
    pub fn dsr_answered(&self) -> u64 {
        self.dsr_answered.load(Ordering::Relaxed)
    }

    /// Total bytes forwarded to the sink so far (live).
    pub fn bytes_forwarded(&self) -> u64 {
        self.bytes.load(Ordering::Relaxed)
    }

    /// Ask the pump to stop. Observed the next time a `read()` returns; it does
    /// **not** interrupt a read already blocked (that requires tearing the child
    /// down — the no-EOF-while-writer-held discipline).
    pub fn request_stop(&self) {
        self.stop.store(true, Ordering::Relaxed);
    }

    /// Join the pump thread. Only returns once the underlying `read()` has
    /// unblocked (EOF / error / a returned read that observes the stop flag) —
    /// on ConPTY that means the child must be torn down first. Best-effort.
    pub fn join(mut self) {
        if let Some(h) = self.handle.take() {
            let _ = h.join();
        }
    }
}

impl Drain {
    /// Spawn the drain thread over `reader`, answering DSR via `writer` and
    /// forwarding every byte to `sink`. The `sink` may block (that is how A4's
    /// bounded channel applies backpressure).
    fn spawn<R, S>(reader: R, writer: SharedWriter, mut sink: S) -> Self
    where
        R: Read + Send + 'static,
        S: FnMut(&[u8]) + Send + 'static,
    {
        let stop = Arc::new(AtomicBool::new(false));
        let dsr_answered = Arc::new(AtomicU64::new(0));
        let bytes = Arc::new(AtomicU64::new(0));

        let t_stop = Arc::clone(&stop);
        let t_dsr = Arc::clone(&dsr_answered);
        let t_bytes = Arc::clone(&bytes);

        let handle = thread::spawn(move || {
            let mut reader = reader;
            let mut matcher = DsrMatcher::default();
            let mut buf = [0u8; 8192];
            loop {
                if t_stop.load(Ordering::Relaxed) {
                    break;
                }
                match reader.read(&mut buf) {
                    Ok(0) => break, // EOF — child gone and handles released
                    Ok(n) => {
                        let chunk = &buf[..n];
                        let hits = matcher.scan(chunk);
                        if hits > 0 {
                            // Answer through the shared, serialized writer so the
                            // CPR never interleaves with caller-injected input.
                            if let Ok(mut w) = writer.lock() {
                                for _ in 0..hits {
                                    let _ = w.write_all(DSR_REPLY);
                                }
                                let _ = w.flush();
                            }
                            t_dsr.fetch_add(hits, Ordering::Relaxed);
                        }
                        sink(chunk);
                        t_bytes.fetch_add(n as u64, Ordering::Relaxed);
                    }
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
                    Err(_) => break,
                }
            }
        });

        Drain { stop, dsr_answered, bytes, handle: Some(handle) }
    }
}

impl PtySession {
    /// Start draining this surface's output on a background thread, answering
    /// ConPTY's DSR query and forwarding every byte to `sink`. Takes the master
    /// reader (once — a second call returns [`SurfaceError::ReaderTaken`]).
    ///
    /// This is the substrate the A4 bounded byte-stream and the A5 digest ride.
    //
    // [impl->REQ-HAZARD-CONPTY-DSR]
    pub fn drain<S>(&self, sink: S) -> Result<Drain, SurfaceError>
    where
        S: FnMut(&[u8]) + Send + 'static,
    {
        let reader = self.take_reader()?;
        let writer = self.shared_writer();
        Ok(Drain::spawn(reader, writer, sink))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn dsr_matcher_finds_query_across_chunk_boundary() {
        let mut m = DsrMatcher::default();
        // Split `\x1b[6n` across two feeds.
        assert_eq!(m.scan(b"output\x1b["), 0);
        assert_eq!(m.scan(b"6nmore"), 1);
        // A lone ESC that restarts a match mid-stream, then completes.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b\x1b[6n"), 1);
        // Two back-to-back queries.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b[6n\x1b[6n"), 2);
    }
}


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

//! Output drain pump — the ConPTY-DSR auto-answer reader (M3a A2).
//!
//! ConPTY emits a cursor-position query `ESC [ 6 n` at startup and **withholds
//! all child stdout until the terminal answers it** (Spike #1: first runs
//! captured exactly the 4 bytes `\e[6n` and zero program output — the child
//! looked dead but was ticking fine). Replying `ESC [ 1;1 R` immediately
//! unblocks the full stream. A real terminal *is* expected to answer DSR, so the
//! pump answers every `ESC[6n` it sees on every platform (harmless correct CPR
//! on the Unix raw-pipe backend; load-bearing on ConPTY).
//!
//! **No-EOF-while-writer-held (Spike #1 secondary gotcha):** a ConPTY master
//! does *not* EOF while the writer is held, so the read loop runs on its own
//! thread and shutdown **never gates on a blocking `read()` returning**. The
//! broker stops a surface by terminating the child (which tears the PTY down and
//! unblocks the read); the [`Drain`] stop flag is a cooperative secondary signal
//! observed whenever a read does return. Deterministic, ordered teardown (drop
//! the writer, then the master) is the M3b broker's lifecycle responsibility.

use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;

use crate::pty::{PtySession, SharedWriter};
use crate::surface::SurfaceError;

/// The ConPTY startup cursor-position query (DSR — Device Status Report). All
/// child stdout is withheld until this is answered (REQ-HAZARD-CONPTY-DSR).
const DSR_QUERY: &[u8] = b"\x1b[6n";
/// The reply: a Cursor Position Report placing the cursor at row 1, col 1. Any
/// valid CPR unblocks ConPTY; `1;1` is the minimal answer Spike #1 proved.
const DSR_REPLY: &[u8] = b"\x1b[1;1R";

/// Streaming matcher for [`DSR_QUERY`] that survives chunk boundaries (the 4-byte
/// query can split across two `read()`s). `DSR_QUERY` has no proper prefix that
/// is also a suffix, so a single "bytes matched so far" counter is sufficient.
#[derive(Default)]
struct DsrMatcher {
    matched: usize,
}

impl DsrMatcher {
    /// Feed a chunk; return how many complete DSR queries it completed.
    fn scan(&mut self, chunk: &[u8]) -> u64 {
        let mut hits = 0;
        for &b in chunk {
            if b == DSR_QUERY[self.matched] {
                self.matched += 1;
                if self.matched == DSR_QUERY.len() {
                    hits += 1;
                    self.matched = 0;
                }
            } else {
                // Restart — but `b` may itself begin a fresh match (e.g. a lone ESC).
                self.matched = usize::from(b == DSR_QUERY[0]);
            }
        }
        hits
    }
}

/// A running drain pump over a surface's output. Holds live counters and a
/// cooperative stop flag; see the module docs for the teardown contract.
pub struct Drain {
    stop: Arc<AtomicBool>,
    dsr_answered: Arc<AtomicU64>,
    bytes: Arc<AtomicU64>,
    handle: Option<thread::JoinHandle<()>>,
}

impl Drain {
    /// Total DSR queries answered so far (live).
    pub fn dsr_answered(&self) -> u64 {
        self.dsr_answered.load(Ordering::Relaxed)
    }

    /// Total bytes forwarded to the sink so far (live).
    pub fn bytes_forwarded(&self) -> u64 {
        self.bytes.load(Ordering::Relaxed)
    }

    /// Ask the pump to stop. Observed the next time a `read()` returns; it does
    /// **not** interrupt a read already blocked (that requires tearing the child
    /// down — the no-EOF-while-writer-held discipline).
    pub fn request_stop(&self) {
        self.stop.store(true, Ordering::Relaxed);
    }

    /// Join the pump thread. Only returns once the underlying `read()` has
    /// unblocked (EOF / error / a returned read that observes the stop flag) —
    /// on ConPTY that means the child must be torn down first. Best-effort.
    pub fn join(mut self) {
        if let Some(h) = self.handle.take() {
            let _ = h.join();
        }
    }
}

impl Drain {
    /// Spawn the drain thread over `reader`, answering DSR via `writer` and
    /// forwarding every byte to `sink`. The `sink` may block (that is how A4's
    /// bounded channel applies backpressure).
    fn spawn<R, S>(reader: R, writer: SharedWriter, mut sink: S) -> Self
    where
        R: Read + Send + 'static,
        S: FnMut(&[u8]) + Send + 'static,
    {
        let stop = Arc::new(AtomicBool::new(false));
        let dsr_answered = Arc::new(AtomicU64::new(0));
        let bytes = Arc::new(AtomicU64::new(0));

        let t_stop = Arc::clone(&stop);
        let t_dsr = Arc::clone(&dsr_answered);
        let t_bytes = Arc::clone(&bytes);

        let handle = thread::spawn(move || {
            let mut reader = reader;
            let mut matcher = DsrMatcher::default();
            let mut buf = [0u8; 8192];
            loop {
                if t_stop.load(Ordering::Relaxed) {
                    break;
                }
                match reader.read(&mut buf) {
                    Ok(0) => break, // EOF — child gone and handles released
                    Ok(n) => {
                        let chunk = &buf[..n];
                        let hits = matcher.scan(chunk);
                        if hits > 0 {
                            // Answer through the shared, serialized writer so the
                            // CPR never interleaves with caller-injected input.
                            if let Ok(mut w) = writer.lock() {
                                for _ in 0..hits {
                                    let _ = w.write_all(DSR_REPLY);
                                }
                                let _ = w.flush();
                            }
                            t_dsr.fetch_add(hits, Ordering::Relaxed);
                        }
                        sink(chunk);
                        t_bytes.fetch_add(n as u64, Ordering::Relaxed);
                    }
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
                    Err(_) => break,
                }
            }
        });

        Drain { stop, dsr_answered, bytes, handle: Some(handle) }
    }
}

impl PtySession {
    /// Start draining this surface's output on a background thread, answering
    /// ConPTY's DSR query and forwarding every byte to `sink`. Takes the master
    /// reader (once — a second call returns [`SurfaceError::ReaderTaken`]).
    ///
    /// This is the substrate the A4 bounded byte-stream and the A5 digest ride.
    //
    // [impl->REQ-HAZARD-CONPTY-DSR]
    pub fn drain<S>(&self, sink: S) -> Result<Drain, SurfaceError>
    where
        S: FnMut(&[u8]) + Send + 'static,
    {
        let reader = self.take_reader()?;
        let writer = self.shared_writer();
        Ok(Drain::spawn(reader, writer, sink))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn dsr_matcher_finds_query_across_chunk_boundary() {
        let mut m = DsrMatcher::default();
        // Split `\x1b[6n` across two feeds.
        assert_eq!(m.scan(b"output\x1b["), 0);
        assert_eq!(m.scan(b"6nmore"), 1);
        // A lone ESC that restarts a match mid-stream, then completes.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b\x1b[6n"), 1);
        // Two back-to-back queries.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b[6n\x1b[6n"), 2);
    }
}


Edit

//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR)
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

pub mod pty;
pub mod surface;

pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};

//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

pub mod pty;
pub mod reader;
pub mod surface;

pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};


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

//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

pub mod pty;
pub mod reader;
pub mod surface;

pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};

//! spt-core session-surface mechanism — the broker's PTY half (M3a).
//!
//! `spt-term` is the **multiplatform session-surface mechanism** that every hosted
//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
//! injection, byte-stream output, and the live activity buffer (PTY digest) parser
//! primitive layered on top. It is the sixth crate in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-term`), a sibling of `spt-runtime`.
//!
//! M3a delivers the *library mechanism*; the **M3b** `spt-daemon` broker delivers
//! the *process* that hosts many of these behind versioned IPC. `spt-term` is
//! deliberately **single-surface-per-handle** — it owns one child's PTY, not the
//! multiplexed name-addressable fleet (that supervisor is M3b). See `M3a-PLAN.md`.
//!
//! ## OS stream-contract neutrality (Spike #4 / #5, binding)
//!
//! The single biggest design risk this crate carries: `forkpty` is a **raw ordered
//! pipe**; ConPTY is a **screen buffer** (repaint-on-resize reorders + duplicates
//! the stream; a mid-session attach sees the viewport, not scrollback). The
//! [`SessionSurface`] trait and the byte-stream / digest contracts therefore must
//! **not** bake in either OS's semantics — the surface is modeled as a byte stream
//! with explicit, OS-independent attach/replay semantics.
//
// spt-term is internal broker machinery, NOT part of the public semver SDK
// surface (R-ARCH-2 stays spt-proto/spt-store/spt-msg/spt-runtime). The M3b
// daemon programs against it; external harness authors do not.

// Surfaces land across M3a (see M3a-PLAN.md task table):
//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR)
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

pub mod pty;
pub mod surface;

pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};

-//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR)

+//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed


Now the A2 integration test (cross-OS forward + Windows DSR-answer proof).


Write

//! A2 regression tests for the ConPTY-DSR auto-answer drain pump.
//!
//! On ConPTY, child stdout is **withheld until the DSR query is answered**
//! (Spike #1) — so on Windows, observing the child's output through the drain at
//! all *is* proof the pump answered DSR; the explicit `dsr_answered()` assertion
//! pins it. On the Unix raw-pipe backend there is no withholding; the test still
//! confirms the drain forwards output and is DSR-clean.

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

use spt_term::{PtySession, SurfaceSize};

/// A child that prints a unique marker and exits, OS-appropriate.
fn print_marker(marker: &str) -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program(
        "sh",
        ["-c", &format!("printf '{marker}\\n'")],
        SurfaceSize::new(24, 80),
    );
    #[cfg(windows)]
    let s = PtySession::spawn_program(
        "cmd",
        ["/c", &format!("echo {marker}")],
        SurfaceSize::new(24, 80),
    );
    s.expect("spawn marker child under a pty")
}

/// Poll `collected` until it contains `needle` or the budget elapses.
fn wait_for(collected: &Arc<Mutex<Vec<u8>>>, needle: &str) -> bool {
    for _ in 0..500 {
        if String::from_utf8_lossy(&collected.lock().unwrap()).contains(needle) {
            return true;
        }
        thread::sleep(Duration::from_millis(10));
    }
    false
}

// [int->REQ-HAZARD-CONPTY-DSR]
#[test]
fn drain_forwards_child_output() {
    let session = print_marker("HELLO-DRAIN");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    let found = wait_for(&collected, "HELLO-DRAIN");
    drain.request_stop();
    let _ = session.kill();

    assert!(
        found,
        "the drain must forward the child's stdout (ConPTY: only after DSR is answered)"
    );
    // The reader is once-only: drain took it.
    assert!(session.take_reader().is_err(), "drain owns the reader");
}

// [int->REQ-HAZARD-CONPTY-DSR]
#[cfg(windows)]
#[test]
fn conpty_dsr_is_answered_so_output_is_not_withheld() {
    let session = print_marker("DSR-PROOF");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    // On ConPTY this output only arrives because the pump answered ESC[6n.
    let found = wait_for(&collected, "DSR-PROOF");
    drain.request_stop();
    let _ = session.kill();

    assert!(found, "ConPTY withholds all stdout until DSR is answered");
    assert!(
        drain.dsr_answered() >= 1,
        "the pump must have answered ConPTY's startup DSR query"
    );
    assert!(drain.bytes_forwarded() > 0, "bytes flowed after the answer");
}


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

//! A2 regression tests for the ConPTY-DSR auto-answer drain pump.
//!
//! On ConPTY, child stdout is **withheld until the DSR query is answered**
//! (Spike #1) — so on Windows, observing the child's output through the drain at
//! all *is* proof the pump answered DSR; the explicit `dsr_answered()` assertion
//! pins it. On the Unix raw-pipe backend there is no withholding; the test still
//! confirms the drain forwards output and is DSR-clean.

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

use spt_term::{PtySession, SurfaceSize};

/// A child that prints a unique marker and exits, OS-appropriate.
fn print_marker(marker: &str) -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program(
        "sh",
        ["-c", &format!("printf '{marker}\\n'")],
        SurfaceSize::new(24, 80),
    );
    #[cfg(windows)]
    let s = PtySession::spawn_program(
        "cmd",
        ["/c", &format!("echo {marker}")],
        SurfaceSize::new(24, 80),
    );
    s.expect("spawn marker child under a pty")
}

/// Poll `collected` until it contains `needle` or the budget elapses.
fn wait_for(collected: &Arc<Mutex<Vec<u8>>>, needle: &str) -> bool {
    for _ in 0..500 {
        if String::from_utf8_lossy(&collected.lock().unwrap()).contains(needle) {
            return true;
        }
        thread::sleep(Duration::from_millis(10));
    }
    false
}

// [int->REQ-HAZARD-CONPTY-DSR]
#[test]
fn drain_forwards_child_output() {
    let session = print_marker("HELLO-DRAIN");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    let found = wait_for(&collected, "HELLO-DRAIN");
    drain.request_stop();
    let _ = session.kill();

    assert!(
        found,
        "the drain must forward the child's stdout (ConPTY: only after DSR is answered)"
    );
    // The reader is once-only: drain took it.
    assert!(session.take_reader().is_err(), "drain owns the reader");
}

// [int->REQ-HAZARD-CONPTY-DSR]
#[cfg(windows)]
#[test]
fn conpty_dsr_is_answered_so_output_is_not_withheld() {
    let session = print_marker("DSR-PROOF");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    // On ConPTY this output only arrives because the pump answered ESC[6n.
    let found = wait_for(&collected, "DSR-PROOF");
    drain.request_stop();
    let _ = session.kill();

    assert!(found, "ConPTY withholds all stdout until DSR is answered");
    assert!(
        drain.dsr_answered() >= 1,
        "the pump must have answered ConPTY's startup DSR query"
    );
    assert!(drain.bytes_forwarded() > 0, "bytes flowed after the answer");
}


Bash

Run A2 tests on Windows


   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.03s
     Running unittests src\lib.rs (target\debug\deps\spt_term-48be9b7ca1471aa6.exe)

running 1 test
test reader::tests::dsr_matcher_finds_query_across_chunk_boundary ... ok

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

     Running tests\dsr.rs (target\debug\deps\dsr-be86cd065eed8da3.exe)

running 2 tests
test conpty_dsr_is_answered_so_output_is_not_withheld ... FAILED
test drain_forwards_child_output ... ok

failures:

---- conpty_dsr_is_answered_so_output_is_not_withheld stdout ----

thread 'conpty_dsr_is_answered_so_output_is_not_withheld' (60236) panicked at crates\spt-term\tests\dsr.rs:82:5:
the pump must have answered ConPTY's startup DSR query
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    conpty_dsr_is_answered_so_output_is_not_withheld

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

error: test failed, to rerun pass `-p spt-term --test dsr`


Output flowed on Windows *without* a detected DSR answer — modern ConPTY (Win11 26200) may not stall here, or emits a different query. Probe raw startup bytes.


cat > crates/spt-term/examples/dsr_probe.rs <<'EOF'
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use spt_term::{PtySession, SurfaceSize};

fn main() {
    let session = PtySession::spawn_program("cmd", ["/c", "echo HELLO-PROBE"], SurfaceSize::new(24, 80)).unwrap();
    let buf = Arc::new(Mutex::new(Vec::new()));
    let s = Arc::clone(&buf);
    let _d = session.drain(move |c| s.lock().unwrap().extend_from_slice(c)).unwrap();
    thread::sleep(Duration::from_millis(800));
    let _ = session.kill();
    let b = buf.lock().unwrap();
    print!("RAW({}): ", b.len());
    for &x in b.iter() {
        if x == 0x1b { print!("<ESC>"); }
        else if (0x20..0x7f).contains(&x) { print!("{}", x as char); }
        else { print!("\\x{:02x}", x); }
    }
    println!();
}
EOF
cargo run -p spt-term --example dsr_probe 2>&1 | tail -5

Probe raw ConPTY startup bytes


/usr/bin/bash: line 27: crates/spt-term/examples/dsr_probe.rs: No such file or directory
error: no example target named `dsr_probe` in `spt-term` package


Write

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use spt_term::{PtySession, SurfaceSize};

fn main() {
    let session =
        PtySession::spawn_program("cmd", ["/c", "echo HELLO-PROBE"], SurfaceSize::new(24, 80))
            .unwrap();
    let buf = Arc::new(Mutex::new(Vec::new()));
    let s = Arc::clone(&buf);
    let _d = session
        .drain(move |c| s.lock().unwrap().extend_from_slice(c))
        .unwrap();
    thread::sleep(Duration::from_millis(800));
    let _ = session.kill();
    let b = buf.lock().unwrap();
    print!("RAW({}): ", b.len());
    for &x in b.iter() {
        if x == 0x1b {
            print!("<ESC>");
        } else if (0x20..0x7f).contains(&x) {
            print!("{}", x as char);
        } else {
            print!("\\x{:02x}", x);
        }
    }
    println!();
}


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

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use spt_term::{PtySession, SurfaceSize};

fn main() {
    let session =
        PtySession::spawn_program("cmd", ["/c", "echo HELLO-PROBE"], SurfaceSize::new(24, 80))
            .unwrap();
    let buf = Arc::new(Mutex::new(Vec::new()));
    let s = Arc::clone(&buf);
    let _d = session
        .drain(move |c| s.lock().unwrap().extend_from_slice(c))
        .unwrap();
    thread::sleep(Duration::from_millis(800));
    let _ = session.kill();
    let b = buf.lock().unwrap();
    print!("RAW({}): ", b.len());
    for &x in b.iter() {
        if x == 0x1b {
            print!("<ESC>");
        } else if (0x20..0x7f).contains(&x) {
            print!("{}", x as char);
        } else {
            print!("\\x{:02x}", x);
        }
    }
    println!();
}


Bash

cargo run -p spt-term --example dsr_probe 2>&1 | grep "RAW" | head

Run DSR probe


RAW(83): <ESC>[?9001h<ESC>[?1004h<ESC>[?25l<ESC>[2J<ESC>[m<ESC>[HHELLO-PROBE\x0d\x0a<ESC>]0;C:\WINDOWS\system32\cmd.EXE\x07<ESC>[?25h


ConPTY on Win11 26200 doesn't emit `ESC[6n` here (startup is `ESC[?9001h…[2J[H`, output not withheld). The hazard is build/program-conditional — keep the defensive auto-answer, but prove it deterministically by injecting a synthetic DSR rather than asserting the host always emits one. Fix tests.


Edit

//! ConPTY emits a cursor-position query `ESC [ 6 n` at startup and **withholds
//! all child stdout until the terminal answers it** (Spike #1: first runs
//! captured exactly the 4 bytes `\e[6n` and zero program output — the child
//! looked dead but was ticking fine). Replying `ESC [ 1;1 R` immediately
//! unblocks the full stream. A real terminal *is* expected to answer DSR, so the
//! pump answers every `ESC[6n` it sees on every platform (harmless correct CPR
//! on the Unix raw-pipe backend; load-bearing on ConPTY).

//! ConPTY can emit a cursor-position query `ESC [ 6 n` and **withhold all child
//! stdout until the terminal answers it** (Spike #1: a run captured exactly the
//! 4 bytes `\e[6n` and zero program output — the child looked dead but was
//! ticking fine; replying `ESC [ 1;1 R` immediately unblocked the full stream).
//! Whether the query fires is build- and program-dependent (some ConPTY versions
//! never emit it for short commands; an app that queries DSR itself always
//! will). A real terminal *is* expected to answer DSR regardless, so the pump
//! answers every `ESC[6n` it sees on every platform unconditionally — harmless
//! correct CPR on the Unix raw-pipe backend, and the stall-breaker on any ConPTY
//! that does withhold. The auto-answer is verified deterministically by injecting
//! a synthetic DSR query (it must not depend on the host's ConPTY emitting one).


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

//! ConPTY can emit a cursor-position query `ESC [ 6 n` and **withhold all child
//! stdout until the terminal answers it** (Spike #1: a run captured exactly the
//! 4 bytes `\e[6n` and zero program output — the child looked dead but was
//! ticking fine; replying `ESC [ 1;1 R` immediately unblocked the full stream).
//! Whether the query fires is build- and program-dependent (some ConPTY versions
//! never emit it for short commands; an app that queries DSR itself always
//! will). A real terminal *is* expected to answer DSR regardless, so the pump
//! answers every `ESC[6n` it sees on every platform unconditionally — harmless
//! correct CPR on the Unix raw-pipe backend, and the stall-breaker on any ConPTY
//! that does withhold. The auto-answer is verified deterministically by injecting
//! a synthetic DSR query (it must not depend on the host's ConPTY emitting one).

//! Output drain pump — the ConPTY-DSR auto-answer reader (M3a A2).
//!
//! ConPTY emits a cursor-position query `ESC [ 6 n` at startup and **withholds
//! all child stdout until the terminal answers it** (Spike #1: first runs
//! captured exactly the 4 bytes `\e[6n` and zero program output — the child
//! looked dead but was ticking fine). Replying `ESC [ 1;1 R` immediately
//! unblocks the full stream. A real terminal *is* expected to answer DSR, so the
//! pump answers every `ESC[6n` it sees on every platform (harmless correct CPR
//! on the Unix raw-pipe backend; load-bearing on ConPTY).
//!
//! **No-EOF-while-writer-held (Spike #1 secondary gotcha):** a ConPTY master
//! does *not* EOF while the writer is held, so the read loop runs on its own
//! thread and shutdown **never gates on a blocking `read()` returning**. The
//! broker stops a surface by terminating the child (which tears the PTY down and
//! unblocks the read); the [`Drain`] stop flag is a cooperative secondary signal
//! observed whenever a read does return. Deterministic, ordered teardown (drop
//! the writer, then the master) is the M3b broker's lifecycle responsibility.

use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;

use crate::pty::{PtySession, SharedWriter};
use crate::surface::SurfaceError;

/// The ConPTY startup cursor-position query (DSR — Device Status Report). All
/// child stdout is withheld until this is answered (REQ-HAZARD-CONPTY-DSR).
const DSR_QUERY: &[u8] = b"\x1b[6n";
/// The reply: a Cursor Position Report placing the cursor at row 1, col 1. Any
/// valid CPR unblocks ConPTY; `1;1` is the minimal answer Spike #1 proved.
const DSR_REPLY: &[u8] = b"\x1b[1;1R";

/// Streaming matcher for [`DSR_QUERY`] that survives chunk boundaries (the 4-byte
/// query can split across two `read()`s). `DSR_QUERY` has no proper prefix that
/// is also a suffix, so a single "bytes matched so far" counter is sufficient.
#[derive(Default)]
struct DsrMatcher {
    matched: usize,
}

impl DsrMatcher {
    /// Feed a chunk; return how many complete DSR queries it completed.
    fn scan(&mut self, chunk: &[u8]) -> u64 {
        let mut hits = 0;
        for &b in chunk {
            if b == DSR_QUERY[self.matched] {
                self.matched += 1;
                if self.matched == DSR_QUERY.len() {
                    hits += 1;
                    self.matched = 0;
                }
            } else {
                // Restart — but `b` may itself begin a fresh match (e.g. a lone ESC).
                self.matched = usize::from(b == DSR_QUERY[0]);
            }
        }
        hits
    }
}

/// A running drain pump over a surface's output. Holds live counters and a
/// cooperative stop flag; see the module docs for the teardown contract.
pub struct Drain {
    stop: Arc<AtomicBool>,
    dsr_answered: Arc<AtomicU64>,
    bytes: Arc<AtomicU64>,
    handle: Option<thread::JoinHandle<()>>,
}

impl Drain {
    /// Total DSR queries answered so far (live).
    pub fn dsr_answered(&self) -> u64 {
        self.dsr_answered.load(Ordering::Relaxed)
    }

    /// Total bytes forwarded to the sink so far (live).
    pub fn bytes_forwarded(&self) -> u64 {
        self.bytes.load(Ordering::Relaxed)
    }

    /// Ask the pump to stop. Observed the next time a `read()` returns; it does
    /// **not** interrupt a read already blocked (that requires tearing the child
    /// down — the no-EOF-while-writer-held discipline).
    pub fn request_stop(&self) {
        self.stop.store(true, Ordering::Relaxed);
    }

    /// Join the pump thread. Only returns once the underlying `read()` has
    /// unblocked (EOF / error / a returned read that observes the stop flag) —
    /// on ConPTY that means the child must be torn down first. Best-effort.
    pub fn join(mut self) {
        if let Some(h) = self.handle.take() {
            let _ = h.join();
        }
    }
}

impl Drain {
    /// Spawn the drain thread over `reader`, answering DSR via `writer` and
    /// forwarding every byte to `sink`. The `sink` may block (that is how A4's
    /// bounded channel applies backpressure).
    fn spawn<R, S>(reader: R, writer: SharedWriter, mut sink: S) -> Self
    where
        R: Read + Send + 'static,
        S: FnMut(&[u8]) + Send + 'static,
    {
        let stop = Arc::new(AtomicBool::new(false));
        let dsr_answered = Arc::new(AtomicU64::new(0));
        let bytes = Arc::new(AtomicU64::new(0));

        let t_stop = Arc::clone(&stop);
        let t_dsr = Arc::clone(&dsr_answered);
        let t_bytes = Arc::clone(&bytes);

        let handle = thread::spawn(move || {
            let mut reader = reader;
            let mut matcher = DsrMatcher::default();
            let mut buf = [0u8; 8192];
            loop {
                if t_stop.load(Ordering::Relaxed) {
                    break;
                }
                match reader.read(&mut buf) {
                    Ok(0) => break, // EOF — child gone and handles released
                    Ok(n) => {
                        let chunk = &buf[..n];
                        let hits = matcher.scan(chunk);
                        if hits > 0 {
                            // Answer through the shared, serialized writer so the
                            // CPR never interleaves with caller-injected input.
                            if let Ok(mut w) = writer.lock() {
                                for _ in 0..hits {
                                    let _ = w.write_all(DSR_REPLY);
                                }
                                let _ = w.flush();
                            }
                            t_dsr.fetch_add(hits, Ordering::Relaxed);
                        }
                        sink(chunk);
                        t_bytes.fetch_add(n as u64, Ordering::Relaxed);
                    }
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
                    Err(_) => break,
                }
            }
        });

        Drain { stop, dsr_answered, bytes, handle: Some(handle) }
    }
}

impl PtySession {
    /// Start draining this surface's output on a background thread, answering
    /// ConPTY's DSR query and forwarding every byte to `sink`. Takes the master
    /// reader (once — a second call returns [`SurfaceError::ReaderTaken`]).
    ///
    /// This is the substrate the A4 bounded byte-stream and the A5 digest ride.
    //
    // [impl->REQ-HAZARD-CONPTY-DSR]
    pub fn drain<S>(&self, sink: S) -> Result<Drain, SurfaceError>
    where
        S: FnMut(&[u8]) + Send + 'static,
    {
        let reader = self.take_reader()?;
        let writer = self.shared_writer();
        Ok(Drain::spawn(reader, writer, sink))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn dsr_matcher_finds_query_across_chunk_boundary() {
        let mut m = DsrMatcher::default();
        // Split `\x1b[6n` across two feeds.
        assert_eq!(m.scan(b"output\x1b["), 0);
        assert_eq!(m.scan(b"6nmore"), 1);
        // A lone ESC that restarts a match mid-stream, then completes.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b\x1b[6n"), 1);
        // Two back-to-back queries.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b[6n\x1b[6n"), 2);
    }
}

-//! ConPTY emits a cursor-position query `ESC [ 6 n` at startup and **withholds

-//! all child stdout until the terminal answers it** (Spike #1: first runs

-//! captured exactly the 4 bytes `\e[6n` and zero program output — the child

-//! looked dead but was ticking fine). Replying `ESC [ 1;1 R` immediately

-//! unblocks the full stream. A real terminal *is* expected to answer DSR, so the

-//! pump answers every `ESC[6n` it sees on every platform (harmless correct CPR

-//! on the Unix raw-pipe backend; load-bearing on ConPTY).

+//! ConPTY can emit a cursor-position query `ESC [ 6 n` and **withhold all child

+//! stdout until the terminal answers it** (Spike #1: a run captured exactly the

+//! 4 bytes `\e[6n` and zero program output — the child looked dead but was

+//! ticking fine; replying `ESC [ 1;1 R` immediately unblocked the full stream).

+//! Whether the query fires is build- and program-dependent (some ConPTY versions

+//! never emit it for short commands; an app that queries DSR itself always

+//! will). A real terminal *is* expected to answer DSR regardless, so the pump

+//! answers every `ESC[6n` it sees on every platform unconditionally — harmless

+//! correct CPR on the Unix raw-pipe backend, and the stall-breaker on any ConPTY

+//! that does withhold. The auto-answer is verified deterministically by injecting

+//! a synthetic DSR query (it must not depend on the host's ConPTY emitting one).


#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn dsr_matcher_finds_query_across_chunk_boundary() {
        let mut m = DsrMatcher::default();
        // Split `\x1b[6n` across two feeds.
        assert_eq!(m.scan(b"output\x1b["), 0);
        assert_eq!(m.scan(b"6nmore"), 1);
        // A lone ESC that restarts a match mid-stream, then completes.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b\x1b[6n"), 1);
        // Two back-to-back queries.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b[6n\x1b[6n"), 2);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;
    use std::time::Duration;

    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn dsr_matcher_finds_query_across_chunk_boundary() {
        let mut m = DsrMatcher::default();
        // Split `\x1b[6n` across two feeds.
        assert_eq!(m.scan(b"output\x1b["), 0);
        assert_eq!(m.scan(b"6nmore"), 1);
        // A lone ESC that restarts a match mid-stream, then completes.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b\x1b[6n"), 1);
        // Two back-to-back queries.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b[6n\x1b[6n"), 2);
    }

    /// A reader that yields one scripted chunk then EOFs — models a ConPTY
    /// stream that emits a DSR query, independent of the host's real ConPTY.
    struct ScriptedReader {
        chunks: std::vec::IntoIter<Vec<u8>>,
    }
    impl Read for ScriptedReader {
        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
            match self.chunks.next() {
                Some(c) => {
                    let n = c.len().min(buf.len());
                    buf[..n].copy_from_slice(&c[..n]);
                    Ok(n)
                }
                None => Ok(0),
            }
        }
    }

    /// A writer capturing everything written, behind the SharedWriter shape.
    fn capturing_writer() -> (SharedWriter, Arc<Mutex<Vec<u8>>>) {
        struct Cap(Arc<Mutex<Vec<u8>>>);
        impl Write for Cap {
            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
                self.0.lock().unwrap().extend_from_slice(b);
                Ok(b.len())
            }
            fn flush(&mut self) -> std::io::Result<()> {
                Ok(())
            }
        }
        let sink = Arc::new(Mutex::new(Vec::new()));
        let w: SharedWriter = Arc::new(Mutex::new(Box::new(Cap(Arc::clone(&sink)))));
        (w, sink)
    }

    // The auto-answer fires deterministically on a synthetic DSR query and the
    // CPR reply reaches the writer — no dependence on the host's ConPTY.
    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn auto_answers_synthetic_dsr_query() {
        let reader = ScriptedReader {
            chunks: vec![b"before\x1b[6nafter".to_vec()].into_iter(),
        };
        let (writer, written) = capturing_writer();
        let forwarded = Arc::new(Mutex::new(Vec::new()));
        let fsink = Arc::clone(&forwarded);

        let drain = Drain::spawn(reader, writer, move |c| {
            fsink.lock().unwrap().extend_from_slice(c)
        });
        drain.join(); // ScriptedReader EOFs, so this returns

        assert_eq!(
            &*written.lock().unwrap(),
            DSR_REPLY,
            "the pump must answer the DSR query with exactly one CPR reply"
        );
        assert_eq!(
            &*forwarded.lock().unwrap(),
            b"before\x1b[6nafter",
            "every byte (query included) is forwarded verbatim to the sink"
        );
    }

    // A stream with no DSR query is forwarded untouched and answers nothing.
    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn no_dsr_means_no_answer() {
        let reader = ScriptedReader {
            chunks: vec![b"plain output, no query".to_vec()].into_iter(),
        };
        let (writer, written) = capturing_writer();
        let drain = Drain::spawn(reader, writer, |_| {});
        // Let the thread finish the one read + EOF.
        for _ in 0..100 {
            if drain.bytes_forwarded() > 0 {
                break;
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        let answered = drain.dsr_answered();
        drain.join();
        assert_eq!(answered, 0, "no DSR query → no CPR answer");
        assert!(written.lock().unwrap().is_empty(), "nothing written back");
    }
}


#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;
    use std::time::Duration;

    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn dsr_matcher_finds_query_across_chunk_boundary() {
        let mut m = DsrMatcher::default();
        // Split `\x1b[6n` across two feeds.
        assert_eq!(m.scan(b"output\x1b["), 0);
        assert_eq!(m.scan(b"6nmore"), 1);
        // A lone ESC that restarts a match mid-stream, then completes.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b\x1b[6n"), 1);
        // Two back-to-back queries.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b[6n\x1b[6n"), 2);
    }

    /// A reader that yields one scripted chunk then EOFs — models a ConPTY
    /// stream that emits a DSR query, independent of the host's real ConPTY.
    struct ScriptedReader {
        chunks: std::vec::IntoIter<Vec<u8>>,
    }
    impl Read for ScriptedReader {
        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
            match self.chunks.next() {
                Some(c) => {
                    let n = c.len().min(buf.len());
                    buf[..n].copy_from_slice(&c[..n]);
                    Ok(n)
                }
                None => Ok(0),
            }
        }
    }

    /// A writer capturing everything written, behind the SharedWriter shape.
    fn capturing_writer() -> (SharedWriter, Arc<Mutex<Vec<u8>>>) {
        struct Cap(Arc<Mutex<Vec<u8>>>);
        impl Write for Cap {
            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
                self.0.lock().unwrap().extend_from_slice(b);
                Ok(b.len())
            }
            fn flush(&mut self) -> std::io::Result<()> {
                Ok(())
            }
        }
        let sink = Arc::new(Mutex::new(Vec::new()));
        let w: SharedWriter = Arc::new(Mutex::new(Box::new(Cap(Arc::clone(&sink)))));
        (w, sink)
    }

    // The auto-answer fires deterministically on a synthetic DSR query and the
    // CPR reply reaches the writer — no dependence on the host's ConPTY.
    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn auto_answers_synthetic_dsr_query() {
        let reader = ScriptedReader {
            chunks: vec![b"before\x1b[6nafter".to_vec()].into_iter(),
        };
        let (writer, written) = capturing_writer();
        let forwarded = Arc::new(Mutex::new(Vec::new()));
        let fsink = Arc::clone(&forwarded);

        let drain = Drain::spawn(reader, writer, move |c| {
            fsink.lock().unwrap().extend_from_slice(c)
        });
        drain.join(); // ScriptedReader EOFs, so this returns

        assert_eq!(
            &*written.lock().unwrap(),
            DSR_REPLY,
            "the pump must answer the DSR query with exactly one CPR reply"
        );
        assert_eq!(
            &*forwarded.lock().unwrap(),
            b"before\x1b[6nafter",
            "every byte (query included) is forwarded verbatim to the sink"
        );
    }

    // A stream with no DSR query is forwarded untouched and answers nothing.
    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn no_dsr_means_no_answer() {
        let reader = ScriptedReader {
            chunks: vec![b"plain output, no query".to_vec()].into_iter(),
        };
        let (writer, written) = capturing_writer();
        let drain = Drain::spawn(reader, writer, |_| {});
        // Let the thread finish the one read + EOF.
        for _ in 0..100 {
            if drain.bytes_forwarded() > 0 {
                break;
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        let answered = drain.dsr_answered();
        drain.join();
        assert_eq!(answered, 0, "no DSR query → no CPR answer");
        assert!(written.lock().unwrap().is_empty(), "nothing written back");
    }
}

//! Output drain pump — the ConPTY-DSR auto-answer reader (M3a A2).
//!
//! ConPTY can emit a cursor-position query `ESC [ 6 n` and **withhold all child
//! stdout until the terminal answers it** (Spike #1: a run captured exactly the
//! 4 bytes `\e[6n` and zero program output — the child looked dead but was
//! ticking fine; replying `ESC [ 1;1 R` immediately unblocked the full stream).
//! Whether the query fires is build- and program-dependent (some ConPTY versions
//! never emit it for short commands; an app that queries DSR itself always
//! will). A real terminal *is* expected to answer DSR regardless, so the pump
//! answers every `ESC[6n` it sees on every platform unconditionally — harmless
//! correct CPR on the Unix raw-pipe backend, and the stall-breaker on any ConPTY
//! that does withhold. The auto-answer is verified deterministically by injecting
//! a synthetic DSR query (it must not depend on the host's ConPTY emitting one).
//!
//! **No-EOF-while-writer-held (Spike #1 secondary gotcha):** a ConPTY master
//! does *not* EOF while the writer is held, so the read loop runs on its own
//! thread and shutdown **never gates on a blocking `read()` returning**. The
//! broker stops a surface by terminating the child (which tears the PTY down and
//! unblocks the read); the [`Drain`] stop flag is a cooperative secondary signal
//! observed whenever a read does return. Deterministic, ordered teardown (drop
//! the writer, then the master) is the M3b broker's lifecycle responsibility.

use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;

use crate::pty::{PtySession, SharedWriter};
use crate::surface::SurfaceError;

/// The ConPTY startup cursor-position query (DSR — Device Status Report). All
/// child stdout is withheld until this is answered (REQ-HAZARD-CONPTY-DSR).
const DSR_QUERY: &[u8] = b"\x1b[6n";
/// The reply: a Cursor Position Report placing the cursor at row 1, col 1. Any
/// valid CPR unblocks ConPTY; `1;1` is the minimal answer Spike #1 proved.
const DSR_REPLY: &[u8] = b"\x1b[1;1R";

/// Streaming matcher for [`DSR_QUERY`] that survives chunk boundaries (the 4-byte
/// query can split across two `read()`s). `DSR_QUERY` has no proper prefix that
/// is also a suffix, so a single "bytes matched so far" counter is sufficient.
#[derive(Default)]
struct DsrMatcher {
    matched: usize,
}

impl DsrMatcher {
    /// Feed a chunk; return how many complete DSR queries it completed.
    fn scan(&mut self, chunk: &[u8]) -> u64 {
        let mut hits = 0;
        for &b in chunk {
            if b == DSR_QUERY[self.matched] {
                self.matched += 1;
                if self.matched == DSR_QUERY.len() {
                    hits += 1;
                    self.matched = 0;
                }
            } else {
                // Restart — but `b` may itself begin a fresh match (e.g. a lone ESC).
                self.matched = usize::from(b == DSR_QUERY[0]);
            }
        }
        hits
    }
}

/// A running drain pump over a surface's output. Holds live counters and a
/// cooperative stop flag; see the module docs for the teardown contract.
pub struct Drain {
    stop: Arc<AtomicBool>,
    dsr_answered: Arc<AtomicU64>,
    bytes: Arc<AtomicU64>,
    handle: Option<thread::JoinHandle<()>>,
}

impl Drain {
    /// Total DSR queries answered so far (live).
    pub fn dsr_answered(&self) -> u64 {
        self.dsr_answered.load(Ordering::Relaxed)
    }

    /// Total bytes forwarded to the sink so far (live).
    pub fn bytes_forwarded(&self) -> u64 {
        self.bytes.load(Ordering::Relaxed)
    }

    /// Ask the pump to stop. Observed the next time a `read()` returns; it does
    /// **not** interrupt a read already blocked (that requires tearing the child
    /// down — the no-EOF-while-writer-held discipline).
    pub fn request_stop(&self) {
        self.stop.store(true, Ordering::Relaxed);
    }

    /// Join the pump thread. Only returns once the underlying `read()` has
    /// unblocked (EOF / error / a returned read that observes the stop flag) —
    /// on ConPTY that means the child must be torn down first. Best-effort.
    pub fn join(mut self) {
        if let Some(h) = self.handle.take() {
            let _ = h.join();
        }
    }
}

impl Drain {
    /// Spawn the drain thread over `reader`, answering DSR via `writer` and
    /// forwarding every byte to `sink`. The `sink` may block (that is how A4's
    /// bounded channel applies backpressure).
    fn spawn<R, S>(reader: R, writer: SharedWriter, mut sink: S) -> Self
    where
        R: Read + Send + 'static,
        S: FnMut(&[u8]) + Send + 'static,
    {
        let stop = Arc::new(AtomicBool::new(false));
        let dsr_answered = Arc::new(AtomicU64::new(0));
        let bytes = Arc::new(AtomicU64::new(0));

        let t_stop = Arc::clone(&stop);
        let t_dsr = Arc::clone(&dsr_answered);
        let t_bytes = Arc::clone(&bytes);

        let handle = thread::spawn(move || {
            let mut reader = reader;
            let mut matcher = DsrMatcher::default();
            let mut buf = [0u8; 8192];
            loop {
                if t_stop.load(Ordering::Relaxed) {
                    break;
                }
                match reader.read(&mut buf) {
                    Ok(0) => break, // EOF — child gone and handles released
                    Ok(n) => {
                        let chunk = &buf[..n];
                        let hits = matcher.scan(chunk);
                        if hits > 0 {
                            // Answer through the shared, serialized writer so the
                            // CPR never interleaves with caller-injected input.
                            if let Ok(mut w) = writer.lock() {
                                for _ in 0..hits {
                                    let _ = w.write_all(DSR_REPLY);
                                }
                                let _ = w.flush();
                            }
                            t_dsr.fetch_add(hits, Ordering::Relaxed);
                        }
                        sink(chunk);
                        t_bytes.fetch_add(n as u64, Ordering::Relaxed);
                    }
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
                    Err(_) => break,
                }
            }
        });

        Drain { stop, dsr_answered, bytes, handle: Some(handle) }
    }
}

impl PtySession {
    /// Start draining this surface's output on a background thread, answering
    /// ConPTY's DSR query and forwarding every byte to `sink`. Takes the master
    /// reader (once — a second call returns [`SurfaceError::ReaderTaken`]).
    ///
    /// This is the substrate the A4 bounded byte-stream and the A5 digest ride.
    //
    // [impl->REQ-HAZARD-CONPTY-DSR]
    pub fn drain<S>(&self, sink: S) -> Result<Drain, SurfaceError>
    where
        S: FnMut(&[u8]) + Send + 'static,
    {
        let reader = self.take_reader()?;
        let writer = self.shared_writer();
        Ok(Drain::spawn(reader, writer, sink))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn dsr_matcher_finds_query_across_chunk_boundary() {
        let mut m = DsrMatcher::default();
        // Split `\x1b[6n` across two feeds.
        assert_eq!(m.scan(b"output\x1b["), 0);
        assert_eq!(m.scan(b"6nmore"), 1);
        // A lone ESC that restarts a match mid-stream, then completes.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b\x1b[6n"), 1);
        // Two back-to-back queries.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b[6n\x1b[6n"), 2);
    }
}

#[cfg(test)]
mod tests {
use super::*;
+    use std::sync::Mutex;
+    use std::time::Duration;
// [unit->REQ-HAZARD-CONPTY-DSR]
#[test]

+    /// A reader that yields one scripted chunk then EOFs — models a ConPTY

+    /// stream that emits a DSR query, independent of the host's real ConPTY.

+        chunks: std::vec::IntoIter<Vec<u8>>,

+        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {

+                    let n = c.len().min(buf.len());

+                    buf[..n].copy_from_slice(&c[..n]);

+    /// A writer capturing everything written, behind the SharedWriter shape.

+    fn capturing_writer() -> (SharedWriter, Arc<Mutex<Vec<u8>>>) {

+        struct Cap(Arc<Mutex<Vec<u8>>>);

+            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {

+                self.0.lock().unwrap().extend_from_slice(b);

+            fn flush(&mut self) -> std::io::Result<()> {

+        let sink = Arc::new(Mutex::new(Vec::new()));

+        let w: SharedWriter = Arc::new(Mutex::new(Box::new(Cap(Arc::clone(&sink)))));

+    // The auto-answer fires deterministically on a synthetic DSR query and the

+    // CPR reply reaches the writer — no dependence on the host's ConPTY.

+    fn auto_answers_synthetic_dsr_query() {

+            chunks: vec![b"before\x1b[6nafter".to_vec()].into_iter(),

+        let (writer, written) = capturing_writer();

+        let forwarded = Arc::new(Mutex::new(Vec::new()));

+        let fsink = Arc::clone(&forwarded);

+        let drain = Drain::spawn(reader, writer, move |c| {

+            fsink.lock().unwrap().extend_from_slice(c)

+        drain.join(); // ScriptedReader EOFs, so this returns

+            "the pump must answer the DSR query with exactly one CPR reply"

+            &*forwarded.lock().unwrap(),

+            "every byte (query included) is forwarded verbatim to the sink"

+    // A stream with no DSR query is forwarded untouched and answers nothing.

+            chunks: vec![b"plain output, no query".to_vec()].into_iter(),

+        let drain = Drain::spawn(reader, writer, |_| {});

+        // Let the thread finish the one read + EOF.

+            if drain.bytes_forwarded() > 0 {

+            std::thread::sleep(Duration::from_millis(5));

+        let answered = drain.dsr_answered();

+        assert_eq!(answered, 0, "no DSR query → no CPR answer");

+        assert!(written.lock().unwrap().is_empty(), "nothing written back");


Now fix the integration test — drop the host-emits-DSR assumption, keep not-withheld proof.


//! On ConPTY, child stdout is **withheld until the DSR query is answered**
//! (Spike #1) — so on Windows, observing the child's output through the drain at
//! all *is* proof the pump answered DSR; the explicit `dsr_answered()` assertion
//! pins it. On the Unix raw-pipe backend there is no withholding; the test still
//! confirms the drain forwards output and is DSR-clean.

//! Whether the host ConPTY emits the DSR query is build/program-dependent, so
//! the *auto-answer mechanism* is proven deterministically with a synthetic
//! query in the `reader` unit tests. These integration tests prove the
//! complementary end-to-end guarantee on a **real** PTY: the drain forwards the
//! child's stdout and nothing is withheld (on any ConPTY that does stall, the
//! auto-answer is what keeps this passing).


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

//! Whether the host ConPTY emits the DSR query is build/program-dependent, so
//! the *auto-answer mechanism* is proven deterministically with a synthetic
//! query in the `reader` unit tests. These integration tests prove the
//! complementary end-to-end guarantee on a **real** PTY: the drain forwards the
//! child's stdout and nothing is withheld (on any ConPTY that does stall, the
//! auto-answer is what keeps this passing).

//! A2 regression tests for the ConPTY-DSR auto-answer drain pump.
//!
//! On ConPTY, child stdout is **withheld until the DSR query is answered**
//! (Spike #1) — so on Windows, observing the child's output through the drain at
//! all *is* proof the pump answered DSR; the explicit `dsr_answered()` assertion
//! pins it. On the Unix raw-pipe backend there is no withholding; the test still
//! confirms the drain forwards output and is DSR-clean.

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

use spt_term::{PtySession, SurfaceSize};

/// A child that prints a unique marker and exits, OS-appropriate.
fn print_marker(marker: &str) -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program(
        "sh",
        ["-c", &format!("printf '{marker}\\n'")],
        SurfaceSize::new(24, 80),
    );
    #[cfg(windows)]
    let s = PtySession::spawn_program(
        "cmd",
        ["/c", &format!("echo {marker}")],
        SurfaceSize::new(24, 80),
    );
    s.expect("spawn marker child under a pty")
}

/// Poll `collected` until it contains `needle` or the budget elapses.
fn wait_for(collected: &Arc<Mutex<Vec<u8>>>, needle: &str) -> bool {
    for _ in 0..500 {
        if String::from_utf8_lossy(&collected.lock().unwrap()).contains(needle) {
            return true;
        }
        thread::sleep(Duration::from_millis(10));
    }
    false
}

// [int->REQ-HAZARD-CONPTY-DSR]
#[test]
fn drain_forwards_child_output() {
    let session = print_marker("HELLO-DRAIN");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    let found = wait_for(&collected, "HELLO-DRAIN");
    drain.request_stop();
    let _ = session.kill();

    assert!(
        found,
        "the drain must forward the child's stdout (ConPTY: only after DSR is answered)"
    );
    // The reader is once-only: drain took it.
    assert!(session.take_reader().is_err(), "drain owns the reader");
}

// [int->REQ-HAZARD-CONPTY-DSR]
#[cfg(windows)]
#[test]
fn conpty_dsr_is_answered_so_output_is_not_withheld() {
    let session = print_marker("DSR-PROOF");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    // On ConPTY this output only arrives because the pump answered ESC[6n.
    let found = wait_for(&collected, "DSR-PROOF");
    drain.request_stop();
    let _ = session.kill();

    assert!(found, "ConPTY withholds all stdout until DSR is answered");
    assert!(
        drain.dsr_answered() >= 1,
        "the pump must have answered ConPTY's startup DSR query"
    );
    assert!(drain.bytes_forwarded() > 0, "bytes flowed after the answer");
}

-//! On ConPTY, child stdout is **withheld until the DSR query is answered**

-//! (Spike #1) — so on Windows, observing the child's output through the drain at

-//! all *is* proof the pump answered DSR; the explicit `dsr_answered()` assertion

-//! pins it. On the Unix raw-pipe backend there is no withholding; the test still

-//! confirms the drain forwards output and is DSR-clean.

+//! Whether the host ConPTY emits the DSR query is build/program-dependent, so

+//! the *auto-answer mechanism* is proven deterministically with a synthetic

+//! query in the `reader` unit tests. These integration tests prove the

+//! complementary end-to-end guarantee on a **real** PTY: the drain forwards the

+//! child's stdout and nothing is withheld (on any ConPTY that does stall, the

+//! auto-answer is what keeps this passing).


// [int->REQ-HAZARD-CONPTY-DSR]
#[cfg(windows)]
#[test]
fn conpty_dsr_is_answered_so_output_is_not_withheld() {
    let session = print_marker("DSR-PROOF");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    // On ConPTY this output only arrives because the pump answered ESC[6n.
    let found = wait_for(&collected, "DSR-PROOF");
    drain.request_stop();
    let _ = session.kill();

    assert!(found, "ConPTY withholds all stdout until DSR is answered");
    assert!(
        drain.dsr_answered() >= 1,
        "the pump must have answered ConPTY's startup DSR query"
    );
    assert!(drain.bytes_forwarded() > 0, "bytes flowed after the answer");
}

// [int->REQ-HAZARD-CONPTY-DSR]
#[cfg(windows)]
#[test]
fn conpty_output_not_withheld_under_the_drain() {
    let session = print_marker("DSR-PROOF");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    // The drain runs the DSR auto-answer; ConPTY stdout reaches us either way.
    let found = wait_for(&collected, "DSR-PROOF");
    drain.request_stop();
    let _ = session.kill();

    assert!(found, "ConPTY stdout must not be withheld under the drain");
    assert!(drain.bytes_forwarded() > 0, "bytes flowed");
}


// [int->REQ-HAZARD-CONPTY-DSR]
#[cfg(windows)]
#[test]
fn conpty_output_not_withheld_under_the_drain() {
    let session = print_marker("DSR-PROOF");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    // The drain runs the DSR auto-answer; ConPTY stdout reaches us either way.
    let found = wait_for(&collected, "DSR-PROOF");
    drain.request_stop();
    let _ = session.kill();

    assert!(found, "ConPTY stdout must not be withheld under the drain");
    assert!(drain.bytes_forwarded() > 0, "bytes flowed");
}

//! A2 regression tests for the ConPTY-DSR auto-answer drain pump.
//!
//! Whether the host ConPTY emits the DSR query is build/program-dependent, so
//! the *auto-answer mechanism* is proven deterministically with a synthetic
//! query in the `reader` unit tests. These integration tests prove the
//! complementary end-to-end guarantee on a **real** PTY: the drain forwards the
//! child's stdout and nothing is withheld (on any ConPTY that does stall, the
//! auto-answer is what keeps this passing).

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

use spt_term::{PtySession, SurfaceSize};

/// A child that prints a unique marker and exits, OS-appropriate.
fn print_marker(marker: &str) -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program(
        "sh",
        ["-c", &format!("printf '{marker}\\n'")],
        SurfaceSize::new(24, 80),
    );
    #[cfg(windows)]
    let s = PtySession::spawn_program(
        "cmd",
        ["/c", &format!("echo {marker}")],
        SurfaceSize::new(24, 80),
    );
    s.expect("spawn marker child under a pty")
}

/// Poll `collected` until it contains `needle` or the budget elapses.
fn wait_for(collected: &Arc<Mutex<Vec<u8>>>, needle: &str) -> bool {
    for _ in 0..500 {
        if String::from_utf8_lossy(&collected.lock().unwrap()).contains(needle) {
            return true;
        }
        thread::sleep(Duration::from_millis(10));
    }
    false
}

// [int->REQ-HAZARD-CONPTY-DSR]
#[test]
fn drain_forwards_child_output() {
    let session = print_marker("HELLO-DRAIN");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    let found = wait_for(&collected, "HELLO-DRAIN");
    drain.request_stop();
    let _ = session.kill();

    assert!(
        found,
        "the drain must forward the child's stdout (ConPTY: only after DSR is answered)"
    );
    // The reader is once-only: drain took it.
    assert!(session.take_reader().is_err(), "drain owns the reader");
}

// [int->REQ-HAZARD-CONPTY-DSR]
#[cfg(windows)]
#[test]
fn conpty_dsr_is_answered_so_output_is_not_withheld() {
    let session = print_marker("DSR-PROOF");
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");

    // On ConPTY this output only arrives because the pump answered ESC[6n.
    let found = wait_for(&collected, "DSR-PROOF");
    drain.request_stop();
    let _ = session.kill();

    assert!(found, "ConPTY withholds all stdout until DSR is answered");
    assert!(
        drain.dsr_answered() >= 1,
        "the pump must have answered ConPTY's startup DSR query"
    );
    assert!(drain.bytes_forwarded() > 0, "bytes flowed after the answer");
}

-    // On ConPTY this output only arrives because the pump answered ESC[6n.

+    // The drain runs the DSR auto-answer; ConPTY stdout reaches us either way.

-    assert!(found, "ConPTY withholds all stdout until DSR is answered");

-        "the pump must have answered ConPTY's startup DSR query"

-    assert!(drain.bytes_forwarded() > 0, "bytes flowed after the answer");

+    assert!(found, "ConPTY stdout must not be withheld under the drain");

+    assert!(drain.bytes_forwarded() > 0, "bytes flowed");


Bash

Re-run A2 tests on Windows


test reader::tests::no_dsr_means_no_answer ... ok

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

     Running tests\dsr.rs (target\debug\deps\dsr-be86cd065eed8da3.exe)

running 2 tests
test drain_forwards_child_output ... ok
test conpty_output_not_withheld_under_the_drain ... ok

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

     Running tests\surface.rs (target\debug\deps\surface-2c41d505ae7ef51e.exe)

running 1 test
test resize_under_load_does_not_hang_or_panic ... ok

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

   Doc-tests spt_term

running 0 tests

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


All green on Windows. Remove the probe example, clippy, activate the hazard req.


rm crates/spt-term/examples/dsr_probe.rs && rmdir crates/spt-term/examples 2>/dev/null; cargo clippy -p spt-term --all-targets -- -D warnings 2>&1 | tail -8

Remove probe + clippy


    Checking spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.34s


498	title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
499	required_stages = ["impl", "unit"]
500	# --- Added 2026-05-31 from Stage A red-team + Spike #1 (inactive until M-activation) ---
501	[[requirements]]
502	id = "REQ-HAZARD-CONPTY-DSR"
503	title = "ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)"
504	required_stages = []
505	[[requirements]]
506	id = "REQ-HAZARD-LOCAL-API-AUTH"
507	title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
508	required_stages = ["impl", "unit"]
509	[[requirements]]

title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
required_stages = ["impl", "unit"]
# --- Added 2026-05-31 from Stage A red-team + Spike #1 (inactive until M-activation) ---
[[requirements]]
id = "REQ-HAZARD-CONPTY-DSR"
title = "ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)"
required_stages = []
[[requirements]]
id = "REQ-HAZARD-LOCAL-API-AUTH"
title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
required_stages = ["impl", "unit"]
[[requirements]]


Edit

id = "REQ-HAZARD-CONPTY-DSR"
title = "ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)"
required_stages = []

id = "REQ-HAZARD-CONPTY-DSR"
title = "ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)"
required_stages = ["impl", "unit"]


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

id = "REQ-HAZARD-CONPTY-DSR"
title = "ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)"
required_stages = []

id = "REQ-HAZARD-CONPTY-DSR"
title = "ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)"
required_stages = ["impl", "unit"]

title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"


Bash

/c/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs check 2>&1 | grep -E "CONPTY-DSR|TERM-1|FAIL|MISSING|Error|error|invalid" | head

traceable check for hazard req


  [OK] REQ-HAZARD-CONPTY-DSR  required: [impl, unit]  stages: -doc +impl +unit +int
  [OK] REQ-TERM-1  required: [impl, unit]  stages: -doc +impl +unit -int


Validate A2 on Linux (forkpty), then commit.


tar czf /tmp/spt-sync.tgz --exclude=target --exclude=.git . 2>/dev/null && \
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force \
ssh -o StrictHostKeyChecking=no david@gravity-linux \
  'cat > /tmp/spt-sync.tgz' < /tmp/spt-sync.tgz 2>&1 | tail -1
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force \
ssh -o StrictHostKeyChecking=no david@gravity-linux \
  'rm -rf ~/spt-core-sync && mkdir -p ~/spt-core-sync && tar xzf /tmp/spt-sync.tgz -C ~/spt-core-sync && export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test -p spt-term 2>&1 | tail -22' < /dev/null 2>&1 | tail -26

Sync + test A2 on Linux


     Running tests/dsr.rs (target/debug/deps/dsr-b608c4cf40109e21)

running 1 test
test drain_forwards_child_output ... ok

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

     Running tests/surface.rs (target/debug/deps/surface-423800a3dc2e08e6)

running 2 tests
test captures_child_output_off_the_pty ... ok
test resize_under_load_does_not_hang_or_panic ... ok

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

   Doc-tests spt_term

running 0 tests

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


SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test -p spt-term --lib 2>&1 | grep -E "reader::tests|test result"' < /dev/null 2>&1 | tail; echo "=== clippy linux ==="; SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo clippy -p spt-term --all-targets -- -D warnings 2>&1 | tail -4' < /dev/null 2>&1 | tail -5

Confirm unit tests + clippy on Linux


test reader::tests::dsr_matcher_finds_query_across_chunk_boundary ... ok
test reader::tests::auto_answers_synthetic_dsr_query ... ok
test reader::tests::no_dsr_means_no_answer ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
=== clippy linux ===
error: 'cargo-clippy' is not installed for the toolchain 'stable-x86_64-unknown-linux-gnu'.
help: run `rustup component add clippy` to install it


Linux green (synthetic-DSR auto-answer + no-answer both pass on forkpty). Clippy missing on Linux host — Windows clippy clean, CI matrix covers it in A6. Commit A2.


git add -A && git commit -q -m "feat(term): ConPTY-DSR auto-answer drain pump (M3a A2)

The output drain: a background reader thread that forwards every byte to
a sink and auto-answers ConPTY's DSR cursor query (ESC[6n -> ESC[1;1R)
through the shared, serialized writer so the CPR never interleaves with
caller input. Closes REQ-HAZARD-CONPTY-DSR: a ConPTY that withholds
stdout until DSR is answered (Spike #1) is unblocked; the read loop runs
off-thread and shutdown never gates on a blocking read (no-EOF-while-
writer-held). DsrMatcher tolerates the 4-byte query splitting across
read boundaries.

The query is build/program-conditional — Win11 26200 ConPTY does not
emit it for short commands (verified: startup is ESC[?9001h..[2J[H, no
withholding). So the auto-answer is proven deterministically by
injecting a synthetic DSR over a scripted reader + capturing writer
(must not depend on the host emitting one); the real-PTY integration
test proves the complementary end-to-end guarantee (stdout not withheld
under the drain). Drain exposes live dsr_answered/bytes_forwarded
counters + a cooperative stop flag; deterministic teardown is M3b.

Validated on Windows (ConPTY) + Linux (forkpty, gravity-linux).
Activates REQ-HAZARD-CONPTY-DSR (impl, unit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" && git log --oneline -1

Commit A2


warning: in the working copy of 'crates/spt-term/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/src/pty.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/src/reader.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/tests/dsr.rs', LF will be replaced by CRLF the next time Git touches it
0adaae5 feat(term): ConPTY-DSR auto-answer drain pump (M3a A2)


A2 committed (A0–A2 done). Now A3: injection — `send_keys` (raw) + `send_line` (cooked). Build on `write_input` as default trait methods so M4's network surface inherits them.


Edit

    /// Inform the surface (and thus the child) of a new window geometry. On a
    /// real PTY this updates the kernel winsize and raises `SIGWINCH`.
    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError>;
}

    /// Inform the surface (and thus the child) of a new window geometry. On a
    /// real PTY this updates the kernel winsize and raises `SIGWINCH`.
    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError>;

    /// **send-keys** — raw keystroke injection. Bytes go to the child verbatim,
    /// including control bytes and escape sequences (e.g. `Ctrl-C` = `0x03`, an
    /// arrow key = `ESC [ C`). A semantic alias of [`SessionSurface::write_input`]
    /// for the "type these exact keys" intent. Ordering is preserved: each call
    /// is one serialized write.
    //
    // [impl->REQ-TERM-2]
    fn send_keys(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
        self.write_input(bytes)
    }

    /// **send-line** — cooked line submission. Appends a carriage return (`\r`,
    /// the byte a real Enter keypress emits; the slave line discipline maps it
    /// to newline where applicable) and writes the whole line in one serialized
    /// flush, so a concurrent injector can never split the line.
    //
    // [impl->REQ-TERM-2]
    fn send_line(&self, line: &str) -> Result<(), SurfaceError> {
        let mut buf = Vec::with_capacity(line.len() + 1);
        buf.extend_from_slice(line.as_bytes());
        buf.push(b'\r');
        self.write_input(&buf)
    }
}


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

    /// Inform the surface (and thus the child) of a new window geometry. On a
    /// real PTY this updates the kernel winsize and raises `SIGWINCH`.
    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError>;

    /// **send-keys** — raw keystroke injection. Bytes go to the child verbatim,
    /// including control bytes and escape sequences (e.g. `Ctrl-C` = `0x03`, an
    /// arrow key = `ESC [ C`). A semantic alias of [`SessionSurface::write_input`]
    /// for the "type these exact keys" intent. Ordering is preserved: each call
    /// is one serialized write.
    //
    // [impl->REQ-TERM-2]
    fn send_keys(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
        self.write_input(bytes)
    }

    /// **send-line** — cooked line submission. Appends a carriage return (`\r`,
    /// the byte a real Enter keypress emits; the slave line discipline maps it
    /// to newline where applicable) and writes the whole line in one serialized
    /// flush, so a concurrent injector can never split the line.
    //
    // [impl->REQ-TERM-2]
    fn send_line(&self, line: &str) -> Result<(), SurfaceError> {
        let mut buf = Vec::with_capacity(line.len() + 1);
        buf.extend_from_slice(line.as_bytes());
        buf.push(b'\r');
        self.write_input(&buf)
    }
}

//! The OS-neutral session-surface contract.
//!
//! A [`SessionSurface`] is "anything you can write input to and read output
//! from" — the abstraction every hosted agent session is driven through. The
//! native PTY backend ([`crate::pty::PtySession`]) is the M3a implementation;
//! M4's network-attached surface and the GUI text pane slot in behind the same
//! trait later.
//!
//! **OS stream-contract neutrality (Spike #4 / #5, binding):** the surface is
//! modeled as a byte stream with explicit, OS-independent attach/replay
//! semantics. The trait deliberately exposes *write input* + *resize* only and
//! says nothing about whether the underlying output is a raw ordered pipe
//! (`forkpty`) or a repaint-on-resize screen buffer (ConPTY) — output streaming
//! is a separate, equally OS-neutral concern (A4, [`crate::pty::PtySession`]'s
//! reader). No backend's stream semantics leak through this contract.

use portable_pty::PtySize;

/// Window geometry for a surface — OS-neutral (`rows × cols` plus optional pixel
/// hints some backends fill and others ignore). Mirrors `portable_pty::PtySize`
/// without leaking the dependency into the trait's public shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SurfaceSize {
    /// Number of text rows.
    pub rows: u16,
    /// Number of text columns.
    pub cols: u16,
    /// Cell width in pixels (0 = unset; many systems ignore this).
    pub pixel_width: u16,
    /// Cell height in pixels (0 = unset; many systems ignore this).
    pub pixel_height: u16,
}

impl SurfaceSize {
    /// A `rows × cols` geometry with pixel hints left unset.
    pub const fn new(rows: u16, cols: u16) -> Self {
        Self { rows, cols, pixel_width: 0, pixel_height: 0 }
    }
}

impl Default for SurfaceSize {
    /// The conventional 24×80 terminal.
    fn default() -> Self {
        Self::new(24, 80)
    }
}

impl From<SurfaceSize> for PtySize {
    fn from(s: SurfaceSize) -> Self {
        PtySize {
            rows: s.rows,
            cols: s.cols,
            pixel_width: s.pixel_width,
            pixel_height: s.pixel_height,
        }
    }
}

/// A session-surface failure. Carries an operator-facing message; never panics.
#[derive(Debug)]
pub enum SurfaceError {
    /// The OS refused to allocate a PTY pair.
    OpenPty(String),
    /// The OS refused to spawn the child under the PTY.
    Spawn(String),
    /// An I/O operation on the surface failed (read/write/resize).
    Io(std::io::Error),
    /// The single writer handle was already taken (PTY writers are once-only).
    WriterTaken,
    /// The single reader handle was already taken.
    ReaderTaken,
}

impl std::fmt::Display for SurfaceError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SurfaceError::OpenPty(e) => write!(f, "failed to open a pty: {e}"),
            SurfaceError::Spawn(e) => write!(f, "failed to spawn a child under the pty: {e}"),
            SurfaceError::Io(e) => write!(f, "surface i/o failed: {e}"),
            SurfaceError::WriterTaken => write!(f, "the surface writer was already taken"),
            SurfaceError::ReaderTaken => write!(f, "the surface reader was already taken"),
        }
    }
}

impl std::error::Error for SurfaceError {}

/// Anything a hosted session can be driven through: input goes in, output comes
/// out, geometry can change. See the module docs for the OS-neutrality contract.
///
/// `Send + Sync` so the M3b broker can host the surface behind a shared handle
/// and drive `write_input` / `resize` from its IPC thread while a separate
/// reader thread drains output.
//
// [impl->REQ-TERM-1]
pub trait SessionSurface: Send + Sync {
    /// Write raw bytes to the child's input. Escape sequences pass through
    /// verbatim (the cooked/raw `send_line` / `send_keys` distinction lands on
    /// the concrete surface in A3). Flushed on return.
    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError>;

    /// Inform the surface (and thus the child) of a new window geometry. On a
    /// real PTY this updates the kernel winsize and raises `SIGWINCH`.
    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError>;
}

+    /// **send-keys** — raw keystroke injection. Bytes go to the child verbatim,

+    /// including control bytes and escape sequences (e.g. `Ctrl-C` = `0x03`, an

+    /// arrow key = `ESC [ C`). A semantic alias of [`SessionSurface::write_input`]

+    /// for the "type these exact keys" intent. Ordering is preserved: each call

+    fn send_keys(&self, bytes: &[u8]) -> Result<(), SurfaceError> {

+    /// **send-line** — cooked line submission. Appends a carriage return (`\r`,

+    /// the byte a real Enter keypress emits; the slave line discipline maps it

+    /// to newline where applicable) and writes the whole line in one serialized

+    /// flush, so a concurrent injector can never split the line.

+    fn send_line(&self, line: &str) -> Result<(), SurfaceError> {

+        let mut buf = Vec::with_capacity(line.len() + 1);

+        buf.extend_from_slice(line.as_bytes());