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