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}