Skip to main content

cli_stream/
process.rs

1//! Streaming subprocess control: spawn a child, pipe its stdout/stderr
2//! line-by-line through a callback as [`Event`]s, and hand back a
3//! [`ProcessHandle`] for cancellation (SIGTERM → SIGKILL).
4//!
5//! The environment is the caller's: this spawns what it is told to spawn, with
6//! the `PATH` it is given. Finding a CLI a user installed — resolving a bare
7//! name, locating the `node` it was installed under — is a different question,
8//! and one only a caller driving such a CLI needs answered.
9//!
10//! Cancellation is the wrinkle: a run needs to be stoppable mid-stream
11//! when the user closes the tab or hits "stop". `ProcessHandle::cancel()`
12//! sends SIGTERM (with a SIGKILL fallback) and flips an atomic
13//! `cancelled` flag the reader threads use to short-circuit.
14
15use crate::error::StreamError;
16use serde::Serialize;
17use std::io::{BufRead, BufReader, Read};
18use std::path::PathBuf;
19use std::process::{Child, Stdio};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::{Arc, Mutex};
22use std::thread;
23use std::time::Duration;
24
25/// Raw events emitted to the caller's callback during a streaming run.
26/// JSON-tagged so axum SSE and Tauri Channel render identical payloads
27/// on the wire. Harness-neutral: a process-backed adapter parses the
28/// `Stdout` lines into a normalized event vocabulary (e.g. `agent-harness`'s `RunEvent`).
29#[derive(Debug, Clone, Serialize)]
30#[serde(tag = "kind", rename_all = "camelCase")]
31// New lifecycle events can be added without breaking downstream matches —
32// consumers must carry a `_` arm. (Construction of existing variants is
33// unaffected, so it's still ergonomic to build them.)
34#[non_exhaustive]
35pub enum Event {
36    /// First event. Sent before the child has produced any output so the
37    /// UI can show a "thinking…" state.
38    Started { run_id: String },
39    /// Raw stdout line. Process-backed CLIs emit one JSON object per line
40    /// in their streaming mode. The caller parses.
41    Stdout { run_id: String, line: String },
42    /// Raw stderr line. Warnings + the occasional error.
43    Stderr { run_id: String, line: String },
44    /// Command / IO failure. Terminal — followed by `Exited`.
45    Error { run_id: String, message: String },
46    /// Process exited. Always sent exactly once at the end.
47    Exited {
48        run_id: String,
49        exit_code: Option<i32>,
50        /// True iff `cancel()` was called before exit.
51        cancelled: bool,
52    },
53}
54
55/// Handle to an in-flight streaming run. Caller stores it (e.g. in a
56/// runId-keyed map) so a later `cancel()` can find it.
57///
58/// Dropping the handle does NOT cancel the run — the reader threads +
59/// wait thread continue independently. Use `cancel()` explicitly when
60/// the user closes the connection.
61#[derive(Clone, Debug)]
62pub struct ProcessHandle {
63    inner: Arc<HandleInner>,
64}
65
66/// How many events `start` buffers before the reader threads wait.
67///
68/// Unbounded would mean a chatty child and a slow consumer growing memory
69/// without limit. Bounded turns that into backpressure instead: enough that a
70/// consumer doing ordinary work never feels it, small enough that a runaway
71/// child cannot exhaust memory before anyone notices.
72const EVENT_BUFFER: usize = 1024;
73
74/// What the child's stdin is connected to.
75///
76/// Most CLIs get everything as arguments and want [`Closed`](Stdin::Closed): a
77/// child that inherits a terminal's stdin can block forever waiting for input
78/// nobody is typing. A child that *answers* — a JSON-RPC server over stdio —
79/// needs [`Piped`](Stdin::Piped) and [`ProcessHandle::write_line`].
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
81pub enum Stdin {
82    #[default]
83    Closed,
84    Piped,
85}
86
87/// What happens to the child's stderr.
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
89pub enum Stderr {
90    /// Read it and deliver each line as [`Event::Stderr`].
91    #[default]
92    Streamed,
93    /// Send it to the null device. The OS discards it, so a chatty child
94    /// costs nothing and can never block on a full pipe — right for a server
95    /// whose stderr is its own logging.
96    Discarded,
97}
98
99/// What to spawn. Named fields rather than six positional arguments, so a call
100/// site says which string is the program and which is the run id, and a new
101/// knob is a field with a default instead of a break.
102#[derive(Debug, Clone)]
103pub struct Command {
104    pub program: PathBuf,
105    pub args: Vec<String>,
106    /// Extra environment for the child, applied over the inherited one.
107    pub env: Vec<(String, String)>,
108    pub cwd: PathBuf,
109    /// The caller's correlation id, echoed on every [`Event`].
110    pub run_id: String,
111    pub stdin: Stdin,
112    pub stderr: Stderr,
113    /// Give up after this long. `None` (the default) waits indefinitely — the
114    /// right answer for an agent run a user is watching and can stop, and the
115    /// wrong one for anything unattended.
116    pub timeout: Option<Duration>,
117}
118
119impl Command {
120    /// A run of `program`, in the current directory, with stdin closed.
121    ///
122    /// The program is the only thing a spawn cannot default, so it is the only
123    /// argument. Everything else is a named method — three bare strings in a
124    /// row read as "which one was the cwd again?".
125    pub fn new(program: impl Into<PathBuf>) -> Self {
126        Self {
127            program: program.into(),
128            args: Vec::new(),
129            env: Vec::new(),
130            cwd: std::env::current_dir().unwrap_or_default(),
131            run_id: String::new(),
132            stdin: Stdin::Closed,
133            stderr: Stderr::Streamed,
134            timeout: None,
135        }
136    }
137
138    /// Where the child runs. Defaults to the current directory.
139    #[must_use]
140    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
141        self.cwd = cwd.into();
142        self
143    }
144
145    /// A correlation id echoed on every [`Event`], for a caller
146    /// multiplexing several runs through one callback. Defaults to empty —
147    /// with one run the handle already identifies it.
148    #[must_use]
149    pub fn run_id(mut self, run_id: impl Into<String>) -> Self {
150        self.run_id = run_id.into();
151        self
152    }
153
154    /// `.args(["--stdio"])` — anything string-like, borrowed or owned.
155    #[must_use]
156    pub fn args<I, S>(mut self, args: I) -> Self
157    where
158        I: IntoIterator<Item = S>,
159        S: Into<String>,
160    {
161        self.args = args.into_iter().map(Into::into).collect();
162        self
163    }
164
165    /// `.env([("RUST_LOG", "info")])` — applied over the inherited environment.
166    #[must_use]
167    pub fn env<I, K, V>(mut self, env: I) -> Self
168    where
169        I: IntoIterator<Item = (K, V)>,
170        K: Into<String>,
171        V: Into<String>,
172    {
173        self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect();
174        self
175    }
176
177    /// What the child's stdin is connected to. `stdout` and `stderr` are always
178    /// piped — streaming them is what this crate is for — and arrive as
179    /// [`Event::Stdout`] / [`Event::Stderr`].
180    #[must_use]
181    pub fn stdin(mut self, stdin: Stdin) -> Self {
182        self.stdin = stdin;
183        self
184    }
185
186    /// Whether the child's stderr is streamed or thrown away.
187    #[must_use]
188    pub fn stderr(mut self, stderr: Stderr) -> Self {
189        self.stderr = stderr;
190        self
191    }
192
193    /// Stop the child if it is still running after `timeout`, the same way
194    /// [`ProcessHandle::cancel`] would — so it exits `cancelled: true` rather
195    /// than hanging a caller that has nobody to press stop.
196    #[must_use]
197    pub fn timeout(mut self, timeout: Duration) -> Self {
198        self.timeout = Some(timeout);
199        self
200    }
201
202    /// Run it, reading events off a channel.
203    ///
204    /// The channel closes on its own when the run ends: the forwarding closure
205    /// is the only owner of the `Sender`, and it drops with the reader threads.
206    pub fn start(self) -> Result<(ProcessHandle, std::sync::mpsc::Receiver<Event>), StreamError> {
207        let (tx, rx) = std::sync::mpsc::sync_channel(EVENT_BUFFER);
208        let handle = self.stream(move |event| {
209            // Blocking here is the point: a full buffer stalls the reader
210            // thread, which stops draining the child's pipe, which slows the
211            // child. Memory stays bounded and no line is lost. A hung-up
212            // receiver returns Err immediately rather than blocking, so a
213            // caller that stopped reading does not wedge the run.
214            let _ = tx.send(event);
215        })?;
216        Ok((handle, rx))
217    }
218
219    /// Run it, pushing each event to `callback` as it happens — for a caller
220    /// forwarding straight onto a sink rather than looping.
221    pub fn stream<F>(self, callback: F) -> Result<ProcessHandle, StreamError>
222    where
223        F: FnMut(Event) + Send + Sync + Clone + 'static,
224    {
225        spawn_streaming(self, callback)
226    }
227}
228
229
230#[derive(Debug)]
231struct HandleInner {
232    child: Mutex<Option<Child>>,
233    /// The child's stdin, when it was piped. Taken from the `Child` at spawn so
234    /// writing never has to lock the same mutex `cancel` uses.
235    stdin: Mutex<Option<std::process::ChildStdin>>,
236    cancelled: AtomicBool,
237}
238
239impl ProcessHandle {
240    /// SIGTERM the process, then SIGKILL after 1.5s if it's still alive.
241    /// The CLI is supposed to flush a final result on SIGTERM but we
242    /// don't trust it to do so forever.
243    ///
244    /// # Platform behaviour
245    ///
246    /// This ends **the process it spawned**, not that process's descendants.
247    /// On unix the distinction rarely bites: signalling a shell that `exec`ed
248    /// its payload reaches the payload, because they are the same process. On
249    /// Windows cancelling is `TerminateProcess`, which has no notion of a
250    /// process tree — so a child that spawned its own children leaves them
251    /// running, and because they inherited the stdout handle, the stream stays
252    /// open and no [`Event::Exited`] arrives. Killing a tree there needs a Job
253    /// Object, which this does not yet create.
254    pub fn cancel(&self) -> Result<(), StreamError> {
255        self.inner.cancelled.store(true, Ordering::SeqCst);
256        let mut guard = self
257            .inner
258            .child
259            .lock()
260            .map_err(|_| StreamError::CancelLockPoisoned)?;
261        let Some(child) = guard.as_mut() else {
262            // Already exited.
263            return Ok(());
264        };
265        // Best-effort SIGTERM. On Unix, kill() sends SIGKILL by default;
266        // we use libc::kill for SIGTERM, falling back to child.kill() if
267        // the libc call fails. On Windows there's only TerminateProcess
268        // via .kill().
269        #[cfg(unix)]
270        {
271            let pid = child.id() as i32;
272            // SAFETY: pid is the child's PID owned by this Child; sending
273            // SIGTERM is well-defined.
274            unsafe { libc::kill(pid, libc::SIGTERM) };
275            // Command the SIGKILL fallback inline to avoid holding the mutex
276            // while sleeping.
277            let inner = Arc::clone(&self.inner);
278            thread::spawn(move || {
279                thread::sleep(Duration::from_millis(1500));
280                if let Ok(mut guard) = inner.child.lock() {
281                    if let Some(child) = guard.as_mut() {
282                        let _ = child.kill();
283                    }
284                }
285            });
286        }
287        #[cfg(not(unix))]
288        {
289            let _ = child.kill();
290        }
291        Ok(())
292    }
293
294    /// Send one line to the child's stdin, newline-terminated and flushed.
295    ///
296    /// There is only one stream a caller can write to, so the name does not
297    /// repeat it — [`Stdin::Piped`] on the command is where that was said.
298    ///
299    /// `Err` when the child was spawned with [`Stdin::Closed`] (the default), or
300    /// when it has exited and the pipe is gone — both of which a caller
301    /// expecting an answer needs to hear about rather than block on.
302    pub fn write_line(&self, line: &str) -> Result<(), StreamError> {
303        self.write(line.as_bytes())?;
304        self.write(b"\n")
305    }
306
307    /// Send raw bytes to the child's stdin, flushed.
308    ///
309    /// [`write_line`](Self::write_line) covers newline-delimited protocols,
310    /// which most CLIs and MCP's stdio transport use. This is for the ones that
311    /// frame differently — LSP counts bytes in a `Content-Length` header, and a
312    /// stray newline there is a protocol error.
313    pub fn write(&self, bytes: &[u8]) -> Result<(), StreamError> {
314        let mut guard = self.inner.stdin.lock().map_err(|_| StreamError::CancelLockPoisoned)?;
315        let stdin = guard.as_mut().ok_or(StreamError::PipeNotCaptured { stream: "stdin" })?;
316        use std::io::Write;
317        stdin.write_all(bytes).and_then(|()| stdin.flush()).map_err(|source| StreamError::Write { source })
318    }
319
320    /// Whether `cancel()` was called. Tagged on the final `Exited` event.
321    pub fn was_cancelled(&self) -> bool {
322        self.inner.cancelled.load(Ordering::SeqCst)
323    }
324
325    /// The child's OS process id while it's alive, or `None` once it has been
326    /// reaped (the `Child` is taken on exit). Lets an embedder record the pid
327    /// so a child orphaned by a hard crash can be killed on the next launch.
328    pub fn pid(&self) -> Option<u32> {
329        self.inner
330            .child
331            .lock()
332            .ok()
333            .and_then(|guard| guard.as_ref().map(Child::id))
334    }
335}
336
337/// Command an arbitrary streaming child process — the generic engine behind
338/// every process-backed harness (bob, Claude Code, Codex).
339///
340/// Pipes stdout/stderr line-by-line through `callback` using the raw
341/// [`Event`] vocabulary (Started / Stdout / Stderr / Error /
342/// Exited). `env` supplies per-harness secrets (each harness's API-key
343/// var, or none for self-authenticating CLIs). PATH is augmented so
344/// Node-based CLIs find `node`. Returns a [`ProcessHandle`] for
345/// cancellation.
346///
347/// `callback` is invoked from three threads (stdout reader, stderr
348/// reader, exit watcher); the `Clone` bound lets us hand a copy to each.
349/// `run_id` is opaque — the caller chooses it and uses it to correlate
350/// events with the handle.
351///
352/// ```no_run
353/// use cli_stream::{Command, Event};
354///
355/// # fn main() -> Result<(), cli_stream::StreamError> {
356/// let handle = Command::new("echo").args(["hello"]).stream(|event| match event {
357///     Event::Stdout { line, .. } => println!("{line}"),
358///     Event::Exited { exit_code, .. } => eprintln!("exit {exit_code:?}"),
359///     _ => {}
360/// })?;
361/// // `handle.cancel()` stops it early; dropping the handle does not.
362/// let _ = handle;
363/// # Ok(())
364/// # }
365/// ```
366///
367pub(crate) fn spawn_streaming<F>(spawn: Command, callback: F) -> Result<ProcessHandle, StreamError>
368where
369    F: FnMut(Event) + Send + Sync + Clone + 'static,
370{
371    let Command { program, args, env, cwd, run_id, stdin, stderr, timeout } = spawn;
372    let mut command = hidden_command(&program);
373    command
374        .args(&args)
375        .current_dir(&cwd)
376        .stdin(match stdin {
377            Stdin::Closed => Stdio::null(),
378            Stdin::Piped => Stdio::piped(),
379        })
380        .stdout(Stdio::piped())
381        .stderr(match stderr {
382            Stderr::Streamed => Stdio::piped(),
383            Stderr::Discarded => Stdio::null(),
384        });
385    for (key, value) in &env {
386        command.env(key, value);
387    }
388    let mut child = command.spawn().map_err(|source| StreamError::Spawn {
389        program: program.display().to_string(),
390        source,
391    })?;
392
393    let stdout = child
394        .stdout
395        .take()
396        .ok_or(StreamError::PipeNotCaptured { stream: "stdout" })?;
397    // Absent by design when discarded — the OS is dropping it, so there is
398    // nothing to read and no thread to spend on reading it.
399    let stderr_pipe = child.stderr.take();
400
401    // Taken now so `write_line` never contends with `cancel` for the child.
402    let child_stdin = child.stdin.take();
403    let inner = Arc::new(HandleInner {
404        child: Mutex::new(Some(child)),
405        stdin: Mutex::new(child_stdin),
406        cancelled: AtomicBool::new(false),
407    });
408    let handle = ProcessHandle {
409        inner: Arc::clone(&inner),
410    };
411
412    // Emit Started immediately so the caller doesn't wait on the first
413    // output line for a UI signal.
414    let mut started_cb = callback.clone();
415    started_cb(Event::Started {
416        run_id: run_id.clone(),
417    });
418
419    // Reader threads. Each owns its own callback clone — the Clone bound
420    // is the whole point.
421    let stdout_cb = callback.clone();
422    let stdout_run_id = run_id.clone();
423    let stdout_handle = thread::spawn(move || {
424        pump_lines(stdout, stdout_run_id, true, stdout_cb);
425    });
426
427    let stderr_handle = stderr_pipe.map(|pipe| {
428        let stderr_cb = callback.clone();
429        let stderr_run_id = run_id.clone();
430        thread::spawn(move || pump_lines(pipe, stderr_run_id, false, stderr_cb))
431    });
432
433    // Exit watcher — emits the terminal Exited event with the cancellation
434    // flag. It must NOT hold the child lock across a blocking `wait()`:
435    // `cancel()` needs that same lock to signal the child, so a held lock
436    // would block cancel until the process exited on its own (defeating it).
437    // Instead poll `try_wait()`, locking only for each non-blocking check and
438    // releasing between polls so `cancel()` can acquire the lock mid-run.
439    let exit_inner = Arc::clone(&inner);
440    let timeout_handle = handle.clone();
441    let mut exit_cb = callback;
442    let exit_run_id = run_id;
443    thread::spawn(move || {
444        let started = std::time::Instant::now();
445        let wait_result = loop {
446            {
447                let mut guard = match exit_inner.child.lock() {
448                    Ok(guard) => guard,
449                    Err(_) => return, // poisoned — nothing safe to do
450                };
451                match guard.as_mut() {
452                    Some(child) => match child.try_wait() {
453                        Ok(Some(status)) => break Ok(status),
454                        Ok(None) => {} // still running; poll again
455                        Err(err) => break Err(err),
456                    },
457                    None => return, // already reaped
458                }
459            } // lock released before sleeping, so cancel() can acquire it
460            // A run nobody is watching still has to end. Cancelling rather than
461            // killing gives the child the same SIGTERM grace a user's stop
462            // would, and the exit reports `cancelled` so the caller can tell
463            // this apart from a child that finished on its own.
464            if timeout.is_some_and(|limit| started.elapsed() >= limit) {
465                let _ = timeout_handle.cancel();
466            }
467            thread::sleep(Duration::from_millis(50));
468        };
469        let _ = stdout_handle.join();
470        if let Some(stderr_handle) = stderr_handle {
471            let _ = stderr_handle.join();
472        }
473        let cancelled = exit_inner.cancelled.load(Ordering::SeqCst);
474
475        match wait_result {
476            Ok(status) => exit_cb(Event::Exited {
477                run_id: exit_run_id.clone(),
478                exit_code: status.code(),
479                cancelled,
480            }),
481            Err(err) => exit_cb(Event::Error {
482                run_id: exit_run_id.clone(),
483                message: format!("wait failed: {err}"),
484            }),
485        }
486
487        // Drop the child handle so subsequent cancel() calls
488        // short-circuit cleanly.
489        if let Ok(mut guard) = exit_inner.child.lock() {
490            *guard = None;
491        }
492    });
493
494    Ok(handle)
495}
496
497fn pump_lines<R, F>(reader: R, run_id: String, is_stdout: bool, mut callback: F)
498where
499    R: Read,
500    F: FnMut(Event),
501{
502    let mut buffered = BufReader::new(reader);
503    let mut bytes = Vec::new();
504    loop {
505        bytes.clear();
506        match buffered.read_until(b'\n', &mut bytes) {
507            Ok(0) => return,
508            Ok(_) => {
509                strip_eol(&mut bytes);
510                // Lossy on purpose. A child's stdout is a byte stream, and
511                // agent CLIs share it with progress bars, ANSI art and paths
512                // in whatever encoding the filesystem gave them. Decoding
513                // strictly makes one undecodable byte end the transcript,
514                // taking the result line with it.
515                let text = String::from_utf8_lossy(&bytes).into_owned();
516                let event = if is_stdout {
517                    Event::Stdout {
518                        run_id: run_id.clone(),
519                        line: text,
520                    }
521                } else {
522                    Event::Stderr {
523                        run_id: run_id.clone(),
524                        line: text,
525                    }
526                };
527                callback(event);
528            }
529            Err(err) => {
530                callback(Event::Error {
531                    run_id: run_id.clone(),
532                    message: format!("stream read failed: {err}"),
533                });
534                return;
535            }
536        }
537    }
538}
539
540/// Drop one trailing line terminator, `\n` or `\r\n`.
541fn strip_eol(bytes: &mut Vec<u8>) {
542    if bytes.last() == Some(&b'\n') {
543        bytes.pop();
544        if bytes.last() == Some(&b'\r') {
545            bytes.pop();
546        }
547    }
548}
549
550/// Compose a PATH for the spawned process that always includes the
551/// directory containing the program — where `node`, `npm`, and friends
552/// usually live in an nvm install. The user's existing PATH stays as a
553/// fallback after our prepended directory.
554/// A [`Command`] that never opens a console window on Windows.
555///
556/// A GUI host (a Tauri app, an IDE) spawning a console-subsystem CLI gets a
557/// black console flashed on screen for every agent run and every `--version`
558/// probe. `CREATE_NO_WINDOW` suppresses it. Use this in place of
559/// `Command::new` for anything a desktop app spawns; it is a plain
560/// `Command::new` on every other platform, so call sites stay `cfg`-free.
561/// Whether a line from a child suggests it wanted a terminal and did not get
562/// one.
563///
564/// Every child spawned here gets **pipes**, never a TTY, so `isatty` is false
565/// and a CLI may change what it prints or refuse to run. Most of the time that
566/// is welcome — no colour codes, no progress bars — but a CLI built around
567/// interactive prompts fails, and the message it gives is easy to miss among
568/// ordinary stderr.
569///
570/// Recognising it turns a confusing exit into a next step: run the CLI in
571/// whatever non-interactive mode it has (`--yes`, `-p`, `exec`, …).
572pub fn needs_terminal(line: &str) -> bool {
573    const SIGNS: &[&str] = &[
574        "not a tty",
575        "not a terminal",
576        "is not interactive",
577        "input device is not a tty",
578        "raw mode is not supported",
579        "non-tty environment",
580        "requires a tty",
581    ];
582    let lowered = line.to_lowercase();
583    SIGNS.iter().any(|sign| lowered.contains(sign))
584}
585
586pub fn hidden_command(program: impl AsRef<std::ffi::OsStr>) -> std::process::Command {
587    #[allow(unused_mut)]
588    let mut command = std::process::Command::new(program);
589    #[cfg(windows)]
590    {
591        use std::os::windows::process::CommandExt;
592        // https://learn.microsoft.com/windows/win32/procthread/process-creation-flags
593        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
594        command.creation_flags(CREATE_NO_WINDOW);
595    }
596    command
597}
598
599#[cfg(test)]
600mod tests {
601    use proptest::prelude::*;
602    use super::*;
603
604    /// Issue #35: a GUI host spawning a console-subsystem CLI flashed a console
605    /// window on Windows for every run and every `--version` probe. The flag is
606    /// Windows-only, so what is portable to assert is that the constructor is a
607    /// drop-in for `Command::new` — it still runs, and still captures output.
608    #[test]
609    fn hidden_command_runs_like_a_plain_command() {
610        let program = if cfg!(windows) { "cmd" } else { "echo" };
611        let args: &[&str] = if cfg!(windows) {
612            &["/C", "echo", "ok"]
613        } else {
614            &["ok"]
615        };
616        let out = hidden_command(program).args(args).output().unwrap();
617        assert!(out.status.success());
618        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok");
619    }
620
621    use std::sync::Condvar;
622    use std::time::Instant;
623
624    type Done = Arc<(Mutex<bool>, Condvar)>;
625
626    /// A thread-safe event collector that signals `done` on the terminal
627    /// event. Returns the (cloneable) callback + the shared collections.
628    fn collector() -> (
629        impl FnMut(Event) + Send + Sync + Clone + 'static,
630        Arc<Mutex<Vec<Event>>>,
631        Done,
632    ) {
633        let events = Arc::new(Mutex::new(Vec::new()));
634        let done: Done = Arc::new((Mutex::new(false), Condvar::new()));
635        let cb = {
636            let events = Arc::clone(&events);
637            let done = Arc::clone(&done);
638            move |ev: Event| {
639                let terminal =
640                    matches!(ev, Event::Exited { .. } | Event::Error { .. });
641                events.lock().unwrap().push(ev);
642                if terminal {
643                    let (lock, cvar) = &*done;
644                    *lock.lock().unwrap() = true;
645                    cvar.notify_all();
646                }
647            }
648        };
649        (cb, events, done)
650    }
651
652    /// Block until the terminal event fires, or panic after `secs`.
653    fn wait_done(done: &Done, secs: u64) {
654        let (lock, cvar) = &**done;
655        let mut finished = lock.lock().unwrap();
656        let deadline = Instant::now() + Duration::from_secs(secs);
657        while !*finished {
658            let now = Instant::now();
659            assert!(now < deadline, "process did not finish within {secs}s");
660            let (guard, _) = cvar.wait_timeout(finished, deadline - now).unwrap();
661            finished = guard;
662        }
663    }
664
665    /// Command `program args`, block until it exits, return every event.
666    fn run(program: &str, args: &[&str]) -> Vec<Event> {
667        let (cb, events, done) = collector();
668        let _handle = spawn_streaming(
669            Command::new(program).run_id("t").args(args.iter().copied()),
670            cb,
671        )
672        .expect("spawn");
673        wait_done(&done, 10);
674        let events = events.lock().unwrap();
675        events.clone()
676    }
677
678    /// Emit `alpha` and `beta` on separate lines. `printf` is not a program on
679    /// Windows, and the shell there does not read `%s\n` as a format — the
680    /// child printed `alphabeta` and the engine faithfully reported the one
681    /// line it was given.
682    fn two_lines() -> (&'static str, Vec<&'static str>) {
683        if cfg!(windows) {
684            ("cmd", vec!["/C", "echo alpha&echo beta"])
685        } else {
686            ("printf", vec!["%s\n", "alpha", "beta"])
687        }
688    }
689
690    #[test]
691    fn streams_stdout_lines_then_exits_zero() {
692        let (program, args) = two_lines();
693        let events = run(program, &args);
694        // Started leads, Exited(0, not cancelled) closes.
695        assert!(matches!(events.first(), Some(Event::Started { .. })));
696        assert!(matches!(
697            events.last(),
698            Some(Event::Exited {
699                exit_code: Some(0),
700                cancelled: false,
701                ..
702            })
703        ));
704        // Lines arrive in order, one event each.
705        let lines: Vec<&str> = events
706            .iter()
707            .filter_map(|e| match e {
708                Event::Stdout { line, .. } => Some(line.as_str()),
709                _ => None,
710            })
711            .collect();
712        assert_eq!(lines, vec!["alpha", "beta"]);
713    }
714
715    #[test]
716    fn nonzero_exit_code_is_reported() {
717        let events = run("sh", &["-c", "exit 3"]);
718        assert!(matches!(
719            events.last(),
720            Some(Event::Exited {
721                exit_code: Some(3),
722                cancelled: false,
723                ..
724            })
725        ));
726    }
727
728    #[test]
729    fn env_vars_are_passed_to_the_child() {
730        // The `env` argument must reach the child's environment — exercise it
731        // directly (the other lifecycle tests pass an empty env).
732        let (cb, events, done) = collector();
733        let _handle = spawn_streaming(
734            Command::new("sh").run_id("t").args(vec![
735                "-c".to_owned(),
736                "printf '%s\\n' \"$CLI_STREAM_STUB\"".to_owned(),
737            ]).env(vec![("CLI_STREAM_STUB".to_owned(), "from-env".to_owned())]),
738            cb,
739        )
740        .expect("spawn");
741        wait_done(&done, 10);
742        let events = events.lock().unwrap();
743        assert!(
744            events
745                .iter()
746                .any(|e| matches!(e, Event::Stdout { line, .. } if line == "from-env")),
747            "child should observe the injected env var, got {events:?}"
748        );
749    }
750
751    #[test]
752    fn stderr_is_streamed_and_not_misrouted_to_stdout() {
753        let events = run("sh", &["-c", "echo to-stderr 1>&2"]);
754        assert!(events
755            .iter()
756            .any(|e| matches!(e, Event::Stderr { line, .. } if line == "to-stderr")));
757        assert!(!events
758            .iter()
759            .any(|e| matches!(e, Event::Stdout { .. })));
760        assert!(events.iter().any(|e| matches!(
761            e,
762            Event::Exited {
763                exit_code: Some(0),
764                ..
765            }
766        )));
767    }
768
769    /// A single process that runs for ~10s and holds no children.
770    ///
771    /// The distinction matters to what cancelling can promise. On unix `exec`
772    /// makes the shell *become* `sleep`, so there is one process and SIGTERM
773    /// reaches it. Windows has no `exec` and cancelling is `TerminateProcess`,
774    /// which ends the process it names and not its descendants — so a shell
775    /// wrapper there would leave the sleeper running, holding the pipe open,
776    /// and no `Exited` would ever arrive. `ping` is the sleeper itself.
777    fn long_sleeper() -> (&'static str, Vec<&'static str>) {
778        if cfg!(windows) {
779            ("ping", vec!["-n", "11", "127.0.0.1"])
780        } else {
781            ("sh", vec!["-c", "exec sleep 10"])
782        }
783    }
784
785    #[test]
786    fn cancel_promptly_terminates_the_run_and_flags_it() {
787        // A 10s sleeper we cancel ~immediately; a working engine must kill it
788        // far sooner than 10s.
789        let (cb, events, done) = collector();
790        let (program, args) = long_sleeper();
791        let handle =
792            spawn_streaming(Command::new(program).run_id("t").args(args), cb).expect("spawn");
793
794        // cancel() may block until the child is reaped, so fire it off-thread.
795        let canceller = handle.clone();
796        thread::spawn(move || {
797            thread::sleep(Duration::from_millis(100));
798            let _ = canceller.cancel();
799        });
800
801        // Correct cancellation terminates the 10s sleep within a few seconds.
802        wait_done(&done, 4);
803        assert!(handle.was_cancelled());
804        let events = events.lock().unwrap();
805        assert!(
806            matches!(
807                events.last(),
808                Some(Event::Exited {
809                    cancelled: true,
810                    ..
811                })
812            ),
813            "expected Exited(cancelled=true), got {:?}",
814            events.last()
815        );
816    }
817
818    #[test]
819    fn a_cli_asking_for_a_terminal_is_recognised_however_it_phrases_it() {
820        // Children get pipes, never a TTY. When that is the problem, the CLI
821        // says so on stderr and the run otherwise looks like an unexplained
822        // failure — so the phrasings worth catching are the common ones.
823        for complaint in [
824            "Error: stdin is not a TTY",
825            "the input device is not a TTY",
826            "Raw mode is not supported on the current process.stdin",
827            "Prompts cannot be rendered in a non-TTY environment",
828            "this command requires a TTY",
829            "warning: stdout is not a terminal",
830        ] {
831            assert!(needs_terminal(complaint), "missed: {complaint}");
832        }
833
834        // And ordinary noise is left alone — mislabelling it would bury the
835        // real message under an explanation of the wrong problem.
836        for ordinary in ["npm WARN deprecated foo@1.0.0", "compiling 12 files", "", "tty"] {
837            assert!(!needs_terminal(ordinary), "false positive: {ordinary}");
838        }
839    }
840
841    #[cfg(unix)]
842    #[test]
843    fn a_timeout_stops_a_child_that_would_otherwise_run_forever() {
844        // Unattended runs have nobody to press stop. The child must end, and
845        // the exit has to say it was stopped rather than that it finished.
846        let started = Instant::now();
847        let (_handle, events) = Command::new("sleep")
848            .run_id("hung")
849            .args(["30"])
850            .timeout(Duration::from_millis(200))
851            .start()
852            .expect("spawn");
853
854        let exit = events
855            .into_iter()
856            .find_map(|e| match e {
857                Event::Exited { cancelled, .. } => Some(cancelled),
858                _ => None,
859            })
860            .expect("the run ends");
861        assert!(exit, "a timed-out run reports as cancelled, not as a clean finish");
862        assert!(started.elapsed() < Duration::from_secs(10), "and does not wait out the sleep");
863    }
864
865    #[cfg(unix)]
866    #[test]
867    fn a_run_inside_its_timeout_is_untouched() {
868        let (_handle, events) = Command::new("echo")
869            .run_id("quick")
870            .args(["done"])
871            .timeout(Duration::from_secs(30))
872            .start()
873            .expect("spawn");
874        let seen: Vec<Event> = events.into_iter().collect();
875        assert!(seen.iter().any(|e| matches!(e, Event::Stdout { line, .. } if line == "done")));
876        assert!(
877            seen.iter().any(|e| matches!(e, Event::Exited { cancelled: false, .. })),
878            "finished on its own: {seen:?}"
879        );
880    }
881
882    #[cfg(unix)]
883    #[test]
884    fn discarded_stderr_never_reaches_the_caller() {
885        // A server whose stderr is its own logging should cost nothing: the OS
886        // drops it, so there is no pipe to fill and no thread reading it.
887        let noisy = "echo out; echo noise 1>&2";
888        let (_h, events) = Command::new("sh")
889            .run_id("quiet")
890            .args(["-c", noisy])
891            .stderr(Stderr::Discarded)
892            .start()
893            .expect("spawn");
894        let seen: Vec<Event> = events.into_iter().collect();
895        assert!(seen.iter().any(|e| matches!(e, Event::Stdout { line, .. } if line == "out")));
896        assert!(!seen.iter().any(|e| matches!(e, Event::Stderr { .. })), "got {seen:?}");
897
898        // And streamed is still the default.
899        let (_h, events) = Command::new("sh").run_id("loud").args(["-c", noisy]).start().expect("spawn");
900        assert!(events.into_iter().any(|e| matches!(e, Event::Stderr { line, .. } if line == "noise")));
901    }
902
903    #[cfg(unix)]
904    #[test]
905    fn writing_needs_a_pipe_that_was_asked_for_and_a_child_still_listening() {
906        // Both failures are ones a caller waiting on an answer has to hear
907        // about: without them it blocks forever on a reply that is not coming.
908        let quiet = Command::new("sleep").run_id("nostdin").args(["5"]).stream(|_| {}).expect("spawn");
909        let err = quiet.write_line("anyone there?").unwrap_err();
910        assert!(
911            matches!(err, StreamError::PipeNotCaptured { stream: "stdin" }),
912            "stdin was never piped, got {err}"
913        );
914        let _ = quiet.cancel();
915
916        // `cat` echoes stdin, so it is listening until it is not.
917        let (handle, events) =
918            Command::new("cat").run_id("echoing").stdin(Stdin::Piped).start().expect("spawn");
919        handle.write_line("hello").expect("a live child takes input");
920
921        // Waited for with a deadline, not `events.iter()`. `cat` holds the
922        // channel open for as long as it lives, so iterating blocks once the
923        // queue drains — a version of this test that scanned for the line only
924        // ever terminated *because* it was there, and hung on the failure it
925        // exists to report.
926        let deadline = Instant::now() + Duration::from_secs(5);
927        let echoed = loop {
928            let left = deadline
929                .checked_duration_since(Instant::now())
930                .expect("the child never echoed the line back");
931            match events.recv_timeout(left) {
932                Ok(Event::Stdout { line, .. }) => break line,
933                Ok(_) => continue,
934                Err(err) => panic!("nothing came back: {err}"),
935            }
936        };
937        assert_eq!(echoed, "hello", "and reads it back");
938        let _ = handle.cancel();
939    }
940
941    #[cfg(unix)]
942    #[test]
943    fn a_live_child_reports_a_pid_and_flips_when_cancelled() {
944        // An embedder records the pid so a child a hard crash orphaned can be
945        // reaped on the next launch, and reads `was_cancelled` to tell a run
946        // the user stopped from one that finished. Both are answered by
947        // forwarding, which is exactly the kind of code that silently returns
948        // the wrong constant.
949        let handle = spawn_streaming(
950            Command::new("/bin/sleep").cwd(std::env::temp_dir()).run_id("pid").args(["30"]),
951            |_| {},
952        )
953        .expect("sleep should spawn");
954
955        let pid = handle.pid().expect("a live child has a pid");
956        assert!(pid > 1, "a real OS pid, not a placeholder: {pid}");
957        assert!(!handle.was_cancelled(), "nothing has stopped it yet");
958
959        handle.cancel().expect("cancel");
960        assert!(handle.was_cancelled(), "a stopped run says so");
961    }
962
963    #[test]
964    fn spawning_a_missing_binary_is_err() {
965        let result = spawn_streaming(
966            Command::new("cli-stream-no-such-binary-zzz").run_id("t"),
967            |_ev: Event| {},
968        );
969        // Typed: a `Spawn` error carrying the OS `NotFound` io::Error as its
970        // source — the whole point of `StreamError` over a `String`. A caller
971        // can branch on `ErrorKind` to tell "not installed" (NotFound) from
972        // "permission denied", which a flattened string can't support.
973        match result {
974            Err(StreamError::Spawn { program, source }) => {
975                assert!(program.contains("cli-stream-no-such-binary-zzz"));
976                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
977            }
978            other => panic!("expected StreamError::Spawn, got {other:?}"),
979        }
980    }
981
982    fn pumped(bytes: &[u8]) -> Vec<Event> {
983        let mut events = Vec::new();
984        pump_lines(bytes, "t".to_owned(), true, |event| events.push(event));
985        events
986    }
987
988    fn lines_of(events: &[Event]) -> Vec<String> {
989        events
990            .iter()
991            .filter_map(|event| match event {
992                Event::Stdout { line, .. } => Some(line.clone()),
993                _ => None,
994            })
995            .collect()
996    }
997
998    #[test]
999    fn one_undecodable_byte_does_not_cost_us_the_rest_of_the_run() {
1000        // Agent CLIs write progress bars, ANSI art and the occasional raw byte
1001        // to the same pipe they write results to. A stream is a byte stream,
1002        // so the only safe reading is that a line we cannot decode is one
1003        // damaged line — not the end of the transcript.
1004        let mut bytes = b"first\n".to_vec();
1005        bytes.extend_from_slice(&[0xff, 0xfe]);
1006        bytes.extend_from_slice(b"\nlast\n");
1007
1008        let lines = lines_of(&pumped(&bytes));
1009
1010        assert_eq!(lines.first().map(String::as_str), Some("first"));
1011        assert_eq!(
1012            lines.last().map(String::as_str),
1013            Some("last"),
1014            "a line after the bad byte still arrives"
1015        );
1016        assert_eq!(lines.len(), 3, "the damaged line is kept, lossily");
1017    }
1018
1019    /// Bytes shaped like a real child's stdout: mostly text, plenty of line
1020    /// terminators, and the high bytes that are never valid UTF-8 alone.
1021    /// Uniform `Vec<u8>` would hit `\n` once every 256 bytes and barely
1022    /// exercise the framing this is here to check.
1023    fn stream_bytes() -> impl Strategy<Value = Vec<u8>> {
1024        prop::collection::vec(
1025            prop_oneof![
1026                6 => 0x20u8..0x7f,
1027                3 => Just(b'\n'),
1028                1 => Just(b'\r'),
1029                2 => 0x80u8..=0xff,
1030            ],
1031            0..64,
1032        )
1033    }
1034
1035    fn line_count(bytes: &[u8]) -> usize {
1036        if bytes.is_empty() {
1037            return 0;
1038        }
1039        let newlines = bytes.iter().filter(|byte| **byte == b'\n').count();
1040        newlines + usize::from(bytes.last() != Some(&b'\n'))
1041    }
1042
1043    proptest! {
1044        /// Framing is a question about newlines, so it cannot depend on whether
1045        /// the bytes between them decode. This is the property the lossy fix is
1046        /// really about: strict decoding satisfied it only for valid UTF-8.
1047        #[test]
1048        fn every_line_the_child_wrote_is_one_the_caller_sees(bytes in stream_bytes()) {
1049            let events = pumped(&bytes);
1050            prop_assert_eq!(lines_of(&events).len(), line_count(&bytes));
1051            prop_assert!(
1052                !events.iter().any(|event| matches!(event, Event::Error { .. })),
1053                "no byte sequence is a read failure",
1054            );
1055        }
1056
1057        /// A line never carries the delimiter that ended it. Only `\n`
1058        /// delimits: a bare `\r` is content — it is how a progress bar
1059        /// overwrites itself — and is stripped only as part of a `\r\n` pair.
1060        #[test]
1061        fn no_line_smuggles_its_delimiter(bytes in stream_bytes()) {
1062            for line in lines_of(&pumped(&bytes)) {
1063                prop_assert!(!line.contains('\n'), "got {line:?}");
1064            }
1065        }
1066
1067        /// And for text, lossiness costs nothing: what the child wrote is
1068        /// exactly what the caller reads.
1069        #[test]
1070        fn text_arrives_unchanged(lines in prop::collection::vec("[^\r\n]{0,24}", 0..8)) {
1071            let written: String = lines.iter().map(|line| format!("{line}\n")).collect();
1072            prop_assert_eq!(lines_of(&pumped(written.as_bytes())), lines);
1073        }
1074    }
1075}