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}