Skip to main content

cli_stream/
process.rs

1//! Generic streaming subprocess engine — the shared core behind every
2//! process-backed harness (bob, Claude Code, Codex, …).
3//!
4//! Spawns a child, pipes stdout/stderr line-by-line through a callback
5//! as [`ProcessEvent`]s, augments PATH so Node-based CLIs resolve even
6//! from a Finder-launched `.app`, and hands back a [`ProcessHandle`] for
7//! cancellation (SIGTERM → SIGKILL). No harness-trait or bob knowledge —
8//! purely subprocess streaming.
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::{Path, PathBuf};
19use std::process::{Child, Command, Stdio};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::{mpsc, Arc, Mutex, OnceLock};
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 ProcessEvent {
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    /// Spawn / 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#[derive(Debug)]
67struct HandleInner {
68    child: Mutex<Option<Child>>,
69    cancelled: AtomicBool,
70}
71
72impl ProcessHandle {
73    /// SIGTERM the process, then SIGKILL after 1.5s if it's still alive.
74    /// The CLI is supposed to flush a final result on SIGTERM but we
75    /// don't trust it to do so forever.
76    pub fn cancel(&self) -> Result<(), StreamError> {
77        self.inner.cancelled.store(true, Ordering::SeqCst);
78        let mut guard = self
79            .inner
80            .child
81            .lock()
82            .map_err(|_| StreamError::CancelLockPoisoned)?;
83        let Some(child) = guard.as_mut() else {
84            // Already exited.
85            return Ok(());
86        };
87        // Best-effort SIGTERM. On Unix, kill() sends SIGKILL by default;
88        // we use libc::kill for SIGTERM, falling back to child.kill() if
89        // the libc call fails. On Windows there's only TerminateProcess
90        // via .kill().
91        #[cfg(unix)]
92        {
93            let pid = child.id() as i32;
94            // SAFETY: pid is the child's PID owned by this Child; sending
95            // SIGTERM is well-defined.
96            unsafe { libc::kill(pid, libc::SIGTERM) };
97            // Spawn the SIGKILL fallback inline to avoid holding the mutex
98            // while sleeping.
99            let inner = Arc::clone(&self.inner);
100            thread::spawn(move || {
101                thread::sleep(Duration::from_millis(1500));
102                if let Ok(mut guard) = inner.child.lock() {
103                    if let Some(child) = guard.as_mut() {
104                        let _ = child.kill();
105                    }
106                }
107            });
108        }
109        #[cfg(not(unix))]
110        {
111            let _ = child.kill();
112        }
113        Ok(())
114    }
115
116    /// Whether `cancel()` was called. Tagged on the final `Exited` event.
117    pub fn was_cancelled(&self) -> bool {
118        self.inner.cancelled.load(Ordering::SeqCst)
119    }
120
121    /// The child's OS process id while it's alive, or `None` once it has been
122    /// reaped (the `Child` is taken on exit). Lets an embedder record the pid
123    /// so a child orphaned by a hard crash can be killed on the next launch.
124    pub fn pid(&self) -> Option<u32> {
125        self.inner
126            .child
127            .lock()
128            .ok()
129            .and_then(|guard| guard.as_ref().map(Child::id))
130    }
131}
132
133/// Spawn an arbitrary streaming child process — the generic engine behind
134/// every process-backed harness (bob, Claude Code, Codex).
135///
136/// Pipes stdout/stderr line-by-line through `callback` using the raw
137/// [`ProcessEvent`] vocabulary (Started / Stdout / Stderr / Error /
138/// Exited). `env` supplies per-harness secrets (each harness's API-key
139/// var, or none for self-authenticating CLIs). PATH is augmented so
140/// Node-based CLIs find `node`. Returns a [`ProcessHandle`] for
141/// cancellation.
142///
143/// `callback` is invoked from three threads (stdout reader, stderr
144/// reader, exit watcher); the `Clone` bound lets us hand a copy to each.
145/// `run_id` is opaque — the caller chooses it and uses it to correlate
146/// events with the handle.
147///
148/// ```no_run
149/// use cli_stream::{spawn_streaming, ProcessEvent};
150/// use std::path::PathBuf;
151///
152/// # fn main() -> Result<(), cli_stream::StreamError> {
153/// let handle = spawn_streaming(
154///     PathBuf::from("echo"),
155///     vec!["hello".to_owned()],
156///     Vec::new(),                      // extra env vars (key, value)
157///     std::env::current_dir().unwrap(),
158///     "run-1".to_owned(),              // your correlation id
159///     |event| match event {
160///         ProcessEvent::Stdout { line, .. } => println!("{line}"),
161///         ProcessEvent::Exited { exit_code, .. } => eprintln!("exit {exit_code:?}"),
162///         _ => {}
163///     },
164/// )?;
165/// // `handle.cancel()` stops it early; dropping the handle does not.
166/// let _ = handle;
167/// # Ok(())
168/// # }
169/// ```
170pub fn spawn_streaming<F>(
171    program: PathBuf,
172    args: Vec<String>,
173    env: Vec<(String, String)>,
174    cwd: PathBuf,
175    run_id: String,
176    callback: F,
177) -> Result<ProcessHandle, StreamError>
178where
179    F: FnMut(ProcessEvent) + Send + Sync + Clone + 'static,
180{
181    // PATH augmentation: Node-based CLIs (bob, claude, codex) expect
182    // `node` (and often `npm`, `git`) on PATH. A desktop app launched
183    // from Finder/Launchpad inherits only the minimal launchd PATH
184    // (`/usr/bin:/bin:/usr/sbin:/sbin`), so an nvm-installed node is
185    // invisible and the child exits 127 ("command not found").
186    //
187    // Fix: prepend the program's parent dir (where node also lives in an
188    // nvm install) to the child's PATH. Added, not replaced, so a PATH
189    // the user explicitly set still wins on later lookups.
190    //
191    // A bare program name is resolved to its absolute path FIRST (see
192    // `resolve_program`), so the prepended dir is the program's real home —
193    // pairing a node CLI with the exact `node` it was installed under,
194    // regardless of which node version leads the inherited PATH.
195    let program = resolve_program(program);
196    let augmented_path = augment_path_for_node(&program);
197
198    let mut command = Command::new(&program);
199    command
200        .args(&args)
201        .current_dir(&cwd)
202        .env("PATH", augmented_path)
203        .stdin(Stdio::null())
204        .stdout(Stdio::piped())
205        .stderr(Stdio::piped());
206    for (key, value) in &env {
207        command.env(key, value);
208    }
209    let mut child = command.spawn().map_err(|source| StreamError::Spawn {
210        program: program.display().to_string(),
211        source,
212    })?;
213
214    let stdout = child
215        .stdout
216        .take()
217        .ok_or(StreamError::PipeNotCaptured { stream: "stdout" })?;
218    let stderr = child
219        .stderr
220        .take()
221        .ok_or(StreamError::PipeNotCaptured { stream: "stderr" })?;
222
223    let inner = Arc::new(HandleInner {
224        child: Mutex::new(Some(child)),
225        cancelled: AtomicBool::new(false),
226    });
227    let handle = ProcessHandle { inner: Arc::clone(&inner) };
228
229    // Emit Started immediately so the caller doesn't wait on the first
230    // output line for a UI signal.
231    let mut started_cb = callback.clone();
232    started_cb(ProcessEvent::Started { run_id: run_id.clone() });
233
234    // Reader threads. Each owns its own callback clone — the Clone bound
235    // is the whole point.
236    let stdout_cb = callback.clone();
237    let stdout_run_id = run_id.clone();
238    let stdout_handle = thread::spawn(move || {
239        pump_lines(stdout, stdout_run_id, true, stdout_cb);
240    });
241
242    let stderr_cb = callback.clone();
243    let stderr_run_id = run_id.clone();
244    let stderr_handle = thread::spawn(move || {
245        pump_lines(stderr, stderr_run_id, false, stderr_cb);
246    });
247
248    // Exit watcher — emits the terminal Exited event with the cancellation
249    // flag. It must NOT hold the child lock across a blocking `wait()`:
250    // `cancel()` needs that same lock to signal the child, so a held lock
251    // would block cancel until the process exited on its own (defeating it).
252    // Instead poll `try_wait()`, locking only for each non-blocking check and
253    // releasing between polls so `cancel()` can acquire the lock mid-run.
254    let exit_inner = Arc::clone(&inner);
255    let mut exit_cb = callback;
256    let exit_run_id = run_id;
257    thread::spawn(move || {
258        let wait_result = loop {
259            {
260                let mut guard = match exit_inner.child.lock() {
261                    Ok(guard) => guard,
262                    Err(_) => return, // poisoned — nothing safe to do
263                };
264                match guard.as_mut() {
265                    Some(child) => match child.try_wait() {
266                        Ok(Some(status)) => break Ok(status),
267                        Ok(None) => {}            // still running; poll again
268                        Err(err) => break Err(err),
269                    },
270                    None => return, // already reaped
271                }
272            } // lock released before sleeping, so cancel() can acquire it
273            thread::sleep(Duration::from_millis(50));
274        };
275        let _ = stdout_handle.join();
276        let _ = stderr_handle.join();
277        let cancelled = exit_inner.cancelled.load(Ordering::SeqCst);
278
279        match wait_result {
280            Ok(status) => exit_cb(ProcessEvent::Exited {
281                run_id: exit_run_id.clone(),
282                exit_code: status.code(),
283                cancelled,
284            }),
285            Err(err) => exit_cb(ProcessEvent::Error {
286                run_id: exit_run_id.clone(),
287                message: format!("wait failed: {err}"),
288            }),
289        }
290
291        // Drop the child handle so subsequent cancel() calls
292        // short-circuit cleanly.
293        if let Ok(mut guard) = exit_inner.child.lock() {
294            *guard = None;
295        }
296    });
297
298    Ok(handle)
299}
300
301fn pump_lines<R, F>(reader: R, run_id: String, is_stdout: bool, mut callback: F)
302where
303    R: Read,
304    F: FnMut(ProcessEvent),
305{
306    let buffered = BufReader::new(reader);
307    for line in buffered.lines() {
308        match line {
309            Ok(text) => {
310                let event = if is_stdout {
311                    ProcessEvent::Stdout { run_id: run_id.clone(), line: text }
312                } else {
313                    ProcessEvent::Stderr { run_id: run_id.clone(), line: text }
314                };
315                callback(event);
316            }
317            Err(err) => {
318                callback(ProcessEvent::Error {
319                    run_id: run_id.clone(),
320                    message: format!("stream read failed: {err}"),
321                });
322                return;
323            }
324        }
325    }
326}
327
328/// Compose a PATH for the spawned process that always includes the
329/// directory containing the program — where `node`, `npm`, and friends
330/// usually live in an nvm install. The user's existing PATH stays as a
331/// fallback after our prepended directory.
332fn augment_path_for_node(program: &Path) -> String {
333    prepend_program_dir(program, &augmented_node_path())
334}
335
336/// Resolve a bare program name (`bob`, `claude`) to its absolute path on the
337/// augmented PATH, so the spawn and the node pairing agree on *one* location.
338///
339/// Without this, a bare name splits the brain: the OS resolves the *program*
340/// against the parent process's PATH, while the child's `#!/usr/bin/env node`
341/// shebang resolves *node* against the PATH we set — and
342/// [`prepend_program_dir`] can't pair the program with its sibling node
343/// because a bare name has no parent dir. Concretely: an nvm-installed `bob`
344/// found under `v24/bin` could re-exec on a `v20` node that happened to lead
345/// the inherited PATH, and die on a v24-only flag ("exited with code 9").
346/// Resolving to the absolute path first means the program's own directory —
347/// holding the exact `node` it was installed with — is prepended and wins.
348///
349/// A program given with an explicit path is returned untouched; a bare name
350/// that can't be found is also returned untouched, so the spawn still fails
351/// with the clear "No such file" error rather than a synthetic one here.
352pub fn resolve_program(program: PathBuf) -> PathBuf {
353    if program.parent().is_some_and(|p| !p.as_os_str().is_empty()) {
354        return program; // explicit path — caller's choice wins
355    }
356    resolve_on_path(&program, &augmented_node_path()).unwrap_or(program)
357}
358
359/// Walk `path_env`'s entries for the first executable file named `name`.
360/// Pure with respect to env/spawn (filesystem only) so it's unit-testable.
361fn resolve_on_path(name: &Path, path_env: &str) -> Option<PathBuf> {
362    path_env
363        .split(':')
364        .filter(|dir| !dir.is_empty())
365        .map(|dir| Path::new(dir).join(name))
366        .find(|candidate| is_executable_file(candidate))
367}
368
369#[cfg(unix)]
370fn is_executable_file(path: &Path) -> bool {
371    use std::os::unix::fs::PermissionsExt;
372    std::fs::metadata(path)
373        .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
374        .unwrap_or(false)
375}
376
377#[cfg(not(unix))]
378fn is_executable_file(path: &Path) -> bool {
379    path.is_file()
380}
381
382/// Prepend the directory containing `program` (where `node` also lives in an
383/// nvm install) to `base_path`, so the resolved binary's own dir is searched
384/// first. Pure (no env / no spawn) so it's unit-tested directly.
385fn prepend_program_dir(program: &Path, base_path: &str) -> String {
386    match program
387        .parent()
388        .map(|p| p.display().to_string())
389        .filter(|s| !s.is_empty())
390    {
391        Some(dir) => format!("{dir}:{base_path}"),
392        None => base_path.to_owned(),
393    }
394}
395
396/// A PATH that resolves Node-based CLIs (bob, claude, codex) even from a
397/// process launched by Finder/Launchpad, which inherits only the minimal
398/// launchd PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) rather than the user's
399/// shell PATH.
400///
401/// Strategy: keep the process's own PATH first (an explicit PATH still wins),
402/// then append the user's **real** PATH as resolved by their login shell —
403/// which sources their rc, so it knows where nvm / pnpm / volta / asdf / fnm /
404/// Homebrew put `node`, with no guessing. If the shell query is unavailable
405/// (no `$SHELL`, a timeout, a sandboxed app that can't spawn, …) we fall back
406/// to a hardcoded best-effort list, so we're never worse than before.
407///
408/// Used by the run path (which prepends the resolved binary's own dir on top
409/// of this) and by readiness probes that locate `claude`/`codex` via a bare
410/// `Command::new(name)`. Computed once and cached for the process — the
411/// (bounded) shell spawn happens at most once per launch, lazily on the first
412/// readiness/run/login, never at construction.
413pub fn augmented_node_path() -> String {
414    static CACHED: OnceLock<String> = OnceLock::new();
415    CACHED.get_or_init(compute_augmented_node_path).clone()
416}
417
418fn compute_augmented_node_path() -> String {
419    let mut parts: Vec<String> = Vec::new();
420    // The process's own PATH first — anything explicitly set still wins.
421    if let Ok(existing) = std::env::var("PATH") {
422        if !existing.is_empty() {
423            parts.push(existing);
424        }
425    }
426    // The user's real PATH (nvm/pnpm/volta/asdf/Homebrew) via their login
427    // shell; a hardcoded best-effort list if that's unavailable.
428    parts.push(login_shell_path().unwrap_or_else(hardcoded_node_dirs));
429    keep_absolute_entries(&parts.join(":"))
430}
431
432/// Keep only **absolute** PATH entries, dropping relative or empty ones (`.`,
433/// `""`, a direnv-style `node_modules/.bin`). Security: we spawn with
434/// `current_dir` set to the user's workspace — where the agent itself writes
435/// files and synced/downloaded content lands — so a relative/empty PATH entry
436/// (which resolves against that cwd) could run a planted `node`/`claude`. An
437/// empty entry is the classic implicit-cwd vector. Absolute dirs only.
438fn keep_absolute_entries(path: &str) -> String {
439    path.split(':')
440        .filter(|entry| entry.starts_with('/'))
441        .collect::<Vec<_>>()
442        .join(":")
443}
444
445/// Resolve PATH by asking the user's login + interactive shell — it sources
446/// their rc, so it knows wherever any node manager (nvm / pnpm / volta / asdf /
447/// fnm / Homebrew) put `node`, without us guessing. Bounded by a timeout so a
448/// slow or interactive rc can't hang us; returns `None` (→ hardcoded fallback)
449/// on any failure: no `$SHELL`, spawn refused (e.g. a sandboxed app), timeout,
450/// or no PATH in the output. Reads PATH from `env` (OS colon format,
451/// shell-agnostic — works for fish too) rather than expanding `$PATH`.
452///
453/// This *executes the user's shell rc*, exactly as opening a terminal does —
454/// their own shell, on their own machine. It is not a privilege/auth step: no
455/// "login session" is created; `-l`/`-i` only select which startup files are
456/// sourced (login profiles + the interactive rc where nvm usually lives).
457/// Printed on its own line right before `env`, so the parser can skip any
458/// shell-init chatter / terminal escape sequences (e.g. iTerm2 shell
459/// integration's `]1337;…` OSC codes) the interactive shell emits before our
460/// command runs — which would otherwise prepend to the `PATH=` line.
461const PATH_SENTINEL: &str = "__CLI_STREAM_PATH__";
462
463#[cfg(unix)]
464fn login_shell_path() -> Option<String> {
465    let shell = std::env::var("SHELL").ok().filter(|s| !s.is_empty())?;
466    // Print a sentinel line, then dump the environment. Reading PATH from `env`
467    // (not by expanding `$PATH`) keeps it OS colon format and shell-agnostic
468    // (fish stores PATH as a list); the sentinel lets the parser ignore
469    // anything the interactive shell prints at startup before `env` runs.
470    let script = format!("printf '\\n{PATH_SENTINEL}\\n'; env");
471    let mut child = Command::new(&shell)
472        .arg("-lic") // -l: login profiles, -i: interactive rc (nvm), -c: command
473        .arg(&script)
474        .stdin(Stdio::null())
475        .stdout(Stdio::piped())
476        .stderr(Stdio::null())
477        .spawn()
478        .ok()?;
479    // Read on a worker thread so the whole query can be bounded by a timeout —
480    // a misbehaving rc must not hang the app. Read bytes + lossy-decode (rather
481    // than `read_to_string`) so non-UTF-8 in the env dump degrades to
482    // replacement chars instead of discarding the whole output.
483    let mut stdout = child.stdout.take()?;
484    let (tx, rx) = mpsc::channel();
485    thread::spawn(move || {
486        let mut buf = Vec::new();
487        let _ = stdout.read_to_end(&mut buf);
488        let _ = tx.send(String::from_utf8_lossy(&buf).into_owned());
489    });
490    // 4s: generous enough for a heavy rc (oh-my-zsh + plugins + nvm lazy-load)
491    // to finish, since this is paid at most once (cached); on timeout we kill
492    // the shell and fall back to the hardcoded list.
493    let output = match rx.recv_timeout(Duration::from_secs(4)) {
494        Ok(buf) => buf,
495        Err(_) => {
496            let _ = child.kill();
497            let _ = child.wait();
498            return None;
499        }
500    };
501    let _ = child.wait();
502    parse_path_from_shell_output(&output)
503}
504
505#[cfg(not(unix))]
506fn login_shell_path() -> Option<String> {
507    None
508}
509
510/// Extract the `PATH=…` value from the shell's `printf <sentinel>; env` output.
511/// Everything up to (and including) the last sentinel is discarded — that's
512/// where shell-init chatter and terminal escape sequences live — then the
513/// `PATH=` line is read from the clean `env` dump that follows. `None` if the
514/// sentinel is missing (query misbehaved) or PATH is absent/empty.
515fn parse_path_from_shell_output(output: &str) -> Option<String> {
516    output
517        .rsplit_once(PATH_SENTINEL)?
518        .1
519        .lines()
520        .find_map(|line| line.strip_prefix("PATH="))
521        .map(str::trim)
522        .filter(|p| !p.is_empty())
523        .map(str::to_owned)
524}
525
526/// Hardcoded best-effort node locations — the fallback when the login-shell
527/// query is unavailable. Leans on the *universal* dirs every distro + macOS
528/// share: `/usr/bin` + `/usr/local/bin` are where apt/dnf/yum/pacman and the
529/// official Node tarball install, so the common Linux container case is covered
530/// without distro-specific guessing. Plus macOS Homebrew, the official-installer
531/// dir, and any nvm-managed node. Anything manager-specific (pnpm/volta/asdf,
532/// Linuxbrew, snap, …) is what the login-shell query is for — and a missing
533/// dir is just skipped, so this is never worse than the bare launchd PATH.
534fn hardcoded_node_dirs() -> String {
535    let mut parts: Vec<String> =
536        vec!["/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_owned()];
537    if let Ok(home) = std::env::var("HOME") {
538        if !home.is_empty() {
539            let home_path = Path::new(&home);
540            // Official-installer location for several agent CLIs.
541            parts.push(home_path.join(".local/bin").display().to_string());
542            // nvm: ~/.nvm/versions/node/<version>/bin — where npm-global
543            // CLIs (bob, claude, codex) live under an nvm-managed node.
544            if let Ok(entries) = std::fs::read_dir(home_path.join(".nvm/versions/node")) {
545                for entry in entries.flatten() {
546                    let bin = entry.path().join("bin");
547                    if bin.is_dir() {
548                        parts.push(bin.display().to_string());
549                    }
550                }
551            }
552        }
553    }
554    parts.join(":")
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn hardcoded_fallback_includes_macos_defaults() {
563        // The fallback (used when the login-shell query is unavailable) must
564        // still carry Homebrew + the system bins, so a launchd-spawned `.app`
565        // resolves CLIs even without a usable shell — the original
566        // "not installed" fix.
567        let path = hardcoded_node_dirs();
568        assert!(path.contains("/opt/homebrew/bin"), "missing Apple-Silicon Homebrew bin");
569        assert!(path.contains("/usr/local/bin"), "missing Intel Homebrew / system bin");
570        assert!(path.contains("/usr/bin"), "missing system bin");
571    }
572
573    #[test]
574    fn parse_path_from_shell_output_skips_chatter_before_the_sentinel() {
575        // Real-world shape: iTerm2 OSC escapes + a banner emitted at shell
576        // startup, BEFORE our sentinel + `env` dump. Only the post-sentinel
577        // PATH= line counts — note the pre-sentinel "PATH=/decoy" is ignored.
578        let output = "\u{1b}]1337;RemoteHost=x\u{7}welcome banner\nPATH=/decoy\n__CLI_STREAM_PATH__\nHOME=/Users/x\nPATH=/opt/homebrew/bin:/usr/bin\nLANG=en_US";
579        assert_eq!(
580            parse_path_from_shell_output(output).as_deref(),
581            Some("/opt/homebrew/bin:/usr/bin")
582        );
583        // No sentinel (query misbehaved) → None, so the caller falls back —
584        // even if a bare PATH= is present.
585        assert_eq!(parse_path_from_shell_output("PATH=/usr/bin"), None);
586        // Sentinel present but PATH absent/empty → None.
587        assert_eq!(parse_path_from_shell_output("__CLI_STREAM_PATH__\nFOO=bar"), None);
588        assert_eq!(parse_path_from_shell_output("__CLI_STREAM_PATH__\nPATH=\nFOO=bar"), None);
589    }
590
591    #[test]
592    fn keep_absolute_entries_drops_relative_and_empty() {
593        // Relative (`node_modules/.bin`, `.`) and empty entries — which resolve
594        // against the spawn cwd (the user's workspace) — are dropped; absolute
595        // dirs survive in order.
596        assert_eq!(
597            keep_absolute_entries("/opt/homebrew/bin:node_modules/.bin:/usr/bin:.::/bin"),
598            "/opt/homebrew/bin:/usr/bin:/bin"
599        );
600        assert_eq!(keep_absolute_entries("/usr/bin"), "/usr/bin");
601        // All-relative → empty (caller still has the process PATH ahead of it).
602        assert_eq!(keep_absolute_entries(".:rel:"), "");
603    }
604
605    #[test]
606    fn prepend_program_dir_puts_the_binary_dir_first() {
607        let combined = prepend_program_dir(
608            Path::new("/Users/x/.nvm/versions/node/v22/bin/bob"),
609            "/opt/homebrew/bin:/usr/bin",
610        );
611        assert!(combined.starts_with("/Users/x/.nvm/versions/node/v22/bin:"));
612        assert!(combined.contains("/opt/homebrew/bin"));
613        // A bare program name has no parent dir → base path unchanged.
614        assert_eq!(prepend_program_dir(Path::new("bob"), "/usr/bin"), "/usr/bin");
615    }
616
617    #[test]
618    fn augmented_node_path_is_nonempty_and_resolves_system_bin() {
619        // Exercises the cached public path once. `/usr/bin` is present whether
620        // the shell query succeeds (real PATH) or falls back (hardcoded), and
621        // is on the bare launchd PATH too — so this holds in any environment.
622        let path = augmented_node_path();
623        assert!(!path.is_empty());
624        assert!(path.contains("/usr/bin"), "system bin must always resolve");
625    }
626
627    #[test]
628    fn resolve_program_returns_explicit_paths_untouched() {
629        // A caller-supplied path is the caller's choice — no PATH lookup.
630        let explicit = PathBuf::from("/opt/somewhere/bob");
631        assert_eq!(resolve_program(explicit.clone()), explicit);
632        let relative = PathBuf::from("./bin/bob");
633        assert_eq!(resolve_program(relative.clone()), relative);
634    }
635
636    #[cfg(unix)]
637    #[test]
638    fn resolve_on_path_finds_the_first_executable_match() {
639        use std::os::unix::fs::PermissionsExt;
640        let root = tempfile::tempdir().expect("tempdir");
641        // dir_a holds a NON-executable `bob` (must be skipped); dir_b an
642        // executable one (must win even though dir_a comes first on PATH).
643        let dir_a = root.path().join("a");
644        let dir_b = root.path().join("b");
645        std::fs::create_dir_all(&dir_a).unwrap();
646        std::fs::create_dir_all(&dir_b).unwrap();
647        std::fs::write(dir_a.join("bob"), "#!/bin/sh\n").unwrap();
648        let exec = dir_b.join("bob");
649        std::fs::write(&exec, "#!/bin/sh\n").unwrap();
650        std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).unwrap();
651
652        let path_env = format!("{}:{}", dir_a.display(), dir_b.display());
653        assert_eq!(resolve_on_path(Path::new("bob"), &path_env), Some(exec));
654        // An unknown name resolves to nothing.
655        assert_eq!(resolve_on_path(Path::new("definitely-missing"), &path_env), None);
656    }
657}
658
659/// End-to-end lifecycle tests that spawn real processes. Unix-only: they use
660/// `printf` / `sh` / `sleep`, and the cancel path is signal-based here.
661#[cfg(all(test, unix))]
662mod lifecycle {
663    use super::*;
664    use std::sync::Condvar;
665    use std::time::Instant;
666
667    type Done = Arc<(Mutex<bool>, Condvar)>;
668
669    /// A thread-safe event collector that signals `done` on the terminal
670    /// event. Returns the (cloneable) callback + the shared collections.
671    fn collector() -> (
672        impl FnMut(ProcessEvent) + Send + Sync + Clone + 'static,
673        Arc<Mutex<Vec<ProcessEvent>>>,
674        Done,
675    ) {
676        let events = Arc::new(Mutex::new(Vec::new()));
677        let done: Done = Arc::new((Mutex::new(false), Condvar::new()));
678        let cb = {
679            let events = Arc::clone(&events);
680            let done = Arc::clone(&done);
681            move |ev: ProcessEvent| {
682                let terminal =
683                    matches!(ev, ProcessEvent::Exited { .. } | ProcessEvent::Error { .. });
684                events.lock().unwrap().push(ev);
685                if terminal {
686                    let (lock, cvar) = &*done;
687                    *lock.lock().unwrap() = true;
688                    cvar.notify_all();
689                }
690            }
691        };
692        (cb, events, done)
693    }
694
695    /// Block until the terminal event fires, or panic after `secs`.
696    fn wait_done(done: &Done, secs: u64) {
697        let (lock, cvar) = &**done;
698        let mut finished = lock.lock().unwrap();
699        let deadline = Instant::now() + Duration::from_secs(secs);
700        while !*finished {
701            let now = Instant::now();
702            assert!(now < deadline, "process did not finish within {secs}s");
703            let (guard, _) = cvar.wait_timeout(finished, deadline - now).unwrap();
704            finished = guard;
705        }
706    }
707
708    /// Spawn `program args`, block until it exits, return every event.
709    fn run(program: &str, args: &[&str]) -> Vec<ProcessEvent> {
710        let (cb, events, done) = collector();
711        let _handle = spawn_streaming(
712            PathBuf::from(program),
713            args.iter().map(|s| (*s).to_owned()).collect(),
714            Vec::new(),
715            PathBuf::from("."),
716            "t".to_owned(),
717            cb,
718        )
719        .expect("spawn");
720        wait_done(&done, 10);
721        let events = events.lock().unwrap();
722        events.clone()
723    }
724
725    #[test]
726    fn streams_stdout_lines_then_exits_zero() {
727        let events = run("printf", &["%s\n", "alpha", "beta"]);
728        // Started leads, Exited(0, not cancelled) closes.
729        assert!(matches!(events.first(), Some(ProcessEvent::Started { .. })));
730        assert!(matches!(
731            events.last(),
732            Some(ProcessEvent::Exited { exit_code: Some(0), cancelled: false, .. })
733        ));
734        // Lines arrive in order, one event each.
735        let lines: Vec<&str> = events
736            .iter()
737            .filter_map(|e| match e {
738                ProcessEvent::Stdout { line, .. } => Some(line.as_str()),
739                _ => None,
740            })
741            .collect();
742        assert_eq!(lines, vec!["alpha", "beta"]);
743    }
744
745    #[test]
746    fn nonzero_exit_code_is_reported() {
747        let events = run("sh", &["-c", "exit 3"]);
748        assert!(matches!(
749            events.last(),
750            Some(ProcessEvent::Exited { exit_code: Some(3), cancelled: false, .. })
751        ));
752    }
753
754    #[test]
755    fn env_vars_are_passed_to_the_child() {
756        // The `env` argument must reach the child's environment — exercise it
757        // directly (the other lifecycle tests pass an empty env).
758        let (cb, events, done) = collector();
759        let _handle = spawn_streaming(
760            PathBuf::from("sh"),
761            vec!["-c".to_owned(), "printf '%s\\n' \"$CLI_STREAM_STUB\"".to_owned()],
762            vec![("CLI_STREAM_STUB".to_owned(), "from-env".to_owned())],
763            PathBuf::from("."),
764            "t".to_owned(),
765            cb,
766        )
767        .expect("spawn");
768        wait_done(&done, 10);
769        let events = events.lock().unwrap();
770        assert!(
771            events
772                .iter()
773                .any(|e| matches!(e, ProcessEvent::Stdout { line, .. } if line == "from-env")),
774            "child should observe the injected env var, got {events:?}"
775        );
776    }
777
778    #[test]
779    fn stderr_is_streamed_and_not_misrouted_to_stdout() {
780        let events = run("sh", &["-c", "echo to-stderr 1>&2"]);
781        assert!(events
782            .iter()
783            .any(|e| matches!(e, ProcessEvent::Stderr { line, .. } if line == "to-stderr")));
784        assert!(!events.iter().any(|e| matches!(e, ProcessEvent::Stdout { .. })));
785        assert!(events
786            .iter()
787            .any(|e| matches!(e, ProcessEvent::Exited { exit_code: Some(0), .. })));
788    }
789
790    #[test]
791    fn cancel_promptly_terminates_the_run_and_flags_it() {
792        // A 10s sleeper we cancel ~immediately; a working engine must kill it
793        // far sooner than 10s. `exec` so the process *is* sleep (no orphan).
794        let (cb, events, done) = collector();
795        let handle = spawn_streaming(
796            PathBuf::from("sh"),
797            vec!["-c".to_owned(), "exec sleep 10".to_owned()],
798            Vec::new(),
799            PathBuf::from("."),
800            "t".to_owned(),
801            cb,
802        )
803        .expect("spawn");
804
805        // cancel() may block until the child is reaped, so fire it off-thread.
806        let canceller = handle.clone();
807        thread::spawn(move || {
808            thread::sleep(Duration::from_millis(100));
809            let _ = canceller.cancel();
810        });
811
812        // Correct cancellation terminates the 10s sleep within a few seconds.
813        wait_done(&done, 4);
814        assert!(handle.was_cancelled());
815        let events = events.lock().unwrap();
816        assert!(
817            matches!(events.last(), Some(ProcessEvent::Exited { cancelled: true, .. })),
818            "expected Exited(cancelled=true), got {:?}",
819            events.last()
820        );
821    }
822
823    #[test]
824    fn spawning_a_missing_binary_is_err() {
825        let result = spawn_streaming(
826            PathBuf::from("cli-stream-no-such-binary-zzz"),
827            Vec::new(),
828            Vec::new(),
829            PathBuf::from("."),
830            "t".to_owned(),
831            |_ev: ProcessEvent| {},
832        );
833        // Typed: a `Spawn` error carrying the OS `NotFound` io::Error as its
834        // source — the whole point of `StreamError` over a `String`. A caller
835        // can branch on `ErrorKind` to tell "not installed" (NotFound) from
836        // "permission denied", which a flattened string can't support.
837        match result {
838            Err(StreamError::Spawn { program, source }) => {
839                assert!(program.contains("cli-stream-no-such-binary-zzz"));
840                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
841            }
842            other => panic!("expected StreamError::Spawn, got {other:?}"),
843        }
844    }
845}