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