Skip to main content

spar/
proc.rs

1//! Running external commands.
2//!
3//! Two things here are load bearing. First, both output streams are captured on
4//! their own threads: an agent CLI can emit megabytes of JSONL, and reading one
5//! pipe to completion before the other deadlocks as soon as the unread pipe
6//! fills. Second, a failure message shows *both* streams, because one agent CLI
7//! reports fatal conditions on stdout with stderr empty, and another writes
8//! routine chatter to stderr on every run.
9
10use std::io::{Read, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16
17use crate::error::{Result, SparError};
18
19pub const DEFAULT_TIMEOUT_SECS: u64 = 1800;
20
21#[derive(Debug, Clone)]
22pub struct ExecOpts {
23    pub cwd: Option<PathBuf>,
24    pub timeout: Duration,
25    /// When false, a non-zero exit returns stdout instead of an error.
26    pub check: bool,
27    pub env: Vec<(String, String)>,
28    pub stdin: Option<String>,
29}
30
31impl Default for ExecOpts {
32    fn default() -> Self {
33        Self {
34            cwd: None,
35            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
36            check: true,
37            env: Vec::new(),
38            stdin: None,
39        }
40    }
41}
42
43impl ExecOpts {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    pub fn cwd(mut self, path: impl AsRef<Path>) -> Self {
49        self.cwd = Some(path.as_ref().to_path_buf());
50        self
51    }
52
53    pub fn cwd_opt(mut self, path: Option<PathBuf>) -> Self {
54        self.cwd = path;
55        self
56    }
57
58    pub fn timeout_secs(mut self, secs: u64) -> Self {
59        self.timeout = Duration::from_secs(secs);
60        self
61    }
62
63    pub fn check(mut self, value: bool) -> Self {
64        self.check = value;
65        self
66    }
67
68    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
69        self.env.push((key.into(), value.into()));
70        self
71    }
72}
73
74#[derive(Debug, Clone)]
75pub struct Output {
76    pub stdout: String,
77    pub stderr: String,
78    pub code: i32,
79}
80
81impl Output {
82    pub fn ok(&self) -> bool {
83        self.code == 0
84    }
85}
86
87/// Run a command to completion, capturing both streams.
88///
89/// Only spawn failures and timeouts are errors here; a non-zero exit is a
90/// normal result that the caller decides how to treat.
91pub fn exec(argv: &[String], opts: &ExecOpts) -> Result<Output> {
92    let program = argv
93        .first()
94        .ok_or_else(|| SparError::new("cannot run an empty command"))?;
95
96    let mut command = Command::new(program);
97    command
98        .args(&argv[1..])
99        .stdout(Stdio::piped())
100        .stderr(Stdio::piped());
101
102    if opts.stdin.is_some() {
103        command.stdin(Stdio::piped());
104    } else {
105        // Agent CLIs happily block forever waiting on an inherited terminal.
106        command.stdin(Stdio::null());
107    }
108
109    if let Some(dir) = &opts.cwd {
110        command.current_dir(dir);
111    }
112    for (key, value) in &opts.env {
113        command.env(key, value);
114    }
115
116    let mut child = command
117        .spawn()
118        .map_err(|e| SparError::new(format!("could not run `{}`: {e}", abbreviate(argv))))?;
119
120    if let Some(text) = &opts.stdin {
121        if let Some(mut pipe) = child.stdin.take() {
122            let _ = pipe.write_all(text.as_bytes());
123        }
124        // Dropping the handle closes the pipe, which the child needs in order
125        // to see EOF and exit.
126    }
127
128    let out_reader = Reader::spawn(child.stdout.take().expect("stdout piped"));
129    let err_reader = Reader::spawn(child.stderr.take().expect("stderr piped"));
130
131    let deadline = Instant::now() + opts.timeout;
132    let mut poll = Duration::from_millis(5);
133    let mut timed_out = false;
134    let status = loop {
135        match child.try_wait()? {
136            Some(status) => break Some(status),
137            None => {
138                if Instant::now() >= deadline {
139                    let _ = child.kill();
140                    let _ = child.wait();
141                    timed_out = true;
142                    break None;
143                }
144                std::thread::sleep(poll);
145                // Back off so a long agent run is not a busy loop, but stay
146                // responsive for the many fast git and gh calls.
147                poll = (poll * 2).min(Duration::from_millis(100));
148            }
149        }
150    };
151
152    // Never join the readers.
153    //
154    // An agent CLI runs shell commands, and any grandchild that inherited the
155    // pipe keeps its write end open after its parent exits. `read_to_end` on
156    // such a pipe never returns, so joining would hang past the deadline and
157    // the timeout would bound nothing at all. Instead, wait for the readers to
158    // finish or for the output to stop arriving, then take what did.
159    let stdout = out_reader.collect(DRAIN_GRACE);
160    let stderr = err_reader.collect(DRAIN_GRACE);
161
162    if timed_out {
163        return Err(SparError::new(format!(
164            "timed out after {}s: {}\nRaise `timeout` on this agent in spar.toml if the model \
165             legitimately needs longer.",
166            opts.timeout.as_secs(),
167            abbreviate(argv)
168        )));
169    }
170
171    Ok(Output {
172        stdout: String::from_utf8_lossy(&stdout).into_owned(),
173        stderr: String::from_utf8_lossy(&stderr).into_owned(),
174        code: status.and_then(|s| s.code()).unwrap_or(-1),
175    })
176}
177
178/// How long to keep waiting for output after the child has exited, when a
179/// surviving grandchild is holding the pipe open. Measured from the last byte
180/// received, so a slow large read is never cut short.
181const DRAIN_GRACE: Duration = Duration::from_secs(3);
182
183/// A pipe drained on its own thread into a shared buffer.
184///
185/// Reading incrementally rather than with `read_to_end` is what lets the caller
186/// take the output without joining, which is what keeps the timeout honest.
187struct Reader {
188    buf: Arc<Mutex<Vec<u8>>>,
189    done: Arc<AtomicBool>,
190}
191
192impl Reader {
193    fn spawn<R: Read + Send + 'static>(mut pipe: R) -> Self {
194        let buf = Arc::new(Mutex::new(Vec::new()));
195        let done = Arc::new(AtomicBool::new(false));
196        let (buf_w, done_w) = (Arc::clone(&buf), Arc::clone(&done));
197        std::thread::spawn(move || {
198            let mut chunk = [0u8; 16 * 1024];
199            loop {
200                match pipe.read(&mut chunk) {
201                    Ok(0) | Err(_) => break,
202                    Ok(n) => buf_w
203                        .lock()
204                        .unwrap_or_else(|e| e.into_inner())
205                        .extend_from_slice(&chunk[..n]),
206                }
207            }
208            done_w.store(true, Ordering::Release);
209        });
210        Self { buf, done }
211    }
212
213    fn len(&self) -> usize {
214        self.buf.lock().unwrap_or_else(|e| e.into_inner()).len()
215    }
216
217    /// Everything received once the reader finishes, or once `grace` passes
218    /// with no new bytes.
219    ///
220    /// The poll starts fine grained and backs off. A run makes hundreds of git
221    /// and gh calls whose pipes are already at EOF by the time the child is
222    /// reaped, and a flat ten millisecond wait on each one is real wall clock
223    /// spent on nothing.
224    fn collect(&self, grace: Duration) -> Vec<u8> {
225        let mut last_len = self.len();
226        let mut quiet_since = Instant::now();
227        let mut poll = Duration::from_micros(100);
228        while !self.done.load(Ordering::Acquire) {
229            let now_len = self.len();
230            if now_len != last_len {
231                last_len = now_len;
232                quiet_since = Instant::now();
233            } else if quiet_since.elapsed() >= grace {
234                break;
235            }
236            std::thread::sleep(poll);
237            poll = (poll * 2).min(Duration::from_millis(10));
238        }
239        self.buf.lock().unwrap_or_else(|e| e.into_inner()).clone()
240    }
241}
242
243/// Run a command and return stdout. With `check` set, a non-zero exit is an
244/// error carrying both streams.
245pub fn run(argv: &[String], opts: &ExecOpts) -> Result<String> {
246    let out = exec(argv, opts)?;
247    if opts.check && !out.ok() {
248        return Err(SparError::new(failure_message(argv, &out)));
249    }
250    Ok(out.stdout)
251}
252
253/// Convenience for the many `run(&["git".into(), ...])` call sites.
254pub fn run_str(argv: &[&str], opts: &ExecOpts) -> Result<String> {
255    let owned: Vec<String> = argv.iter().map(|s| (*s).to_string()).collect();
256    run(&owned, opts)
257}
258
259/// Prompts run to many kilobytes. Echoing them whole buries the actual error.
260pub fn abbreviate(argv: &[String]) -> String {
261    argv.iter()
262        .map(|arg| {
263            let one_line = arg.split_whitespace().collect::<Vec<_>>().join(" ");
264            if one_line.chars().count() <= 60 {
265                one_line
266            } else {
267                let head: String = one_line.chars().take(57).collect();
268                format!("{head}...")
269            }
270        })
271        .collect::<Vec<_>>()
272        .join(" ")
273}
274
275/// Both streams, labelled. Preferring stderr is not enough: one agent CLI
276/// writes chatter like "Reading additional input from stdin..." to stderr on
277/// every run, which outranks the real reason and explains nothing, while
278/// another reports fatal conditions on stdout with stderr empty.
279pub fn failure_message(argv: &[String], out: &Output) -> String {
280    let mut parts = vec![format!(
281        "command failed ({}): {}",
282        out.code,
283        abbreviate(argv)
284    )];
285    for (label, stream) in [("stderr", &out.stderr), ("stdout", &out.stdout)] {
286        let text = stream.trim();
287        if !text.is_empty() {
288            parts.push(format!("--- {label} ---\n{}", tail(text, 1500)));
289        }
290    }
291    if parts.len() == 1 {
292        parts.push("(no output on either stream)".to_string());
293    }
294    parts.join("\n")
295}
296
297/// Last `max` characters, on a character boundary.
298fn tail(text: &str, max: usize) -> &str {
299    let count = text.chars().count();
300    if count <= max {
301        return text;
302    }
303    let start = text
304        .char_indices()
305        .nth(count - max)
306        .map(|(i, _)| i)
307        .unwrap_or(0);
308    &text[start..]
309}
310
311/// Whether a program is on PATH.
312pub fn which(program: &str) -> Option<PathBuf> {
313    if program.contains(std::path::MAIN_SEPARATOR) {
314        let path = PathBuf::from(program);
315        return is_executable(&path).then_some(path);
316    }
317    let paths = std::env::var_os("PATH")?;
318    std::env::split_paths(&paths).find_map(|dir| {
319        let candidate = dir.join(program);
320        is_executable(&candidate).then_some(candidate)
321    })
322}
323
324pub fn is_executable(path: &Path) -> bool {
325    #[cfg(unix)]
326    {
327        use std::os::unix::fs::PermissionsExt;
328        match std::fs::metadata(path) {
329            Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
330            Err(_) => false,
331        }
332    }
333    #[cfg(not(unix))]
334    {
335        path.is_file()
336    }
337}
338
339/// Expand a leading `~` against $HOME. Nothing else: a config path is not a
340/// shell word and should not behave like one.
341pub fn expand_tilde(path: &str) -> PathBuf {
342    if path == "~" {
343        if let Some(home) = home_dir() {
344            return home;
345        }
346    }
347    if let Some(rest) = path.strip_prefix("~/") {
348        if let Some(home) = home_dir() {
349            return home.join(rest);
350        }
351    }
352    PathBuf::from(path)
353}
354
355pub fn home_dir() -> Option<PathBuf> {
356    std::env::var_os("HOME").map(PathBuf::from)
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    fn proc(out: &str, err: &str, code: i32) -> Output {
364        Output {
365            stdout: out.into(),
366            stderr: err.into(),
367            code,
368        }
369    }
370
371    fn argv(parts: &[&str]) -> Vec<String> {
372        parts.iter().map(|s| s.to_string()).collect()
373    }
374
375    #[test]
376    fn stdout_used_when_stderr_is_empty() {
377        let msg = failure_message(&argv(&["claude"]), &proc("You've hit your limit.", "", 1));
378        assert!(msg.contains("hit your limit"), "{msg}");
379    }
380
381    #[test]
382    fn stderr_shown_when_present() {
383        let msg = failure_message(&argv(&["gh"]), &proc("noise", "real reason", 1));
384        assert!(msg.contains("real reason"), "{msg}");
385    }
386
387    #[test]
388    fn both_streams_are_shown_not_just_one() {
389        let msg = failure_message(&argv(&["gh"]), &proc("on stdout", "on stderr", 1));
390        assert!(
391            msg.contains("on stdout") && msg.contains("on stderr"),
392            "{msg}"
393        );
394    }
395
396    #[test]
397    fn says_something_when_both_are_empty() {
398        assert!(failure_message(&argv(&["x"]), &proc("", "", 2)).contains("no output"));
399    }
400
401    #[test]
402    fn long_arguments_are_abbreviated() {
403        let long = "word ".repeat(500);
404        let out = abbreviate(&argv(&["claude", "-p", &long]));
405        assert!(out.len() < 200, "{}", out.len());
406    }
407
408    #[test]
409    fn newlines_in_arguments_do_not_break_the_line() {
410        assert!(!abbreviate(&argv(&["claude", "a\nb\nc"])).contains('\n'));
411    }
412
413    #[test]
414    fn short_arguments_survive_intact() {
415        assert_eq!(
416            "gh pr merge 17",
417            abbreviate(&argv(&["gh", "pr", "merge", "17"]))
418        );
419    }
420
421    #[test]
422    fn abbreviation_never_splits_a_character() {
423        // A multi-byte argument longer than the cap must not panic on a slice
424        // that lands mid-character.
425        let wide = "\u{1f600}".repeat(200);
426        let out = abbreviate(&argv(&[&wide]));
427        assert!(out.ends_with("..."));
428    }
429
430    #[test]
431    fn exit_code_is_reported() {
432        let out = exec(
433            &argv(&["sh", "-c", "exit 3"]),
434            &ExecOpts::new().check(false),
435        )
436        .unwrap();
437        assert_eq!(3, out.code);
438    }
439
440    #[test]
441    fn check_false_returns_stdout_on_failure() {
442        let text = run(
443            &argv(&["sh", "-c", "echo partial; exit 1"]),
444            &ExecOpts::new().check(false),
445        )
446        .unwrap();
447        assert_eq!("partial\n", text);
448    }
449
450    #[test]
451    fn check_true_fails_loudly() {
452        let err = run(
453            &argv(&["sh", "-c", "echo why >&2; exit 1"]),
454            &ExecOpts::new(),
455        )
456        .unwrap_err();
457        assert!(err.to_string().contains("why"), "{err}");
458    }
459
460    #[test]
461    fn large_output_does_not_deadlock() {
462        // Well past a pipe buffer on every platform spar runs on.
463        let text = run(
464            &argv(&["sh", "-c", "yes hello | head -c 400000"]),
465            &ExecOpts::new().timeout_secs(60),
466        )
467        .unwrap();
468        assert_eq!(400_000, text.len());
469    }
470
471    /// The reason the readers are never joined. A grandchild that inherited
472    /// the pipe holds its write end open after its parent exits, so
473    /// `read_to_end` would never return and the deadline would bound nothing.
474    #[test]
475    fn a_surviving_grandchild_holding_the_pipe_cannot_hang_the_timeout() {
476        let start = Instant::now();
477        let err = run(
478            &argv(&["sh", "-c", "sleep 120 & echo parent-output; sleep 60"]),
479            &ExecOpts::new().timeout_secs(1),
480        )
481        .unwrap_err();
482        let elapsed = start.elapsed();
483
484        assert!(err.to_string().contains("timed out"), "{err}");
485        assert!(
486            elapsed < Duration::from_secs(20),
487            "the timeout did not bound the call: {elapsed:?}"
488        );
489    }
490
491    #[test]
492    fn a_surviving_grandchild_does_not_hang_a_normal_exit_either() {
493        let start = Instant::now();
494        let out = run(
495            &argv(&["sh", "-c", "sleep 120 & echo done"]),
496            &ExecOpts::new().timeout_secs(60),
497        )
498        .unwrap();
499        assert!(out.contains("done"), "{out:?}");
500        assert!(
501            start.elapsed() < Duration::from_secs(20),
502            "waited on a grandchild that will never exit"
503        );
504    }
505
506    #[test]
507    fn timeout_kills_and_explains() {
508        let err = run(
509            &argv(&["sh", "-c", "sleep 30"]),
510            &ExecOpts::new().timeout_secs(1),
511        )
512        .unwrap_err();
513        assert!(err.to_string().contains("timed out"), "{err}");
514    }
515
516    #[test]
517    fn missing_binary_names_the_command() {
518        let err = exec(
519            &argv(&["spar-definitely-not-a-real-binary"]),
520            &ExecOpts::new(),
521        )
522        .unwrap_err();
523        assert!(
524            err.to_string()
525                .contains("spar-definitely-not-a-real-binary"),
526            "{err}"
527        );
528    }
529
530    #[test]
531    fn tilde_expands_against_home() {
532        std::env::set_var("HOME", "/home/someone");
533        assert_eq!(PathBuf::from("/home/someone/bin"), expand_tilde("~/bin"));
534        assert_eq!(PathBuf::from("/absolute"), expand_tilde("/absolute"));
535        assert_eq!(PathBuf::from("~notauser/x"), expand_tilde("~notauser/x"));
536    }
537}