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