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    /// On Unix, put the command in its own process group and stop descendants
41    /// when the direct child exits. Agent calls use this to establish a quiet
42    /// point before their working tree is inspected or committed.
43    pub stop_descendants: bool,
44}
45
46impl Default for ExecOpts {
47    fn default() -> Self {
48        Self {
49            cwd: None,
50            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
51            check: true,
52            env: Vec::new(),
53            stdin: None,
54            stop_descendants: false,
55        }
56    }
57}
58
59impl ExecOpts {
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    pub fn cwd(mut self, path: impl AsRef<Path>) -> Self {
65        self.cwd = Some(path.as_ref().to_path_buf());
66        self
67    }
68
69    pub fn cwd_opt(mut self, path: Option<PathBuf>) -> Self {
70        self.cwd = path;
71        self
72    }
73
74    pub fn timeout_secs(mut self, secs: u64) -> Self {
75        self.timeout = Duration::from_secs(secs);
76        self
77    }
78
79    pub fn check(mut self, value: bool) -> Self {
80        self.check = value;
81        self
82    }
83
84    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
85        self.env.push((key.into(), value.into()));
86        self
87    }
88
89    pub fn stdin(mut self, text: impl Into<String>) -> Self {
90        self.stdin = Some(text.into());
91        self
92    }
93
94    pub fn stop_descendants(mut self, value: bool) -> Self {
95        self.stop_descendants = value;
96        self
97    }
98}
99
100#[derive(Debug, Clone)]
101pub struct Output {
102    pub stdout: String,
103    /// Exact stdout bytes for commands whose output carries filesystem paths.
104    pub stdout_bytes: Vec<u8>,
105    pub stderr: String,
106    pub code: i32,
107}
108
109impl Output {
110    pub fn ok(&self) -> bool {
111        self.code == 0
112    }
113}
114
115/// Run a command to completion, capturing both streams.
116///
117/// Only spawn failures and timeouts are errors here; a non-zero exit is a
118/// normal result that the caller decides how to treat.
119pub fn exec(argv: &[String], opts: &ExecOpts) -> Result<Output> {
120    let input = opts.stdin.as_deref().map(str::as_bytes);
121    exec_with_input(argv, opts, input)
122}
123
124/// Set once the process has been told to stop, by Ctrl-C or SIGTERM, when
125/// `stop_on_signal` has been installed. Every command that is running checks it
126/// and stops its child, and the idle sleep in `spar listen` checks it too.
127static INTERRUPTED: AtomicBool = AtomicBool::new(false);
128
129/// Whether the process has been told to stop.
130pub fn interrupted() -> bool {
131    INTERRUPTED.load(Ordering::SeqCst)
132}
133
134#[cfg(unix)]
135extern "C" fn note_signal(sig: libc::c_int) {
136    INTERRUPTED.store(true, Ordering::SeqCst);
137    // Only calls that are safe inside a signal handler belong here. Restoring
138    // the default disposition means a second signal ends the process at once,
139    // for somebody who does not want to wait for the current call to stop.
140    unsafe {
141        libc::signal(sig, libc::SIG_DFL);
142    }
143}
144
145/// Turn SIGINT and SIGTERM into a flag that running commands and idle sleeps
146/// check, instead of the default action that ends the process where it stands.
147///
148/// Installed by `spar listen` alone. Every other command keeps the default, so
149/// nothing about them changes. The default would leave an agent CLI running:
150/// agent calls are started in their own process group, which is what keeps a
151/// terminal's Ctrl-C from reaching them.
152#[cfg(unix)]
153pub fn stop_on_signal() -> Result<()> {
154    for sig in [libc::SIGINT, libc::SIGTERM] {
155        // SAFETY: a zeroed sigaction is a valid starting point on every platform
156        // libc supports, and the handler does nothing a signal handler may not.
157        let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
158        action.sa_sigaction = note_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
159        // Reads on the output pipes restart rather than fail.
160        action.sa_flags = libc::SA_RESTART;
161        let failed = unsafe {
162            libc::sigemptyset(&mut action.sa_mask);
163            libc::sigaction(sig, &action, std::ptr::null_mut()) != 0
164        };
165        if failed {
166            return Err(SparError::new(format!(
167                "could not watch for Ctrl-C: {}",
168                std::io::Error::last_os_error()
169            )));
170        }
171    }
172    Ok(())
173}
174
175#[cfg(not(unix))]
176pub fn stop_on_signal() -> Result<()> {
177    Ok(())
178}
179
180/// Sleep for `total`, waking early if the process is told to stop. True when
181/// it woke early.
182pub fn sleep_unless_interrupted(total: Duration) -> bool {
183    sleep_until(total, &INTERRUPTED)
184}
185
186fn sleep_until(total: Duration, stop: &AtomicBool) -> bool {
187    let deadline = Instant::now() + total;
188    while !stop.load(Ordering::SeqCst) {
189        let left = deadline.saturating_duration_since(Instant::now());
190        if left.is_zero() {
191            return false;
192        }
193        std::thread::sleep(left.min(Duration::from_secs(1)));
194    }
195    true
196}
197
198fn exec_with_input(argv: &[String], opts: &ExecOpts, input: Option<&[u8]>) -> Result<Output> {
199    exec_until(argv, opts, input, &INTERRUPTED)
200}
201
202/// `exec_with_input`, stopping the child as soon as `stop` is set. The flag is
203/// a parameter so the kill path can be tested without touching the process
204/// wide one that every other test's commands check.
205fn exec_until(
206    argv: &[String],
207    opts: &ExecOpts,
208    input: Option<&[u8]>,
209    stop: &AtomicBool,
210) -> Result<Output> {
211    if stop.load(Ordering::SeqCst) {
212        return Err(SparError::interrupted(format!(
213            "interrupted before `{}` started",
214            abbreviate(argv)
215        )));
216    }
217    let program = argv
218        .first()
219        .ok_or_else(|| SparError::new("cannot run an empty command"))?;
220
221    let mut command = Command::new(program);
222    command
223        .args(&argv[1..])
224        .stdout(Stdio::piped())
225        .stderr(Stdio::piped());
226
227    if input.is_some() {
228        command.stdin(Stdio::piped());
229    } else {
230        // Agent CLIs happily block forever waiting on an inherited terminal.
231        command.stdin(Stdio::null());
232    }
233
234    if let Some(dir) = &opts.cwd {
235        command.current_dir(dir);
236    }
237    for (key, value) in &opts.env {
238        command.env(key, value);
239    }
240
241    #[cfg(unix)]
242    if opts.stop_descendants {
243        use std::os::unix::process::CommandExt;
244        command.process_group(0);
245    }
246
247    let mut child = command
248        .spawn()
249        .map_err(|e| SparError::new(format!("could not run `{}`: {e}", abbreviate(argv))))?;
250
251    // Readers first, writer second, and the writer on its own thread.
252    //
253    // A child that writes as it reads, `git check-attr --stdin` being the one
254    // that bit, blocks on a full stdout pipe long before it has drained an
255    // input larger than the pipe buffer. Writing stdin here on the calling
256    // thread would then block against a child blocked on output nobody is
257    // reading yet, and because that happens before the poll loop below, the
258    // timeout would never fire. Neither side can stall now: output is draining
259    // from the moment the child starts.
260    let out_reader = Reader::spawn(child.stdout.take().expect("stdout piped"));
261    let err_reader = Reader::spawn(child.stderr.take().expect("stderr piped"));
262
263    if let Some(bytes) = input {
264        if let Some(mut pipe) = child.stdin.take() {
265            let owned = bytes.to_vec();
266            // Detached: a child that exits early leaves the write erroring with
267            // a broken pipe, which is its answer and not a failure of ours.
268            // Dropping the handle at the end of the thread closes the pipe,
269            // which the child needs in order to see EOF and exit.
270            std::thread::spawn(move || {
271                let _ = pipe.write_all(&owned);
272            });
273        }
274    }
275
276    let deadline = Instant::now() + opts.timeout;
277    let mut poll = Duration::from_millis(5);
278    let mut timed_out = false;
279    let mut stopped = false;
280    let mut quiet_error = None;
281    let status = loop {
282        match child.try_wait() {
283            Ok(Some(status)) => break Some(status),
284            Ok(None) => {
285                if stop.load(Ordering::SeqCst) {
286                    if let Err(error) = stop_and_reap(&mut child, opts.stop_descendants) {
287                        quiet_error = Some(error);
288                    }
289                    stopped = true;
290                    break None;
291                }
292                if Instant::now() >= deadline {
293                    if let Err(error) = stop_and_reap(&mut child, opts.stop_descendants) {
294                        quiet_error = Some(error);
295                    }
296                    timed_out = true;
297                    break None;
298                }
299                std::thread::sleep(poll);
300                // Back off so a long agent run is not a busy loop, but stay
301                // responsive for the many fast git and gh calls.
302                poll = (poll * 2).min(Duration::from_millis(100));
303            }
304            Err(error) => {
305                return Err(poll_failure(error, argv, || {
306                    stop_and_reap(&mut child, opts.stop_descendants)
307                }));
308            }
309        }
310    };
311
312    #[cfg(unix)]
313    if !timed_out && opts.stop_descendants {
314        if let Err(error) = stop_process_group(child.id()) {
315            quiet_error = Some(error);
316        }
317    }
318
319    // Never join the readers.
320    //
321    // An agent CLI runs shell commands, and any grandchild that inherited the
322    // pipe keeps its write end open after its parent exits. `read_to_end` on
323    // such a pipe never returns, so joining would hang past the deadline and
324    // the timeout would bound nothing at all. Instead, wait for the readers to
325    // finish or for the output to stop arriving, then take what did.
326    let stdout = out_reader.collect(DRAIN_GRACE);
327    let stderr = err_reader.collect(DRAIN_GRACE);
328
329    if stopped {
330        let cleanup = quiet_error
331            .map(|error| format!(" Stopping it also failed: {error}."))
332            .unwrap_or_default();
333        return Err(SparError::interrupted(format!(
334            "interrupted: {}.{cleanup}",
335            abbreviate(argv)
336        )));
337    }
338    if let Some(error) = quiet_error {
339        return Err(quiet_point_failure(error, argv));
340    }
341
342    if timed_out {
343        #[cfg(not(unix))]
344        if opts.stop_descendants {
345            return Err(SparError::uncertain_write(format!(
346                "timed out after {}s: {}\nThe direct process was stopped, but remaining child \
347                 processes could not be stopped on this platform. The worktree must be inspected \
348                 before retrying.",
349                opts.timeout.as_secs(),
350                abbreviate(argv)
351            )));
352        }
353        return Err(SparError::timed_out(format!(
354            "timed out after {}s: {}\nRaise `timeout` on this agent in spar.toml if the model \
355             legitimately needs longer. Not retried: asking again would wait exactly as long a \
356             second time.",
357            opts.timeout.as_secs(),
358            abbreviate(argv)
359        )));
360    }
361
362    Ok(Output {
363        stdout: String::from_utf8_lossy(&stdout).into_owned(),
364        stdout_bytes: stdout,
365        stderr: String::from_utf8_lossy(&stderr).into_owned(),
366        code: status.and_then(|s| s.code()).unwrap_or(-1),
367    })
368}
369
370fn stop_and_reap(child: &mut std::process::Child, stop_descendants: bool) -> std::io::Result<()> {
371    #[cfg(not(unix))]
372    let _ = stop_descendants;
373
374    #[cfg(unix)]
375    if stop_descendants {
376        if let Err(group_error) = stop_process_group(child.id()) {
377            if child.kill().is_ok() {
378                let _ = child.wait();
379            }
380            return Err(group_error);
381        }
382        return child.wait().map(|_| ());
383    }
384
385    match child.kill() {
386        Ok(()) => child.wait().map(|_| ()),
387        Err(kill_error) => match child.try_wait() {
388            Ok(Some(_)) => Ok(()),
389            Ok(None) => Err(kill_error),
390            Err(poll_error) => Err(std::io::Error::new(
391                poll_error.kind(),
392                format!("could not stop the direct process: {kill_error}; {poll_error}"),
393            )),
394        },
395    }
396}
397
398fn poll_failure(
399    error: std::io::Error,
400    argv: &[String],
401    stop_and_reap: impl FnOnce() -> std::io::Result<()>,
402) -> SparError {
403    let cleanup = stop_and_reap()
404        .err()
405        .map(|cleanup| format!(" Cleanup also failed: {cleanup}."))
406        .unwrap_or_default();
407    SparError::uncertain_write(format!(
408        "could not confirm whether `{}` stopped: {error}. SPAR attempted to stop and reap the \
409         direct process and its descendants.{cleanup} The worktree must be inspected before \
410         retrying.",
411        abbreviate(argv),
412    ))
413}
414
415fn quiet_point_failure(error: std::io::Error, argv: &[String]) -> SparError {
416    SparError::uncertain_write(format!(
417        "could not establish a quiet point after `{}`: {error}. The worktree must be inspected \
418         before retrying.",
419        abbreviate(argv)
420    ))
421}
422
423#[cfg(unix)]
424fn stop_process_group(id: u32) -> std::io::Result<()> {
425    let id = i32::try_from(id).map_err(|_| {
426        std::io::Error::new(
427            std::io::ErrorKind::InvalidInput,
428            "the child process id does not fit a platform process-group id",
429        )
430    })?;
431    // The child was placed in a new process group whose id is its pid. A
432    // negative pid sends the signal to that group, including descendants that
433    // kept running after the direct command exited.
434    if unsafe { libc::kill(-id, libc::SIGKILL) } == 0 {
435        return Ok(());
436    }
437    let error = std::io::Error::last_os_error();
438    if error.raw_os_error() == Some(libc::ESRCH) {
439        return Ok(());
440    }
441    Err(error)
442}
443
444/// How long to keep waiting for output after the child has exited, when a
445/// surviving grandchild is holding the pipe open. Measured from the last byte
446/// received, so a slow large read is never cut short.
447const DRAIN_GRACE: Duration = Duration::from_secs(3);
448
449/// A pipe drained on its own thread into a shared buffer.
450///
451/// Reading incrementally rather than with `read_to_end` is what lets the caller
452/// take the output without joining, which is what keeps the timeout honest.
453struct Reader {
454    buf: Arc<Mutex<Vec<u8>>>,
455    done: Arc<AtomicBool>,
456}
457
458impl Reader {
459    fn spawn<R: Read + Send + 'static>(mut pipe: R) -> Self {
460        let buf = Arc::new(Mutex::new(Vec::new()));
461        let done = Arc::new(AtomicBool::new(false));
462        let (buf_w, done_w) = (Arc::clone(&buf), Arc::clone(&done));
463        std::thread::spawn(move || {
464            let mut chunk = [0u8; 16 * 1024];
465            loop {
466                match pipe.read(&mut chunk) {
467                    Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
468                    Ok(0) | Err(_) => break,
469                    Ok(n) => buf_w
470                        .lock()
471                        .unwrap_or_else(|e| e.into_inner())
472                        .extend_from_slice(&chunk[..n]),
473                }
474            }
475            done_w.store(true, Ordering::Release);
476        });
477        Self { buf, done }
478    }
479
480    fn len(&self) -> usize {
481        self.buf.lock().unwrap_or_else(|e| e.into_inner()).len()
482    }
483
484    /// Everything received once the reader finishes, or once `grace` passes
485    /// with no new bytes.
486    ///
487    /// The poll starts fine grained and backs off. A run makes hundreds of git
488    /// and gh calls whose pipes are already at EOF by the time the child is
489    /// reaped, and a flat ten millisecond wait on each one is real wall clock
490    /// spent on nothing.
491    fn collect(&self, grace: Duration) -> Vec<u8> {
492        let mut last_len = self.len();
493        let mut quiet_since = Instant::now();
494        let mut poll = Duration::from_micros(100);
495        while !self.done.load(Ordering::Acquire) {
496            let now_len = self.len();
497            if now_len != last_len {
498                last_len = now_len;
499                quiet_since = Instant::now();
500            } else if quiet_since.elapsed() >= grace {
501                break;
502            }
503            std::thread::sleep(poll);
504            poll = (poll * 2).min(Duration::from_millis(10));
505        }
506        self.buf.lock().unwrap_or_else(|e| e.into_inner()).clone()
507    }
508}
509
510/// Run a command and return stdout. With `check` set, a non-zero exit is an
511/// error carrying both streams.
512pub fn run(argv: &[String], opts: &ExecOpts) -> Result<String> {
513    let out = exec(argv, opts)?;
514    if opts.check && !out.ok() {
515        return Err(SparError::call_failed(failure_message(argv, &out)));
516    }
517    Ok(out.stdout)
518}
519
520pub(crate) fn run_with_input_bytes(
521    argv: &[String],
522    opts: &ExecOpts,
523    input: &[u8],
524) -> Result<String> {
525    let out = exec_with_input(argv, opts, Some(input))?;
526    if opts.check && !out.ok() {
527        return Err(SparError::call_failed(failure_message(argv, &out)));
528    }
529    Ok(out.stdout)
530}
531
532/// Run a command and return stdout without changing non-UTF-8 bytes.
533pub fn run_bytes(argv: &[String], opts: &ExecOpts) -> Result<Vec<u8>> {
534    let out = exec(argv, opts)?;
535    if opts.check && !out.ok() {
536        return Err(SparError::call_failed(failure_message(argv, &out)));
537    }
538    Ok(out.stdout_bytes)
539}
540
541/// Convenience for the many `run(&["git".into(), ...])` call sites.
542pub fn run_str(argv: &[&str], opts: &ExecOpts) -> Result<String> {
543    let owned: Vec<String> = argv.iter().map(|s| (*s).to_string()).collect();
544    run(&owned, opts)
545}
546
547/// Prompts run to many kilobytes. Echoing them whole buries the actual error.
548pub fn abbreviate(argv: &[String]) -> String {
549    argv.iter()
550        .map(|arg| {
551            let one_line = arg.split_whitespace().collect::<Vec<_>>().join(" ");
552            if one_line.chars().count() <= 60 {
553                one_line
554            } else {
555                let head: String = one_line.chars().take(57).collect();
556                format!("{head}...")
557            }
558        })
559        .collect::<Vec<_>>()
560        .join(" ")
561}
562
563/// Both streams, labelled. Preferring stderr is not enough: one agent CLI
564/// writes chatter like "Reading additional input from stdin..." to stderr on
565/// every run, which outranks the real reason and explains nothing, while
566/// another reports fatal conditions on stdout with stderr empty.
567pub fn failure_message(argv: &[String], out: &Output) -> String {
568    let mut parts = vec![format!(
569        "command failed ({}): {}",
570        out.code,
571        abbreviate(argv)
572    )];
573    for (label, stream) in [("stderr", &out.stderr), ("stdout", &out.stdout)] {
574        let text = stream.trim();
575        if !text.is_empty() {
576            parts.push(format!("--- {label} ---\n{}", tail(text, 1500)));
577        }
578    }
579    if parts.len() == 1 {
580        parts.push("(no output on either stream)".to_string());
581    }
582    parts.join("\n")
583}
584
585/// Last `max` characters, on a character boundary.
586fn tail(text: &str, max: usize) -> &str {
587    let count = text.chars().count();
588    if count <= max {
589        return text;
590    }
591    let start = text
592        .char_indices()
593        .nth(count - max)
594        .map(|(i, _)| i)
595        .unwrap_or(0);
596    &text[start..]
597}
598
599/// Whether a program is on PATH.
600pub fn which(program: &str) -> Option<PathBuf> {
601    if program.contains(std::path::MAIN_SEPARATOR) {
602        let path = PathBuf::from(program);
603        return is_executable(&path).then_some(path);
604    }
605    let paths = std::env::var_os("PATH")?;
606    std::env::split_paths(&paths).find_map(|dir| {
607        let candidate = dir.join(program);
608        is_executable(&candidate).then_some(candidate)
609    })
610}
611
612pub fn is_executable(path: &Path) -> bool {
613    #[cfg(unix)]
614    {
615        use std::os::unix::fs::PermissionsExt;
616        match std::fs::metadata(path) {
617            Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
618            Err(_) => false,
619        }
620    }
621    #[cfg(not(unix))]
622    {
623        path.is_file()
624    }
625}
626
627/// Expand a leading `~` against $HOME. Nothing else: a config path is not a
628/// shell word and should not behave like one.
629pub fn expand_tilde(path: &str) -> PathBuf {
630    if path == "~" {
631        if let Some(home) = home_dir() {
632            return home;
633        }
634    }
635    if let Some(rest) = path.strip_prefix("~/") {
636        if let Some(home) = home_dir() {
637            return home.join(rest);
638        }
639    }
640    PathBuf::from(path)
641}
642
643pub fn home_dir() -> Option<PathBuf> {
644    std::env::var_os("HOME").map(PathBuf::from)
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    fn proc(out: &str, err: &str, code: i32) -> Output {
652        Output {
653            stdout: out.into(),
654            stdout_bytes: out.as_bytes().to_vec(),
655            stderr: err.into(),
656            code,
657        }
658    }
659
660    fn argv(parts: &[&str]) -> Vec<String> {
661        parts.iter().map(|s| s.to_string()).collect()
662    }
663
664    #[test]
665    fn a_poll_failure_attempts_cleanup_and_is_uncertain() {
666        let stopped = std::cell::Cell::new(false);
667        let error = poll_failure(
668            std::io::Error::other("poll failed"),
669            &argv(&["editor"]),
670            || {
671                stopped.set(true);
672                Ok(())
673            },
674        );
675
676        assert!(stopped.get());
677        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
678        assert!(error.to_string().contains("poll failed"), "{error}");
679    }
680
681    #[test]
682    fn a_stop_or_reap_failure_is_uncertain() {
683        let error =
684            quiet_point_failure(std::io::Error::other("could not reap"), &argv(&["editor"]));
685
686        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
687        assert!(error.to_string().contains("could not reap"), "{error}");
688    }
689
690    #[test]
691    fn stdin_reaches_the_child_byte_for_byte() {
692        let body = "line one  \n\nline three\n";
693        let out = run_str(&["/bin/sh", "-c", "cat"], &ExecOpts::new().stdin(body))
694            .expect("the child reads stdin");
695        assert_eq!(body, out);
696    }
697
698    #[test]
699    fn byte_input_reaches_the_child_without_text_conversion() {
700        let input = [b'f', 0xff, 0];
701        let command = argv(&["/bin/sh", "-c", "cat"]);
702        let out = exec_with_input(&command, &ExecOpts::new(), Some(&input))
703            .expect("the child reads byte input");
704
705        assert_eq!(input, out.stdout_bytes.as_slice());
706    }
707
708    #[test]
709    fn stdout_used_when_stderr_is_empty() {
710        let msg = failure_message(&argv(&["claude"]), &proc("You've hit your limit.", "", 1));
711        assert!(msg.contains("hit your limit"), "{msg}");
712    }
713
714    #[test]
715    fn stderr_shown_when_present() {
716        let msg = failure_message(&argv(&["gh"]), &proc("noise", "real reason", 1));
717        assert!(msg.contains("real reason"), "{msg}");
718    }
719
720    #[test]
721    fn both_streams_are_shown_not_just_one() {
722        let msg = failure_message(&argv(&["gh"]), &proc("on stdout", "on stderr", 1));
723        assert!(
724            msg.contains("on stdout") && msg.contains("on stderr"),
725            "{msg}"
726        );
727    }
728
729    #[test]
730    fn says_something_when_both_are_empty() {
731        assert!(failure_message(&argv(&["x"]), &proc("", "", 2)).contains("no output"));
732    }
733
734    #[test]
735    fn long_arguments_are_abbreviated() {
736        let long = "word ".repeat(500);
737        let out = abbreviate(&argv(&["claude", "-p", &long]));
738        assert!(out.len() < 200, "{}", out.len());
739    }
740
741    #[test]
742    fn newlines_in_arguments_do_not_break_the_line() {
743        assert!(!abbreviate(&argv(&["claude", "a\nb\nc"])).contains('\n'));
744    }
745
746    #[test]
747    fn short_arguments_survive_intact() {
748        assert_eq!(
749            "gh pr merge 17",
750            abbreviate(&argv(&["gh", "pr", "merge", "17"]))
751        );
752    }
753
754    #[test]
755    fn abbreviation_never_splits_a_character() {
756        // A multi-byte argument longer than the cap must not panic on a slice
757        // that lands mid-character.
758        let wide = "\u{1f600}".repeat(200);
759        let out = abbreviate(&argv(&[&wide]));
760        assert!(out.ends_with("..."));
761    }
762
763    #[test]
764    fn exit_code_is_reported() {
765        let out = exec(
766            &argv(&["sh", "-c", "exit 3"]),
767            &ExecOpts::new().check(false),
768        )
769        .unwrap();
770        assert_eq!(3, out.code);
771    }
772
773    #[test]
774    fn check_false_returns_stdout_on_failure() {
775        let text = run(
776            &argv(&["sh", "-c", "echo partial; exit 1"]),
777            &ExecOpts::new().check(false),
778        )
779        .unwrap();
780        assert_eq!("partial\n", text);
781    }
782
783    #[test]
784    fn check_true_fails_loudly() {
785        let err = run(
786            &argv(&["sh", "-c", "echo why >&2; exit 1"]),
787            &ExecOpts::new(),
788        )
789        .unwrap_err();
790        assert!(err.to_string().contains("why"), "{err}");
791    }
792
793    #[test]
794    fn large_output_does_not_deadlock() {
795        // Well past a pipe buffer on every platform spar runs on.
796        let text = run(
797            &argv(&["sh", "-c", "yes hello | head -c 400000"]),
798            &ExecOpts::new().timeout_secs(60),
799        )
800        .unwrap();
801        assert_eq!(400_000, text.len());
802    }
803
804    /// Input larger than a pipe buffer, from a child that answers as it reads.
805    ///
806    /// This is the shape `git check-attr --stdin` has on a repository of a few
807    /// thousand files. Writing stdin before the readers started deadlocked the
808    /// pair: the child blocked writing to a stdout pipe nobody was draining,
809    /// spar blocked writing the rest of stdin, and the timeout never fired
810    /// because the poll loop had not been reached.
811    #[test]
812    fn large_input_and_large_output_together_do_not_deadlock() {
813        let line = "a".repeat(199);
814        let input = std::iter::repeat_n(line.as_str(), 5_000)
815            .collect::<Vec<_>>()
816            .join("\n");
817        assert!(input.len() > 900_000, "the input has to outgrow the pipe");
818
819        let start = Instant::now();
820        // `cat` on both streams, so the child is writing while it is still
821        // reading and neither pipe can be left unattended.
822        let out = run(
823            &argv(&["sh", "-c", "tee /dev/stderr"]),
824            &ExecOpts::new().timeout_secs(60).stdin(input.clone()),
825        )
826        .unwrap();
827
828        assert_eq!(input.len(), out.trim_end().len());
829        assert!(
830            start.elapsed() < Duration::from_secs(30),
831            "the call did not return promptly: {:?}",
832            start.elapsed()
833        );
834    }
835
836    /// The reason the readers are never joined. A grandchild that inherited
837    /// the pipe holds its write end open after its parent exits, so
838    /// `read_to_end` would never return and the deadline would bound nothing.
839    #[test]
840    fn a_surviving_grandchild_holding_the_pipe_cannot_hang_the_timeout() {
841        let start = Instant::now();
842        let err = run(
843            &argv(&["sh", "-c", "sleep 120 & echo parent-output; sleep 60"]),
844            &ExecOpts::new().timeout_secs(1),
845        )
846        .unwrap_err();
847        let elapsed = start.elapsed();
848
849        assert!(err.to_string().contains("timed out"), "{err}");
850        assert!(
851            elapsed < Duration::from_secs(20),
852            "the timeout did not bound the call: {elapsed:?}"
853        );
854    }
855
856    #[test]
857    fn a_surviving_grandchild_does_not_hang_a_normal_exit_either() {
858        let start = Instant::now();
859        let out = run(
860            &argv(&["sh", "-c", "sleep 120 & echo done"]),
861            &ExecOpts::new().timeout_secs(60),
862        )
863        .unwrap();
864        assert!(out.contains("done"), "{out:?}");
865        assert!(
866            start.elapsed() < Duration::from_secs(20),
867            "waited on a grandchild that will never exit"
868        );
869    }
870
871    #[cfg(unix)]
872    #[test]
873    fn a_stopped_process_group_cannot_edit_after_the_parent_exits() {
874        let late = std::env::temp_dir().join(format!(
875            "spar-late-child-{}-{}",
876            std::process::id(),
877            std::thread::current().name().unwrap_or("test")
878        ));
879        let _ = std::fs::remove_file(&late);
880        let out = run(
881            &argv(&[
882                "/bin/sh",
883                "-c",
884                "(sleep 1; touch \"$1\") & echo done",
885                "sh",
886                late.to_str().unwrap(),
887            ]),
888            &ExecOpts::new().stop_descendants(true),
889        )
890        .unwrap();
891
892        assert!(out.contains("done"), "{out:?}");
893        std::thread::sleep(Duration::from_millis(1_200));
894        assert!(!late.exists(), "a descendant edited after the quiet point");
895    }
896
897    #[test]
898    fn timeout_kills_and_explains() {
899        let err = run(
900            &argv(&["sh", "-c", "sleep 30"]),
901            &ExecOpts::new().timeout_secs(1),
902        )
903        .unwrap_err();
904        assert!(err.to_string().contains("timed out"), "{err}");
905    }
906
907    #[test]
908    fn missing_binary_names_the_command() {
909        let err = exec(
910            &argv(&["spar-definitely-not-a-real-binary"]),
911            &ExecOpts::new(),
912        )
913        .unwrap_err();
914        assert!(
915            err.to_string()
916                .contains("spar-definitely-not-a-real-binary"),
917            "{err}"
918        );
919    }
920
921    #[test]
922    fn tilde_expands_against_home() {
923        std::env::set_var("HOME", "/home/someone");
924        assert_eq!(PathBuf::from("/home/someone/bin"), expand_tilde("~/bin"));
925        assert_eq!(PathBuf::from("/absolute"), expand_tilde("/absolute"));
926        assert_eq!(PathBuf::from("~notauser/x"), expand_tilde("~notauser/x"));
927    }
928}
929
930#[cfg(test)]
931mod timeout_kind_tests {
932    use super::*;
933    use crate::error::ErrorKind;
934
935    fn argv(parts: &[&str]) -> Vec<String> {
936        parts.iter().map(|s| s.to_string()).collect()
937    }
938
939    /// A deadline is not a bad answer. Retrying one buys another wait of
940    /// exactly the same length, which on a review at the highest effort
941    /// setting turned a thirty minute failure into an hour of it.
942    #[test]
943    fn a_timeout_is_marked_as_one_and_is_not_worth_retrying() {
944        let err = run(
945            &["sh".to_string(), "-c".to_string(), "sleep 30".to_string()],
946            &ExecOpts::new().timeout_secs(1),
947        )
948        .unwrap_err();
949
950        assert_eq!(ErrorKind::TimedOut, err.kind());
951        assert!(!err.worth_retrying());
952        assert!(err.to_string().contains("Not retried"), "{err}");
953    }
954
955    /// `spar listen` runs for hours. Ctrl-C has to stop the agent call in
956    /// flight rather than leave it running in its own process group with
957    /// nobody to read its answer.
958    #[test]
959    fn a_cancelled_command_is_stopped_and_says_so() {
960        let stop = Arc::new(AtomicBool::new(false));
961        let flag = Arc::clone(&stop);
962        std::thread::spawn(move || {
963            std::thread::sleep(Duration::from_millis(200));
964            flag.store(true, Ordering::SeqCst);
965        });
966        let started = Instant::now();
967        let err = exec_until(
968            &argv(&["sh", "-c", "sleep 30"]),
969            &ExecOpts::new().timeout_secs(30).stop_descendants(true),
970            None,
971            &stop,
972        )
973        .unwrap_err();
974
975        assert_eq!(ErrorKind::Interrupted, err.kind());
976        assert!(
977            err.to_string().starts_with("interrupted: sh -c sleep 30"),
978            "{err}"
979        );
980        assert!(
981            started.elapsed() < Duration::from_secs(5),
982            "took {:?}",
983            started.elapsed()
984        );
985        assert!(!err.worth_retrying());
986    }
987
988    #[test]
989    fn a_command_asked_for_after_the_stop_is_refused_without_running() {
990        let stop = AtomicBool::new(true);
991        let err = exec_until(
992            &argv(&["sh", "-c", "exit 0"]),
993            &ExecOpts::new(),
994            None,
995            &stop,
996        )
997        .unwrap_err();
998        assert_eq!(ErrorKind::Interrupted, err.kind());
999        assert!(err.to_string().contains("before"), "{err}");
1000    }
1001
1002    #[test]
1003    fn a_sleep_ends_early_once_the_stop_is_set_and_on_time_otherwise() {
1004        let stop = Arc::new(AtomicBool::new(false));
1005        let flag = Arc::clone(&stop);
1006        std::thread::spawn(move || {
1007            std::thread::sleep(Duration::from_millis(100));
1008            flag.store(true, Ordering::SeqCst);
1009        });
1010        let started = Instant::now();
1011        assert!(sleep_until(Duration::from_secs(30), &stop));
1012        assert!(started.elapsed() < Duration::from_secs(5));
1013
1014        let calm = AtomicBool::new(false);
1015        let started = Instant::now();
1016        assert!(!sleep_until(Duration::from_millis(50), &calm));
1017        assert!(started.elapsed() >= Duration::from_millis(50));
1018    }
1019
1020    /// A non-zero exit is the CLI reporting that it could not answer, which is
1021    /// a different thing from an answer that arrived and could not be parsed.
1022    /// Only the second is what the retry exists for.
1023    #[test]
1024    fn a_non_zero_exit_is_the_call_failing_rather_than_the_answer() {
1025        let err = run(
1026            &["sh".to_string(), "-c".to_string(), "exit 1".to_string()],
1027            &ExecOpts::new(),
1028        )
1029        .unwrap_err();
1030
1031        assert_eq!(ErrorKind::CallFailed, err.kind());
1032        // Not hopeless in the abstract, since it could have been transient.
1033        // Whether to spend a second call on it is `Agent`'s decision, and it
1034        // turns on whether there is a stand in to send the call to instead.
1035        assert!(err.worth_retrying());
1036    }
1037}