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    pub fn cancel(&self) -> Result<(), StreamError> {
244        self.inner.cancelled.store(true, Ordering::SeqCst);
245        let mut guard = self
246            .inner
247            .child
248            .lock()
249            .map_err(|_| StreamError::CancelLockPoisoned)?;
250        let Some(child) = guard.as_mut() else {
251            // Already exited.
252            return Ok(());
253        };
254        // Best-effort SIGTERM. On Unix, kill() sends SIGKILL by default;
255        // we use libc::kill for SIGTERM, falling back to child.kill() if
256        // the libc call fails. On Windows there's only TerminateProcess
257        // via .kill().
258        #[cfg(unix)]
259        {
260            let pid = child.id() as i32;
261            // SAFETY: pid is the child's PID owned by this Child; sending
262            // SIGTERM is well-defined.
263            unsafe { libc::kill(pid, libc::SIGTERM) };
264            // Command the SIGKILL fallback inline to avoid holding the mutex
265            // while sleeping.
266            let inner = Arc::clone(&self.inner);
267            thread::spawn(move || {
268                thread::sleep(Duration::from_millis(1500));
269                if let Ok(mut guard) = inner.child.lock() {
270                    if let Some(child) = guard.as_mut() {
271                        let _ = child.kill();
272                    }
273                }
274            });
275        }
276        #[cfg(not(unix))]
277        {
278            let _ = child.kill();
279        }
280        Ok(())
281    }
282
283    /// Send one line to the child's stdin, newline-terminated and flushed.
284    ///
285    /// There is only one stream a caller can write to, so the name does not
286    /// repeat it — [`Stdin::Piped`] on the command is where that was said.
287    ///
288    /// `Err` when the child was spawned with [`Stdin::Closed`] (the default), or
289    /// when it has exited and the pipe is gone — both of which a caller
290    /// expecting an answer needs to hear about rather than block on.
291    pub fn write_line(&self, line: &str) -> Result<(), StreamError> {
292        self.write(line.as_bytes())?;
293        self.write(b"\n")
294    }
295
296    /// Send raw bytes to the child's stdin, flushed.
297    ///
298    /// [`write_line`](Self::write_line) covers newline-delimited protocols,
299    /// which most CLIs and MCP's stdio transport use. This is for the ones that
300    /// frame differently — LSP counts bytes in a `Content-Length` header, and a
301    /// stray newline there is a protocol error.
302    pub fn write(&self, bytes: &[u8]) -> Result<(), StreamError> {
303        let mut guard = self.inner.stdin.lock().map_err(|_| StreamError::CancelLockPoisoned)?;
304        let stdin = guard.as_mut().ok_or(StreamError::PipeNotCaptured { stream: "stdin" })?;
305        use std::io::Write;
306        stdin.write_all(bytes).and_then(|()| stdin.flush()).map_err(|source| StreamError::Write { source })
307    }
308
309    /// Whether `cancel()` was called. Tagged on the final `Exited` event.
310    pub fn was_cancelled(&self) -> bool {
311        self.inner.cancelled.load(Ordering::SeqCst)
312    }
313
314    /// The child's OS process id while it's alive, or `None` once it has been
315    /// reaped (the `Child` is taken on exit). Lets an embedder record the pid
316    /// so a child orphaned by a hard crash can be killed on the next launch.
317    pub fn pid(&self) -> Option<u32> {
318        self.inner
319            .child
320            .lock()
321            .ok()
322            .and_then(|guard| guard.as_ref().map(Child::id))
323    }
324}
325
326/// Command an arbitrary streaming child process — the generic engine behind
327/// every process-backed harness (bob, Claude Code, Codex).
328///
329/// Pipes stdout/stderr line-by-line through `callback` using the raw
330/// [`Event`] vocabulary (Started / Stdout / Stderr / Error /
331/// Exited). `env` supplies per-harness secrets (each harness's API-key
332/// var, or none for self-authenticating CLIs). PATH is augmented so
333/// Node-based CLIs find `node`. Returns a [`ProcessHandle`] for
334/// cancellation.
335///
336/// `callback` is invoked from three threads (stdout reader, stderr
337/// reader, exit watcher); the `Clone` bound lets us hand a copy to each.
338/// `run_id` is opaque — the caller chooses it and uses it to correlate
339/// events with the handle.
340///
341/// ```no_run
342/// use cli_stream::{Command, Event};
343///
344/// # fn main() -> Result<(), cli_stream::StreamError> {
345/// let handle = Command::new("echo").args(["hello"]).stream(|event| match event {
346///     Event::Stdout { line, .. } => println!("{line}"),
347///     Event::Exited { exit_code, .. } => eprintln!("exit {exit_code:?}"),
348///     _ => {}
349/// })?;
350/// // `handle.cancel()` stops it early; dropping the handle does not.
351/// let _ = handle;
352/// # Ok(())
353/// # }
354/// ```
355///
356pub(crate) fn spawn_streaming<F>(spawn: Command, callback: F) -> Result<ProcessHandle, StreamError>
357where
358    F: FnMut(Event) + Send + Sync + Clone + 'static,
359{
360    let Command { program, args, env, cwd, run_id, stdin, stderr, timeout } = spawn;
361    let mut command = hidden_command(&program);
362    command
363        .args(&args)
364        .current_dir(&cwd)
365        .stdin(match stdin {
366            Stdin::Closed => Stdio::null(),
367            Stdin::Piped => Stdio::piped(),
368        })
369        .stdout(Stdio::piped())
370        .stderr(match stderr {
371            Stderr::Streamed => Stdio::piped(),
372            Stderr::Discarded => Stdio::null(),
373        });
374    for (key, value) in &env {
375        command.env(key, value);
376    }
377    let mut child = command.spawn().map_err(|source| StreamError::Spawn {
378        program: program.display().to_string(),
379        source,
380    })?;
381
382    let stdout = child
383        .stdout
384        .take()
385        .ok_or(StreamError::PipeNotCaptured { stream: "stdout" })?;
386    // Absent by design when discarded — the OS is dropping it, so there is
387    // nothing to read and no thread to spend on reading it.
388    let stderr_pipe = child.stderr.take();
389
390    // Taken now so `write_line` never contends with `cancel` for the child.
391    let child_stdin = child.stdin.take();
392    let inner = Arc::new(HandleInner {
393        child: Mutex::new(Some(child)),
394        stdin: Mutex::new(child_stdin),
395        cancelled: AtomicBool::new(false),
396    });
397    let handle = ProcessHandle {
398        inner: Arc::clone(&inner),
399    };
400
401    // Emit Started immediately so the caller doesn't wait on the first
402    // output line for a UI signal.
403    let mut started_cb = callback.clone();
404    started_cb(Event::Started {
405        run_id: run_id.clone(),
406    });
407
408    // Reader threads. Each owns its own callback clone — the Clone bound
409    // is the whole point.
410    let stdout_cb = callback.clone();
411    let stdout_run_id = run_id.clone();
412    let stdout_handle = thread::spawn(move || {
413        pump_lines(stdout, stdout_run_id, true, stdout_cb);
414    });
415
416    let stderr_handle = stderr_pipe.map(|pipe| {
417        let stderr_cb = callback.clone();
418        let stderr_run_id = run_id.clone();
419        thread::spawn(move || pump_lines(pipe, stderr_run_id, false, stderr_cb))
420    });
421
422    // Exit watcher — emits the terminal Exited event with the cancellation
423    // flag. It must NOT hold the child lock across a blocking `wait()`:
424    // `cancel()` needs that same lock to signal the child, so a held lock
425    // would block cancel until the process exited on its own (defeating it).
426    // Instead poll `try_wait()`, locking only for each non-blocking check and
427    // releasing between polls so `cancel()` can acquire the lock mid-run.
428    let exit_inner = Arc::clone(&inner);
429    let timeout_handle = handle.clone();
430    let mut exit_cb = callback;
431    let exit_run_id = run_id;
432    thread::spawn(move || {
433        let started = std::time::Instant::now();
434        let wait_result = loop {
435            {
436                let mut guard = match exit_inner.child.lock() {
437                    Ok(guard) => guard,
438                    Err(_) => return, // poisoned — nothing safe to do
439                };
440                match guard.as_mut() {
441                    Some(child) => match child.try_wait() {
442                        Ok(Some(status)) => break Ok(status),
443                        Ok(None) => {} // still running; poll again
444                        Err(err) => break Err(err),
445                    },
446                    None => return, // already reaped
447                }
448            } // lock released before sleeping, so cancel() can acquire it
449            // A run nobody is watching still has to end. Cancelling rather than
450            // killing gives the child the same SIGTERM grace a user's stop
451            // would, and the exit reports `cancelled` so the caller can tell
452            // this apart from a child that finished on its own.
453            if timeout.is_some_and(|limit| started.elapsed() >= limit) {
454                let _ = timeout_handle.cancel();
455            }
456            thread::sleep(Duration::from_millis(50));
457        };
458        let _ = stdout_handle.join();
459        if let Some(stderr_handle) = stderr_handle {
460            let _ = stderr_handle.join();
461        }
462        let cancelled = exit_inner.cancelled.load(Ordering::SeqCst);
463
464        match wait_result {
465            Ok(status) => exit_cb(Event::Exited {
466                run_id: exit_run_id.clone(),
467                exit_code: status.code(),
468                cancelled,
469            }),
470            Err(err) => exit_cb(Event::Error {
471                run_id: exit_run_id.clone(),
472                message: format!("wait failed: {err}"),
473            }),
474        }
475
476        // Drop the child handle so subsequent cancel() calls
477        // short-circuit cleanly.
478        if let Ok(mut guard) = exit_inner.child.lock() {
479            *guard = None;
480        }
481    });
482
483    Ok(handle)
484}
485
486fn pump_lines<R, F>(reader: R, run_id: String, is_stdout: bool, mut callback: F)
487where
488    R: Read,
489    F: FnMut(Event),
490{
491    let mut buffered = BufReader::new(reader);
492    let mut bytes = Vec::new();
493    loop {
494        bytes.clear();
495        match buffered.read_until(b'\n', &mut bytes) {
496            Ok(0) => return,
497            Ok(_) => {
498                strip_eol(&mut bytes);
499                // Lossy on purpose. A child's stdout is a byte stream, and
500                // agent CLIs share it with progress bars, ANSI art and paths
501                // in whatever encoding the filesystem gave them. Decoding
502                // strictly makes one undecodable byte end the transcript,
503                // taking the result line with it.
504                let text = String::from_utf8_lossy(&bytes).into_owned();
505                let event = if is_stdout {
506                    Event::Stdout {
507                        run_id: run_id.clone(),
508                        line: text,
509                    }
510                } else {
511                    Event::Stderr {
512                        run_id: run_id.clone(),
513                        line: text,
514                    }
515                };
516                callback(event);
517            }
518            Err(err) => {
519                callback(Event::Error {
520                    run_id: run_id.clone(),
521                    message: format!("stream read failed: {err}"),
522                });
523                return;
524            }
525        }
526    }
527}
528
529/// Drop one trailing line terminator, `\n` or `\r\n`.
530fn strip_eol(bytes: &mut Vec<u8>) {
531    if bytes.last() == Some(&b'\n') {
532        bytes.pop();
533        if bytes.last() == Some(&b'\r') {
534            bytes.pop();
535        }
536    }
537}
538
539/// Compose a PATH for the spawned process that always includes the
540/// directory containing the program — where `node`, `npm`, and friends
541/// usually live in an nvm install. The user's existing PATH stays as a
542/// fallback after our prepended directory.
543/// A [`Command`] that never opens a console window on Windows.
544///
545/// A GUI host (a Tauri app, an IDE) spawning a console-subsystem CLI gets a
546/// black console flashed on screen for every agent run and every `--version`
547/// probe. `CREATE_NO_WINDOW` suppresses it. Use this in place of
548/// `Command::new` for anything a desktop app spawns; it is a plain
549/// `Command::new` on every other platform, so call sites stay `cfg`-free.
550/// Whether a line from a child suggests it wanted a terminal and did not get
551/// one.
552///
553/// Every child spawned here gets **pipes**, never a TTY, so `isatty` is false
554/// and a CLI may change what it prints or refuse to run. Most of the time that
555/// is welcome — no colour codes, no progress bars — but a CLI built around
556/// interactive prompts fails, and the message it gives is easy to miss among
557/// ordinary stderr.
558///
559/// Recognising it turns a confusing exit into a next step: run the CLI in
560/// whatever non-interactive mode it has (`--yes`, `-p`, `exec`, …).
561pub fn needs_terminal(line: &str) -> bool {
562    const SIGNS: &[&str] = &[
563        "not a tty",
564        "not a terminal",
565        "is not interactive",
566        "input device is not a tty",
567        "raw mode is not supported",
568        "non-tty environment",
569        "requires a tty",
570    ];
571    let lowered = line.to_lowercase();
572    SIGNS.iter().any(|sign| lowered.contains(sign))
573}
574
575pub fn hidden_command(program: impl AsRef<std::ffi::OsStr>) -> std::process::Command {
576    #[allow(unused_mut)]
577    let mut command = std::process::Command::new(program);
578    #[cfg(windows)]
579    {
580        use std::os::windows::process::CommandExt;
581        // https://learn.microsoft.com/windows/win32/procthread/process-creation-flags
582        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
583        command.creation_flags(CREATE_NO_WINDOW);
584    }
585    command
586}
587
588#[cfg(test)]
589mod tests {
590    use proptest::prelude::*;
591    use super::*;
592
593    /// Issue #35: a GUI host spawning a console-subsystem CLI flashed a console
594    /// window on Windows for every run and every `--version` probe. The flag is
595    /// Windows-only, so what is portable to assert is that the constructor is a
596    /// drop-in for `Command::new` — it still runs, and still captures output.
597    #[test]
598    fn hidden_command_runs_like_a_plain_command() {
599        let program = if cfg!(windows) { "cmd" } else { "echo" };
600        let args: &[&str] = if cfg!(windows) {
601            &["/C", "echo", "ok"]
602        } else {
603            &["ok"]
604        };
605        let out = hidden_command(program).args(args).output().unwrap();
606        assert!(out.status.success());
607        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok");
608    }
609
610    use std::sync::Condvar;
611    use std::time::Instant;
612
613    type Done = Arc<(Mutex<bool>, Condvar)>;
614
615    /// A thread-safe event collector that signals `done` on the terminal
616    /// event. Returns the (cloneable) callback + the shared collections.
617    fn collector() -> (
618        impl FnMut(Event) + Send + Sync + Clone + 'static,
619        Arc<Mutex<Vec<Event>>>,
620        Done,
621    ) {
622        let events = Arc::new(Mutex::new(Vec::new()));
623        let done: Done = Arc::new((Mutex::new(false), Condvar::new()));
624        let cb = {
625            let events = Arc::clone(&events);
626            let done = Arc::clone(&done);
627            move |ev: Event| {
628                let terminal =
629                    matches!(ev, Event::Exited { .. } | Event::Error { .. });
630                events.lock().unwrap().push(ev);
631                if terminal {
632                    let (lock, cvar) = &*done;
633                    *lock.lock().unwrap() = true;
634                    cvar.notify_all();
635                }
636            }
637        };
638        (cb, events, done)
639    }
640
641    /// Block until the terminal event fires, or panic after `secs`.
642    fn wait_done(done: &Done, secs: u64) {
643        let (lock, cvar) = &**done;
644        let mut finished = lock.lock().unwrap();
645        let deadline = Instant::now() + Duration::from_secs(secs);
646        while !*finished {
647            let now = Instant::now();
648            assert!(now < deadline, "process did not finish within {secs}s");
649            let (guard, _) = cvar.wait_timeout(finished, deadline - now).unwrap();
650            finished = guard;
651        }
652    }
653
654    /// Command `program args`, block until it exits, return every event.
655    fn run(program: &str, args: &[&str]) -> Vec<Event> {
656        let (cb, events, done) = collector();
657        let _handle = spawn_streaming(
658            Command::new(program).run_id("t").args(args.iter().copied()),
659            cb,
660        )
661        .expect("spawn");
662        wait_done(&done, 10);
663        let events = events.lock().unwrap();
664        events.clone()
665    }
666
667    #[test]
668    fn streams_stdout_lines_then_exits_zero() {
669        let events = run("printf", &["%s\n", "alpha", "beta"]);
670        // Started leads, Exited(0, not cancelled) closes.
671        assert!(matches!(events.first(), Some(Event::Started { .. })));
672        assert!(matches!(
673            events.last(),
674            Some(Event::Exited {
675                exit_code: Some(0),
676                cancelled: false,
677                ..
678            })
679        ));
680        // Lines arrive in order, one event each.
681        let lines: Vec<&str> = events
682            .iter()
683            .filter_map(|e| match e {
684                Event::Stdout { line, .. } => Some(line.as_str()),
685                _ => None,
686            })
687            .collect();
688        assert_eq!(lines, vec!["alpha", "beta"]);
689    }
690
691    #[test]
692    fn nonzero_exit_code_is_reported() {
693        let events = run("sh", &["-c", "exit 3"]);
694        assert!(matches!(
695            events.last(),
696            Some(Event::Exited {
697                exit_code: Some(3),
698                cancelled: false,
699                ..
700            })
701        ));
702    }
703
704    #[test]
705    fn env_vars_are_passed_to_the_child() {
706        // The `env` argument must reach the child's environment — exercise it
707        // directly (the other lifecycle tests pass an empty env).
708        let (cb, events, done) = collector();
709        let _handle = spawn_streaming(
710            Command::new("sh").run_id("t").args(vec![
711                "-c".to_owned(),
712                "printf '%s\\n' \"$CLI_STREAM_STUB\"".to_owned(),
713            ]).env(vec![("CLI_STREAM_STUB".to_owned(), "from-env".to_owned())]),
714            cb,
715        )
716        .expect("spawn");
717        wait_done(&done, 10);
718        let events = events.lock().unwrap();
719        assert!(
720            events
721                .iter()
722                .any(|e| matches!(e, Event::Stdout { line, .. } if line == "from-env")),
723            "child should observe the injected env var, got {events:?}"
724        );
725    }
726
727    #[test]
728    fn stderr_is_streamed_and_not_misrouted_to_stdout() {
729        let events = run("sh", &["-c", "echo to-stderr 1>&2"]);
730        assert!(events
731            .iter()
732            .any(|e| matches!(e, Event::Stderr { line, .. } if line == "to-stderr")));
733        assert!(!events
734            .iter()
735            .any(|e| matches!(e, Event::Stdout { .. })));
736        assert!(events.iter().any(|e| matches!(
737            e,
738            Event::Exited {
739                exit_code: Some(0),
740                ..
741            }
742        )));
743    }
744
745    #[test]
746    fn cancel_promptly_terminates_the_run_and_flags_it() {
747        // A 10s sleeper we cancel ~immediately; a working engine must kill it
748        // far sooner than 10s. `exec` so the process *is* sleep (no orphan).
749        let (cb, events, done) = collector();
750        let handle = spawn_streaming(
751            Command::new("sh").run_id("t").args(["-c", "exec sleep 10"]),
752            cb,
753        )
754        .expect("spawn");
755
756        // cancel() may block until the child is reaped, so fire it off-thread.
757        let canceller = handle.clone();
758        thread::spawn(move || {
759            thread::sleep(Duration::from_millis(100));
760            let _ = canceller.cancel();
761        });
762
763        // Correct cancellation terminates the 10s sleep within a few seconds.
764        wait_done(&done, 4);
765        assert!(handle.was_cancelled());
766        let events = events.lock().unwrap();
767        assert!(
768            matches!(
769                events.last(),
770                Some(Event::Exited {
771                    cancelled: true,
772                    ..
773                })
774            ),
775            "expected Exited(cancelled=true), got {:?}",
776            events.last()
777        );
778    }
779
780    #[test]
781    fn a_cli_asking_for_a_terminal_is_recognised_however_it_phrases_it() {
782        // Children get pipes, never a TTY. When that is the problem, the CLI
783        // says so on stderr and the run otherwise looks like an unexplained
784        // failure — so the phrasings worth catching are the common ones.
785        for complaint in [
786            "Error: stdin is not a TTY",
787            "the input device is not a TTY",
788            "Raw mode is not supported on the current process.stdin",
789            "Prompts cannot be rendered in a non-TTY environment",
790            "this command requires a TTY",
791            "warning: stdout is not a terminal",
792        ] {
793            assert!(needs_terminal(complaint), "missed: {complaint}");
794        }
795
796        // And ordinary noise is left alone — mislabelling it would bury the
797        // real message under an explanation of the wrong problem.
798        for ordinary in ["npm WARN deprecated foo@1.0.0", "compiling 12 files", "", "tty"] {
799            assert!(!needs_terminal(ordinary), "false positive: {ordinary}");
800        }
801    }
802
803    #[cfg(unix)]
804    #[test]
805    fn a_timeout_stops_a_child_that_would_otherwise_run_forever() {
806        // Unattended runs have nobody to press stop. The child must end, and
807        // the exit has to say it was stopped rather than that it finished.
808        let started = Instant::now();
809        let (_handle, events) = Command::new("sleep")
810            .run_id("hung")
811            .args(["30"])
812            .timeout(Duration::from_millis(200))
813            .start()
814            .expect("spawn");
815
816        let exit = events
817            .into_iter()
818            .find_map(|e| match e {
819                Event::Exited { cancelled, .. } => Some(cancelled),
820                _ => None,
821            })
822            .expect("the run ends");
823        assert!(exit, "a timed-out run reports as cancelled, not as a clean finish");
824        assert!(started.elapsed() < Duration::from_secs(10), "and does not wait out the sleep");
825    }
826
827    #[cfg(unix)]
828    #[test]
829    fn a_run_inside_its_timeout_is_untouched() {
830        let (_handle, events) = Command::new("echo")
831            .run_id("quick")
832            .args(["done"])
833            .timeout(Duration::from_secs(30))
834            .start()
835            .expect("spawn");
836        let seen: Vec<Event> = events.into_iter().collect();
837        assert!(seen.iter().any(|e| matches!(e, Event::Stdout { line, .. } if line == "done")));
838        assert!(
839            seen.iter().any(|e| matches!(e, Event::Exited { cancelled: false, .. })),
840            "finished on its own: {seen:?}"
841        );
842    }
843
844    #[cfg(unix)]
845    #[test]
846    fn discarded_stderr_never_reaches_the_caller() {
847        // A server whose stderr is its own logging should cost nothing: the OS
848        // drops it, so there is no pipe to fill and no thread reading it.
849        let noisy = "echo out; echo noise 1>&2";
850        let (_h, events) = Command::new("sh")
851            .run_id("quiet")
852            .args(["-c", noisy])
853            .stderr(Stderr::Discarded)
854            .start()
855            .expect("spawn");
856        let seen: Vec<Event> = events.into_iter().collect();
857        assert!(seen.iter().any(|e| matches!(e, Event::Stdout { line, .. } if line == "out")));
858        assert!(!seen.iter().any(|e| matches!(e, Event::Stderr { .. })), "got {seen:?}");
859
860        // And streamed is still the default.
861        let (_h, events) = Command::new("sh").run_id("loud").args(["-c", noisy]).start().expect("spawn");
862        assert!(events.into_iter().any(|e| matches!(e, Event::Stderr { line, .. } if line == "noise")));
863    }
864
865    #[cfg(unix)]
866    #[test]
867    fn writing_needs_a_pipe_that_was_asked_for_and_a_child_still_listening() {
868        // Both failures are ones a caller waiting on an answer has to hear
869        // about: without them it blocks forever on a reply that is not coming.
870        let quiet = Command::new("sleep").run_id("nostdin").args(["5"]).stream(|_| {}).expect("spawn");
871        let err = quiet.write_line("anyone there?").unwrap_err();
872        assert!(
873            matches!(err, StreamError::PipeNotCaptured { stream: "stdin" }),
874            "stdin was never piped, got {err}"
875        );
876        let _ = quiet.cancel();
877
878        // `cat` echoes stdin, so it is listening until it is not.
879        let (handle, events) =
880            Command::new("cat").run_id("echoing").stdin(Stdin::Piped).start().expect("spawn");
881        handle.write_line("hello").expect("a live child takes input");
882
883        // Waited for with a deadline, not `events.iter()`. `cat` holds the
884        // channel open for as long as it lives, so iterating blocks once the
885        // queue drains — a version of this test that scanned for the line only
886        // ever terminated *because* it was there, and hung on the failure it
887        // exists to report.
888        let deadline = Instant::now() + Duration::from_secs(5);
889        let echoed = loop {
890            let left = deadline
891                .checked_duration_since(Instant::now())
892                .expect("the child never echoed the line back");
893            match events.recv_timeout(left) {
894                Ok(Event::Stdout { line, .. }) => break line,
895                Ok(_) => continue,
896                Err(err) => panic!("nothing came back: {err}"),
897            }
898        };
899        assert_eq!(echoed, "hello", "and reads it back");
900        let _ = handle.cancel();
901    }
902
903    #[cfg(unix)]
904    #[test]
905    fn a_live_child_reports_a_pid_and_flips_when_cancelled() {
906        // An embedder records the pid so a child a hard crash orphaned can be
907        // reaped on the next launch, and reads `was_cancelled` to tell a run
908        // the user stopped from one that finished. Both are answered by
909        // forwarding, which is exactly the kind of code that silently returns
910        // the wrong constant.
911        let handle = spawn_streaming(
912            Command::new("/bin/sleep").cwd(std::env::temp_dir()).run_id("pid").args(["30"]),
913            |_| {},
914        )
915        .expect("sleep should spawn");
916
917        let pid = handle.pid().expect("a live child has a pid");
918        assert!(pid > 1, "a real OS pid, not a placeholder: {pid}");
919        assert!(!handle.was_cancelled(), "nothing has stopped it yet");
920
921        handle.cancel().expect("cancel");
922        assert!(handle.was_cancelled(), "a stopped run says so");
923    }
924
925    #[test]
926    fn spawning_a_missing_binary_is_err() {
927        let result = spawn_streaming(
928            Command::new("cli-stream-no-such-binary-zzz").run_id("t"),
929            |_ev: Event| {},
930        );
931        // Typed: a `Spawn` error carrying the OS `NotFound` io::Error as its
932        // source — the whole point of `StreamError` over a `String`. A caller
933        // can branch on `ErrorKind` to tell "not installed" (NotFound) from
934        // "permission denied", which a flattened string can't support.
935        match result {
936            Err(StreamError::Spawn { program, source }) => {
937                assert!(program.contains("cli-stream-no-such-binary-zzz"));
938                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
939            }
940            other => panic!("expected StreamError::Spawn, got {other:?}"),
941        }
942    }
943
944    fn pumped(bytes: &[u8]) -> Vec<Event> {
945        let mut events = Vec::new();
946        pump_lines(bytes, "t".to_owned(), true, |event| events.push(event));
947        events
948    }
949
950    fn lines_of(events: &[Event]) -> Vec<String> {
951        events
952            .iter()
953            .filter_map(|event| match event {
954                Event::Stdout { line, .. } => Some(line.clone()),
955                _ => None,
956            })
957            .collect()
958    }
959
960    #[test]
961    fn one_undecodable_byte_does_not_cost_us_the_rest_of_the_run() {
962        // Agent CLIs write progress bars, ANSI art and the occasional raw byte
963        // to the same pipe they write results to. A stream is a byte stream,
964        // so the only safe reading is that a line we cannot decode is one
965        // damaged line — not the end of the transcript.
966        let mut bytes = b"first\n".to_vec();
967        bytes.extend_from_slice(&[0xff, 0xfe]);
968        bytes.extend_from_slice(b"\nlast\n");
969
970        let lines = lines_of(&pumped(&bytes));
971
972        assert_eq!(lines.first().map(String::as_str), Some("first"));
973        assert_eq!(
974            lines.last().map(String::as_str),
975            Some("last"),
976            "a line after the bad byte still arrives"
977        );
978        assert_eq!(lines.len(), 3, "the damaged line is kept, lossily");
979    }
980
981    /// Bytes shaped like a real child's stdout: mostly text, plenty of line
982    /// terminators, and the high bytes that are never valid UTF-8 alone.
983    /// Uniform `Vec<u8>` would hit `\n` once every 256 bytes and barely
984    /// exercise the framing this is here to check.
985    fn stream_bytes() -> impl Strategy<Value = Vec<u8>> {
986        prop::collection::vec(
987            prop_oneof![
988                6 => 0x20u8..0x7f,
989                3 => Just(b'\n'),
990                1 => Just(b'\r'),
991                2 => 0x80u8..=0xff,
992            ],
993            0..64,
994        )
995    }
996
997    fn line_count(bytes: &[u8]) -> usize {
998        if bytes.is_empty() {
999            return 0;
1000        }
1001        let newlines = bytes.iter().filter(|byte| **byte == b'\n').count();
1002        newlines + usize::from(bytes.last() != Some(&b'\n'))
1003    }
1004
1005    proptest! {
1006        /// Framing is a question about newlines, so it cannot depend on whether
1007        /// the bytes between them decode. This is the property the lossy fix is
1008        /// really about: strict decoding satisfied it only for valid UTF-8.
1009        #[test]
1010        fn every_line_the_child_wrote_is_one_the_caller_sees(bytes in stream_bytes()) {
1011            let events = pumped(&bytes);
1012            prop_assert_eq!(lines_of(&events).len(), line_count(&bytes));
1013            prop_assert!(
1014                !events.iter().any(|event| matches!(event, Event::Error { .. })),
1015                "no byte sequence is a read failure",
1016            );
1017        }
1018
1019        /// A line never carries the delimiter that ended it. Only `\n`
1020        /// delimits: a bare `\r` is content — it is how a progress bar
1021        /// overwrites itself — and is stripped only as part of a `\r\n` pair.
1022        #[test]
1023        fn no_line_smuggles_its_delimiter(bytes in stream_bytes()) {
1024            for line in lines_of(&pumped(&bytes)) {
1025                prop_assert!(!line.contains('\n'), "got {line:?}");
1026            }
1027        }
1028
1029        /// And for text, lossiness costs nothing: what the child wrote is
1030        /// exactly what the caller reads.
1031        #[test]
1032        fn text_arrives_unchanged(lines in prop::collection::vec("[^\r\n]{0,24}", 0..8)) {
1033            let written: String = lines.iter().map(|line| format!("{line}\n")).collect();
1034            prop_assert_eq!(lines_of(&pumped(written.as_bytes())), lines);
1035        }
1036    }
1037}