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 every
41    /// descendant when the direct child exits, times out, or is interrupted.
42    /// That includes descendants that moved to a group or session of their
43    /// own, which is where agent CLIs run their shells. Agent calls use this
44    /// to establish a quiet point before their working tree is inspected or
45    /// committed.
46    pub stop_descendants: bool,
47}
48
49impl Default for ExecOpts {
50    fn default() -> Self {
51        Self {
52            cwd: None,
53            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
54            check: true,
55            env: Vec::new(),
56            stdin: None,
57            stop_descendants: false,
58        }
59    }
60}
61
62impl ExecOpts {
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    pub fn cwd(mut self, path: impl AsRef<Path>) -> Self {
68        self.cwd = Some(path.as_ref().to_path_buf());
69        self
70    }
71
72    pub fn cwd_opt(mut self, path: Option<PathBuf>) -> Self {
73        self.cwd = path;
74        self
75    }
76
77    pub fn timeout_secs(mut self, secs: u64) -> Self {
78        self.timeout = Duration::from_secs(secs);
79        self
80    }
81
82    pub fn check(mut self, value: bool) -> Self {
83        self.check = value;
84        self
85    }
86
87    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
88        self.env.push((key.into(), value.into()));
89        self
90    }
91
92    pub fn stdin(mut self, text: impl Into<String>) -> Self {
93        self.stdin = Some(text.into());
94        self
95    }
96
97    pub fn stop_descendants(mut self, value: bool) -> Self {
98        self.stop_descendants = value;
99        self
100    }
101}
102
103#[derive(Debug, Clone)]
104pub struct Output {
105    pub stdout: String,
106    /// Exact stdout bytes for commands whose output carries filesystem paths.
107    pub stdout_bytes: Vec<u8>,
108    pub stderr: String,
109    pub code: i32,
110}
111
112impl Output {
113    pub fn ok(&self) -> bool {
114        self.code == 0
115    }
116}
117
118/// Run a command to completion, capturing both streams.
119///
120/// Only spawn failures and timeouts are errors here; a non-zero exit is a
121/// normal result that the caller decides how to treat.
122pub fn exec(argv: &[String], opts: &ExecOpts) -> Result<Output> {
123    let input = opts.stdin.as_deref().map(str::as_bytes);
124    exec_with_input(argv, opts, input)
125}
126
127/// Set once the process has been told to stop, by Ctrl-C or SIGTERM, when
128/// `stop_on_signal` has been installed. Every command that is running checks it
129/// and stops its child, and the idle sleep in `spar listen` checks it too.
130static INTERRUPTED: AtomicBool = AtomicBool::new(false);
131
132/// Whether the process has been told to stop.
133pub fn interrupted() -> bool {
134    INTERRUPTED.load(Ordering::SeqCst)
135}
136
137#[cfg(unix)]
138extern "C" fn note_signal(sig: libc::c_int) {
139    INTERRUPTED.store(true, Ordering::SeqCst);
140    // Only calls that are safe inside a signal handler belong here. Restoring
141    // the default disposition means a second signal ends the process at once,
142    // for somebody who does not want to wait for the current call to stop.
143    unsafe {
144        libc::signal(sig, libc::SIG_DFL);
145    }
146}
147
148/// Turn SIGINT and SIGTERM into a flag that running commands and idle sleeps
149/// check, instead of the default action that ends the process where it stands.
150///
151/// Installed by `spar listen` alone. Every other command keeps the default, so
152/// nothing about them changes. The default would leave an agent CLI running:
153/// agent calls are started in their own process group, which is what keeps a
154/// terminal's Ctrl-C from reaching them.
155#[cfg(unix)]
156pub fn stop_on_signal() -> Result<()> {
157    for sig in [libc::SIGINT, libc::SIGTERM] {
158        // SAFETY: a zeroed sigaction is a valid starting point on every platform
159        // libc supports, and the handler does nothing a signal handler may not.
160        let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
161        action.sa_sigaction = note_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
162        // Reads on the output pipes restart rather than fail.
163        action.sa_flags = libc::SA_RESTART;
164        let failed = unsafe {
165            libc::sigemptyset(&mut action.sa_mask);
166            libc::sigaction(sig, &action, std::ptr::null_mut()) != 0
167        };
168        if failed {
169            return Err(SparError::new(format!(
170                "could not watch for Ctrl-C: {}",
171                std::io::Error::last_os_error()
172            )));
173        }
174    }
175    Ok(())
176}
177
178#[cfg(not(unix))]
179pub fn stop_on_signal() -> Result<()> {
180    Ok(())
181}
182
183/// Sleep for `total`, waking early if the process is told to stop. True when
184/// it woke early.
185pub fn sleep_unless_interrupted(total: Duration) -> bool {
186    sleep_until(total, &INTERRUPTED)
187}
188
189fn sleep_until(total: Duration, stop: &AtomicBool) -> bool {
190    let deadline = Instant::now() + total;
191    while !stop.load(Ordering::SeqCst) {
192        let left = deadline.saturating_duration_since(Instant::now());
193        if left.is_zero() {
194            return false;
195        }
196        std::thread::sleep(left.min(Duration::from_secs(1)));
197    }
198    true
199}
200
201fn exec_with_input(argv: &[String], opts: &ExecOpts, input: Option<&[u8]>) -> Result<Output> {
202    exec_until(argv, opts, input, &INTERRUPTED)
203}
204
205/// `exec_with_input`, stopping the child as soon as `stop` is set. The flag is
206/// a parameter so the kill path can be tested without touching the process
207/// wide one that every other test's commands check.
208fn exec_until(
209    argv: &[String],
210    opts: &ExecOpts,
211    input: Option<&[u8]>,
212    stop: &AtomicBool,
213) -> Result<Output> {
214    if stop.load(Ordering::SeqCst) {
215        return Err(SparError::interrupted(format!(
216            "interrupted before `{}` started",
217            abbreviate(argv)
218        )));
219    }
220    let program = argv
221        .first()
222        .ok_or_else(|| SparError::new("cannot run an empty command"))?;
223
224    let mut command = Command::new(program);
225    command
226        .args(&argv[1..])
227        .stdout(Stdio::piped())
228        .stderr(Stdio::piped());
229
230    if input.is_some() {
231        command.stdin(Stdio::piped());
232    } else {
233        // Agent CLIs happily block forever waiting on an inherited terminal.
234        command.stdin(Stdio::null());
235    }
236
237    if let Some(dir) = &opts.cwd {
238        command.current_dir(dir);
239    }
240    for (key, value) in &opts.env {
241        command.env(key, value);
242    }
243
244    #[cfg(unix)]
245    let token = opts.stop_descendants.then(call_token);
246    #[cfg(unix)]
247    if let Some(token) = &token {
248        use std::os::unix::process::CommandExt;
249        command.process_group(0);
250        let outer = std::env::var(CALLS_VAR).ok();
251        command.env(CALLS_VAR, calls_value(outer.as_deref(), token));
252    }
253
254    let mut child = command
255        .spawn()
256        .map_err(|e| SparError::new(format!("could not run `{}`: {e}", abbreviate(argv))))?;
257
258    #[cfg(unix)]
259    let mut tree = token.map(|token| Descendants::new(child.id(), token));
260    #[cfg(not(unix))]
261    let tree: Option<Descendants> = None;
262
263    // Readers first, writer second, and the writer on its own thread.
264    //
265    // A child that writes as it reads, `git check-attr --stdin` being the one
266    // that bit, blocks on a full stdout pipe long before it has drained an
267    // input larger than the pipe buffer. Writing stdin here on the calling
268    // thread would then block against a child blocked on output nobody is
269    // reading yet, and because that happens before the poll loop below, the
270    // timeout would never fire. Neither side can stall now: output is draining
271    // from the moment the child starts.
272    let out_reader = Reader::spawn(child.stdout.take().expect("stdout piped"));
273    let err_reader = Reader::spawn(child.stderr.take().expect("stderr piped"));
274
275    if let Some(bytes) = input {
276        if let Some(mut pipe) = child.stdin.take() {
277            let owned = bytes.to_vec();
278            // Detached: a child that exits early leaves the write erroring with
279            // a broken pipe, which is its answer and not a failure of ours.
280            // Dropping the handle at the end of the thread closes the pipe,
281            // which the child needs in order to see EOF and exit.
282            std::thread::spawn(move || {
283                let _ = pipe.write_all(&owned);
284            });
285        }
286    }
287
288    let deadline = Instant::now() + opts.timeout;
289    let mut poll = Duration::from_millis(5);
290    let mut timed_out = false;
291    let mut stopped = false;
292    let mut quiet_error = None;
293    let status = loop {
294        match child.try_wait() {
295            Ok(Some(status)) => break Some(status),
296            Ok(None) => {
297                if stop.load(Ordering::SeqCst) {
298                    if let Err(error) = stop_and_reap(&mut child, tree.as_ref()) {
299                        quiet_error = Some(error);
300                    }
301                    stopped = true;
302                    break None;
303                }
304                if Instant::now() >= deadline {
305                    if let Err(error) = stop_and_reap(&mut child, tree.as_ref()) {
306                        quiet_error = Some(error);
307                    }
308                    timed_out = true;
309                    break None;
310                }
311                #[cfg(unix)]
312                if let Some(tree) = tree.as_mut() {
313                    tree.sample_if_due();
314                }
315                std::thread::sleep(poll);
316                // Back off so a long agent run is not a busy loop, but stay
317                // responsive for the many fast git and gh calls.
318                poll = (poll * 2).min(Duration::from_millis(100));
319            }
320            Err(error) => {
321                return Err(poll_failure(error, argv, || {
322                    stop_and_reap(&mut child, tree.as_ref())
323                }));
324            }
325        }
326    };
327
328    // A stopped or timed out call was swept while its child was still alive.
329    #[cfg(unix)]
330    if let (Some(_), Some(tree)) = (status, &tree) {
331        if let Err(error) = tree.stop(false) {
332            quiet_error = Some(error);
333        }
334    }
335
336    // Never join the readers.
337    //
338    // An agent CLI runs shell commands, and any grandchild that inherited the
339    // pipe keeps its write end open after its parent exits. `read_to_end` on
340    // such a pipe never returns, so joining would hang past the deadline and
341    // the timeout would bound nothing at all. Instead, wait for the readers to
342    // finish or for the output to stop arriving, then take what did.
343    let stdout = out_reader.collect(DRAIN_GRACE);
344    let stderr = err_reader.collect(DRAIN_GRACE);
345
346    if stopped {
347        let cleanup = quiet_error
348            .map(|error| format!(" Stopping it also failed: {error}."))
349            .unwrap_or_default();
350        return Err(SparError::interrupted(format!(
351            "interrupted: {}.{cleanup}",
352            abbreviate(argv)
353        )));
354    }
355    if let Some(error) = quiet_error {
356        return Err(quiet_point_failure(error, argv));
357    }
358
359    if timed_out {
360        #[cfg(not(unix))]
361        if opts.stop_descendants {
362            return Err(SparError::uncertain_write(format!(
363                "timed out after {}s: {}\nThe direct process was stopped, but remaining child \
364                 processes could not be stopped on this platform. The worktree must be inspected \
365                 before retrying.",
366                opts.timeout.as_secs(),
367                abbreviate(argv)
368            )));
369        }
370        return Err(SparError::timed_out(format!(
371            "timed out after {}s: {}\nRaise `timeout` on this agent in spar.toml if the model \
372             legitimately needs longer. Not retried: asking again would wait exactly as long a \
373             second time.",
374            opts.timeout.as_secs(),
375            abbreviate(argv)
376        )));
377    }
378
379    Ok(Output {
380        stdout: String::from_utf8_lossy(&stdout).into_owned(),
381        stdout_bytes: stdout,
382        stderr: String::from_utf8_lossy(&stderr).into_owned(),
383        code: status.and_then(|s| s.code()).unwrap_or(-1),
384    })
385}
386
387fn stop_and_reap(
388    child: &mut std::process::Child,
389    tree: Option<&Descendants>,
390) -> std::io::Result<()> {
391    #[cfg(not(unix))]
392    let _ = tree;
393
394    #[cfg(unix)]
395    if let Some(tree) = tree {
396        if let Err(tree_error) = tree.stop(true) {
397            if child.kill().is_ok() {
398                let _ = child.wait();
399            }
400            return Err(tree_error);
401        }
402        return child.wait().map(|_| ());
403    }
404
405    match child.kill() {
406        Ok(()) => child.wait().map(|_| ()),
407        Err(kill_error) => match child.try_wait() {
408            Ok(Some(_)) => Ok(()),
409            Ok(None) => Err(kill_error),
410            Err(poll_error) => Err(std::io::Error::new(
411                poll_error.kind(),
412                format!("could not stop the direct process: {kill_error}; {poll_error}"),
413            )),
414        },
415    }
416}
417
418fn poll_failure(
419    error: std::io::Error,
420    argv: &[String],
421    stop_and_reap: impl FnOnce() -> std::io::Result<()>,
422) -> SparError {
423    let cleanup = stop_and_reap()
424        .err()
425        .map(|cleanup| format!(" Cleanup also failed: {cleanup}."))
426        .unwrap_or_default();
427    SparError::uncertain_write(format!(
428        "could not confirm whether `{}` stopped: {error}. SPAR attempted to stop and reap the \
429         direct process and its descendants.{cleanup} The worktree must be inspected before \
430         retrying.",
431        abbreviate(argv),
432    ))
433}
434
435fn quiet_point_failure(error: std::io::Error, argv: &[String]) -> SparError {
436    SparError::uncertain_write(format!(
437        "could not establish a quiet point after `{}`: {error}. The worktree must be inspected \
438         before retrying.",
439        abbreviate(argv)
440    ))
441}
442
443/// The variable every process below a call inherits, whatever process group
444/// or session it moves to.
445///
446/// The call's own process group is not enough to find them. Agent CLIs start
447/// each shell in a new group and session, so a signal to the call's group
448/// reaches the CLI and none of the test suites it started, and those keep
449/// running under init once the call is over. The value is a list, so a spar
450/// started inside an agent call marks its own calls without unmarking the one
451/// it runs in.
452///
453/// macOS does not show the environment of Apple's own binaries, `/bin/sh` and
454/// `/bin/zsh` among them, to another process. There the variable finds the
455/// test runner a shell started and not the shell, and the session and the
456/// samples below find the shell.
457#[cfg(unix)]
458const CALLS_VAR: &str = "SPAR_CALLS";
459
460/// How often a running call's descendants are noted down. One that leaves the
461/// tree by outliving its parent is still recognised later if a sample saw it
462/// while it was in it. A call shorter than this is never sampled at all.
463#[cfg(unix)]
464const SAMPLE_EVERY: Duration = Duration::from_millis(500);
465
466/// How many times a sweep looks again for processes that started while it was
467/// freezing the ones it had already found.
468#[cfg(unix)]
469const FREEZE_ROUNDS: usize = 8;
470
471#[cfg(unix)]
472static NEXT_CALL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
473
474/// A token no other call has used, in this spar or in an earlier one that had
475/// the same pid.
476#[cfg(unix)]
477fn call_token() -> String {
478    let nanos = std::time::SystemTime::now()
479        .duration_since(std::time::UNIX_EPOCH)
480        .map(|d| d.as_nanos())
481        .unwrap_or(0);
482    format!(
483        "{}.{nanos}.{}",
484        std::process::id(),
485        NEXT_CALL.fetch_add(1, Ordering::Relaxed)
486    )
487}
488
489#[cfg(unix)]
490fn calls_value(outer: Option<&str>, token: &str) -> String {
491    match outer {
492        Some(outer) if !outer.is_empty() => format!("{outer}:{token}"),
493        _ => token.to_string(),
494    }
495}
496
497/// Whether a NUL separated environment block marks its process as one of the
498/// call's.
499#[cfg(unix)]
500fn carries(environment: &[u8], token: &str) -> bool {
501    let prefix = format!("{CALLS_VAR}=");
502    environment
503        .split(|&b| b == 0)
504        .filter_map(|entry| entry.strip_prefix(prefix.as_bytes()))
505        .any(|value| value.split(|&b| b == b':').any(|t| t == token.as_bytes()))
506}
507
508/// The processes one call started.
509#[cfg(unix)]
510struct Descendants {
511    root: u32,
512    token: String,
513    /// When the direct child started, in the process table's own units.
514    /// Nothing that started earlier can descend from it, which spares reading
515    /// the environment of every other process on the machine.
516    floor: Option<u64>,
517    /// What the last sample found, with when each started, which tells one
518    /// from a stranger that was later given its pid.
519    seen: std::collections::BTreeMap<libc::pid_t, u64>,
520    /// Sessions the last sample found a descendant in, other than spar's own,
521    /// with when each one's leader started if it was still running. A session
522    /// outlives its leader, and the start tells the leader from a stranger
523    /// later given its pid, which would lead a session of its own.
524    sessions: std::collections::BTreeMap<libc::pid_t, Option<u64>>,
525    sampled: Instant,
526}
527
528#[cfg(not(unix))]
529struct Descendants;
530
531#[cfg(unix)]
532impl Descendants {
533    fn new(root: u32, token: String) -> Self {
534        let floor = libc::pid_t::try_from(root)
535            .ok()
536            .and_then(table::entry)
537            .map(|entry| entry.start);
538        Self {
539            root,
540            token,
541            floor,
542            seen: Default::default(),
543            sessions: Default::default(),
544            sampled: Instant::now(),
545        }
546    }
547
548    /// Note down what is below the call now, once `SAMPLE_EVERY` has passed
549    /// since the last look. A failed sample leaves the last one standing.
550    fn sample_if_due(&mut self) {
551        if self.sampled.elapsed() < SAMPLE_EVERY {
552            return;
553        }
554        self.sampled = Instant::now();
555        let (Ok(root), Ok(procs)) = (libc::pid_t::try_from(self.root), table::list()) else {
556            return;
557        };
558        // The child has not been reaped, so its pid is still its own even if
559        // it has just exited.
560        let found = self.find(&procs, root, true, true, false);
561        let my_session = unsafe { libc::getsid(0) };
562        let start_of = |pid: libc::pid_t| procs.iter().find(|p| p.pid == pid).map(|p| p.start);
563        let found: Vec<&Entry> = procs.iter().filter(|p| found.contains(&p.pid)).collect();
564        self.seen = found.iter().map(|p| (p.pid, p.start)).collect();
565        self.sessions = found
566            .iter()
567            .filter(|p| p.sid != my_session)
568            .map(|p| (p.sid, start_of(p.sid)))
569            .collect();
570    }
571
572    /// Freeze the call's process group and every process found below it, then
573    /// kill all of them.
574    ///
575    /// Freezing first is what lets the search end. A stopped process cannot
576    /// start another, so each round finds only what started before its parent
577    /// was frozen. `root_alive` is false once the child has been reaped, since
578    /// its pid may then belong to somebody else.
579    fn stop(&self, root_alive: bool) -> std::io::Result<()> {
580        let root = libc::pid_t::try_from(self.root).map_err(|_| {
581            std::io::Error::new(
582                std::io::ErrorKind::InvalidInput,
583                "the child process id does not fit a platform process-group id",
584            )
585        })?;
586        let mut frozen = std::collections::BTreeSet::new();
587        let mut root_group = true;
588        let searched = self.freeze(root, root_alive, &mut frozen, &mut root_group);
589
590        // Whatever the search managed, everything it froze is killed: a
591        // stopped process left behind would hold its files open for good. The
592        // group goes first and once, because a second signal to it finds only
593        // the zombies the first one made.
594        let mut killed = Ok(());
595        if root_group {
596            killed = signal_group(root, libc::SIGKILL);
597        }
598        for pid in &frozen {
599            killed = killed.and(signal(*pid, libc::SIGKILL));
600        }
601        searched.and(killed)
602    }
603
604    /// Every process it could not stop is left out of the kill and reported
605    /// once the rest are stopped, rather than ending the search: one setuid
606    /// descendant must not leave its siblings running.
607    fn freeze(
608        &self,
609        root: libc::pid_t,
610        root_alive: bool,
611        frozen: &mut std::collections::BTreeSet<libc::pid_t>,
612        root_group: &mut bool,
613    ) -> std::io::Result<()> {
614        let mut refused: std::collections::BTreeSet<libc::pid_t> = Default::default();
615        let mut failure = None;
616        if root_alive {
617            if let Err(error) = signal_group(root, libc::SIGSTOP) {
618                failure.get_or_insert(error);
619            }
620        }
621        // A fork under way when its parent was frozen can finish after the
622        // next listing, so a round that froze something is always followed by
623        // one more look.
624        let mut busy = false;
625        for _ in 0..FREEZE_ROUNDS {
626            let procs = table::list()?;
627            // A reaped child's pid is free again, and a process holding it now
628            // leads a group that has nothing to do with this call.
629            if !root_alive && procs.iter().any(|p| p.pid == root) {
630                *root_group = false;
631            }
632            let found = self.find(&procs, root, root_alive, *root_group, true);
633            let fresh: Vec<libc::pid_t> = found
634                .iter()
635                .filter(|&&pid| !frozen.contains(&pid) && !refused.contains(&pid))
636                .copied()
637                .collect();
638            if fresh.is_empty() {
639                if !busy {
640                    return failure.map_or(Ok(()), Err);
641                }
642                busy = false;
643                continue;
644            }
645            busy = true;
646            for pid in fresh {
647                match signal(pid, libc::SIGSTOP) {
648                    Ok(()) => {
649                        frozen.insert(pid);
650                    }
651                    Err(error) => {
652                        refused.insert(pid);
653                        failure.get_or_insert(error);
654                    }
655                }
656            }
657        }
658        Err(failure.unwrap_or_else(|| {
659            std::io::Error::other(
660                "processes below the call kept starting while they were being stopped",
661            )
662        }))
663    }
664
665    /// Everything in `procs` that belongs to the call.
666    ///
667    /// It starts from the child while it is alive, the child's process group,
668    /// whatever the last sample saw that is still the same process, and, with
669    /// `marks`, whatever carries the call's token. From there it spreads to
670    /// children, to the members of any group one of them leads, and to the
671    /// members of any session one of them is in other than spar's own. A
672    /// process cannot join a session it did not inherit, so a session that
673    /// holds a descendant and is not spar's was created by a descendant, and
674    /// everything in it descends from that one.
675    fn find(
676        &self,
677        procs: &[Entry],
678        root: libc::pid_t,
679        root_alive: bool,
680        root_group: bool,
681        marks: bool,
682    ) -> std::collections::BTreeSet<libc::pid_t> {
683        let me = unsafe { libc::getpid() };
684        let uid = unsafe { libc::geteuid() };
685        let my_session = unsafe { libc::getsid(0) };
686        let by_pid: std::collections::HashMap<libc::pid_t, &Entry> =
687            procs.iter().map(|p| (p.pid, p)).collect();
688        // The session a sample recorded, unless its id now belongs to a
689        // stranger that made a session of its own.
690        let recorded = |sid: libc::pid_t| match self.sessions.get(&sid) {
691            None => false,
692            Some(leader) => by_pid
693                .get(&sid)
694                .is_none_or(|now| *leader == Some(now.start)),
695        };
696        let marked = |p: &Entry| {
697            marks
698                && p.uid == uid
699                && self.floor.is_none_or(|floor| p.start >= floor)
700                && table::environment(p.pid).is_some_and(|env| carries(&env, &self.token))
701        };
702        let mut ours: std::collections::BTreeSet<libc::pid_t> = procs
703            .iter()
704            .filter(|p| p.pid != me)
705            .filter(|p| {
706                (root_alive && p.pid == root)
707                    || (root_group && p.pgid == root)
708                    || self.seen.get(&p.pid) == Some(&p.start)
709                    || recorded(p.sid)
710                    || marked(p)
711            })
712            .map(|p| p.pid)
713            .collect();
714        loop {
715            let sessions: std::collections::BTreeSet<libc::pid_t> = procs
716                .iter()
717                .filter(|p| ours.contains(&p.pid) && p.sid != my_session)
718                .map(|p| p.sid)
719                .collect();
720            let before = ours.len();
721            for p in procs {
722                if p.pid != me
723                    && (ours.contains(&p.ppid)
724                        || ours.contains(&p.pgid)
725                        || sessions.contains(&p.sid))
726                {
727                    ours.insert(p.pid);
728                }
729            }
730            if ours.len() == before {
731                return ours;
732            }
733        }
734    }
735}
736
737/// Send `sig` to a process, or to a process group when `target` is negative.
738/// One that has already gone is not an error. Never this process, its group,
739/// or the broadcast targets, whatever a search turned up.
740#[cfg(unix)]
741fn signal(target: libc::pid_t, sig: libc::c_int) -> std::io::Result<()> {
742    let me = unsafe { libc::getpid() };
743    let my_group = unsafe { libc::getpgrp() };
744    if target == 0 || target == -1 || target == me || target == -my_group {
745        return Ok(());
746    }
747    if unsafe { libc::kill(target, sig) } == 0 {
748        return Ok(());
749    }
750    let error = std::io::Error::last_os_error();
751    if error.raw_os_error() == Some(libc::ESRCH) {
752        return Ok(());
753    }
754    Err(error)
755}
756
757/// Signal a process group. macOS refuses with EPERM, not ESRCH, when every
758/// member left is a zombie, so a refusal counts only if a member still visible
759/// in the process table refuses on its own.
760#[cfg(unix)]
761fn signal_group(group: libc::pid_t, sig: libc::c_int) -> std::io::Result<()> {
762    match signal(-group, sig) {
763        Err(error) if error.raw_os_error() == Some(libc::EPERM) => table::list()?
764            .iter()
765            .filter(|p| p.pgid == group)
766            .try_for_each(|p| signal(p.pid, sig)),
767        other => other,
768    }
769}
770
771/// One row of the process table.
772#[cfg(unix)]
773#[derive(Debug, Clone, Copy, PartialEq, Eq)]
774struct Entry {
775    pid: libc::pid_t,
776    ppid: libc::pid_t,
777    pgid: libc::pid_t,
778    sid: libc::pid_t,
779    uid: libc::uid_t,
780    /// When it started, comparable only with another start from the same table.
781    start: u64,
782}
783
784#[cfg(target_os = "macos")]
785mod table {
786    use super::Entry;
787
788    pub(super) fn list() -> std::io::Result<Vec<Entry>> {
789        // Asked with no buffer, the kernel estimates how many there are.
790        let estimate = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
791        let mut room = usize::try_from(estimate).unwrap_or(0) + 256;
792        loop {
793            let mut pids: Vec<libc::pid_t> = vec![0; room];
794            let bytes = libc::c_int::try_from(room * std::mem::size_of::<libc::pid_t>())
795                .map_err(std::io::Error::other)?;
796            let listed = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), bytes) };
797            let Ok(listed) = usize::try_from(listed) else {
798                return Err(std::io::Error::last_os_error());
799            };
800            // A full buffer may have cut the list short.
801            if listed >= room {
802                room *= 2;
803                continue;
804            }
805            pids.truncate(listed);
806            return Ok(pids.into_iter().filter_map(entry).collect());
807        }
808    }
809
810    /// None for a process that has gone, and for one of another user's, which
811    /// the kernel will not describe.
812    pub(super) fn entry(pid: libc::pid_t) -> Option<Entry> {
813        let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
814        let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
815        let filled = unsafe {
816            libc::proc_pidinfo(
817                pid,
818                libc::PROC_PIDTBSDINFO,
819                0,
820                info.as_mut_ptr().cast(),
821                size,
822            )
823        };
824        if filled != size {
825            return None;
826        }
827        // SAFETY: the kernel filled every byte it was given, and zeroed is a
828        // valid value of this plain C struct in any case.
829        let info = unsafe { info.assume_init() };
830        let sid = unsafe { libc::getsid(pid) };
831        if sid < 0 {
832            return None;
833        }
834        Some(Entry {
835            pid,
836            ppid: libc::pid_t::try_from(info.pbi_ppid).ok()?,
837            pgid: libc::pid_t::try_from(info.pbi_pgid).ok()?,
838            sid,
839            uid: info.pbi_uid,
840            start: info
841                .pbi_start_tvsec
842                .saturating_mul(1_000_000)
843                .saturating_add(info.pbi_start_tvusec),
844        })
845    }
846
847    pub(super) fn environment(pid: libc::pid_t) -> Option<Vec<u8>> {
848        let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid];
849        let mut size: libc::size_t = 0;
850        // Asked with no buffer, the kernel says how much room the answer needs.
851        let sized = unsafe {
852            libc::sysctl(
853                mib.as_mut_ptr(),
854                3,
855                std::ptr::null_mut(),
856                &mut size,
857                std::ptr::null_mut(),
858                0,
859            )
860        };
861        if sized != 0 || size == 0 {
862            return None;
863        }
864        let mut buf = vec![0u8; size];
865        let read = unsafe {
866            libc::sysctl(
867                mib.as_mut_ptr(),
868                3,
869                buf.as_mut_ptr().cast(),
870                &mut size,
871                std::ptr::null_mut(),
872                0,
873            )
874        };
875        if read != 0 {
876            return None;
877        }
878        buf.truncate(size);
879        super::procargs_environment(&buf).map(<[u8]>::to_vec)
880    }
881}
882
883#[cfg(target_os = "linux")]
884mod table {
885    use super::Entry;
886    use std::os::unix::fs::MetadataExt;
887
888    pub(super) fn list() -> std::io::Result<Vec<Entry>> {
889        let mut procs = Vec::new();
890        for item in std::fs::read_dir("/proc")? {
891            let Ok(item) = item else { continue };
892            // Only the numbered entries are processes.
893            let Some(pid) = item.file_name().to_str().and_then(|name| name.parse().ok()) else {
894                continue;
895            };
896            procs.extend(entry(pid));
897        }
898        Ok(procs)
899    }
900
901    /// None for a process that exited after the listing.
902    pub(super) fn entry(pid: libc::pid_t) -> Option<Entry> {
903        let dir = format!("/proc/{pid}");
904        let uid = std::fs::metadata(&dir).ok()?.uid();
905        let stat = std::fs::read(format!("{dir}/stat")).ok()?;
906        super::parse_stat(pid, &String::from_utf8_lossy(&stat), uid)
907    }
908
909    pub(super) fn environment(pid: libc::pid_t) -> Option<Vec<u8>> {
910        std::fs::read(format!("/proc/{pid}/environ")).ok()
911    }
912}
913
914/// No process table is read here, so a sweep stops the call's own process
915/// group and nothing that left it.
916#[cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))]
917mod table {
918    use super::Entry;
919
920    pub(super) fn list() -> std::io::Result<Vec<Entry>> {
921        Ok(Vec::new())
922    }
923
924    pub(super) fn entry(_: libc::pid_t) -> Option<Entry> {
925        None
926    }
927
928    pub(super) fn environment(_: libc::pid_t) -> Option<Vec<u8>> {
929        None
930    }
931}
932
933/// The environment in a macOS `KERN_PROCARGS2` answer: an argument count, the
934/// executable path and its padding, the arguments, then the environment up to
935/// an empty string.
936#[cfg(any(target_os = "macos", test))]
937fn procargs_environment(buf: &[u8]) -> Option<&[u8]> {
938    let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
939    let rest = &buf[4..];
940    let path_end = rest.iter().position(|&b| b == 0)?;
941    let rest = &rest[path_end..];
942    let args_start = rest.iter().position(|&b| b != 0)?;
943    let mut rest = &rest[args_start..];
944    for _ in 0..argc {
945        let end = rest.iter().position(|&b| b == 0)?;
946        rest = &rest[end + 1..];
947    }
948    let mut end = 0;
949    for entry in rest.split(|&b| b == 0) {
950        if entry.is_empty() {
951            break;
952        }
953        end += entry.len() + 1;
954    }
955    Some(&rest[..end.min(rest.len())])
956}
957
958/// A Linux `/proc/<pid>/stat` line. The command name sits in parentheses and
959/// may itself hold spaces and parentheses, so the fields are counted from the
960/// last closing one.
961#[cfg(all(unix, any(target_os = "linux", test)))]
962fn parse_stat(pid: libc::pid_t, stat: &str, uid: libc::uid_t) -> Option<Entry> {
963    let (_, rest) = stat.rsplit_once(')')?;
964    let fields: Vec<&str> = rest.split_whitespace().collect();
965    Some(Entry {
966        pid,
967        ppid: fields.get(1)?.parse().ok()?,
968        pgid: fields.get(2)?.parse().ok()?,
969        sid: fields.get(3)?.parse().ok()?,
970        uid,
971        start: fields.get(19)?.parse().ok()?,
972    })
973}
974
975/// How long to keep waiting for output after the child has exited, when a
976/// surviving grandchild is holding the pipe open. Measured from the last byte
977/// received, so a slow large read is never cut short.
978const DRAIN_GRACE: Duration = Duration::from_secs(3);
979
980/// A pipe drained on its own thread into a shared buffer.
981///
982/// Reading incrementally rather than with `read_to_end` is what lets the caller
983/// take the output without joining, which is what keeps the timeout honest.
984struct Reader {
985    buf: Arc<Mutex<Vec<u8>>>,
986    done: Arc<AtomicBool>,
987}
988
989impl Reader {
990    fn spawn<R: Read + Send + 'static>(mut pipe: R) -> Self {
991        let buf = Arc::new(Mutex::new(Vec::new()));
992        let done = Arc::new(AtomicBool::new(false));
993        let (buf_w, done_w) = (Arc::clone(&buf), Arc::clone(&done));
994        std::thread::spawn(move || {
995            let mut chunk = [0u8; 16 * 1024];
996            loop {
997                match pipe.read(&mut chunk) {
998                    Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
999                    Ok(0) | Err(_) => break,
1000                    Ok(n) => buf_w
1001                        .lock()
1002                        .unwrap_or_else(|e| e.into_inner())
1003                        .extend_from_slice(&chunk[..n]),
1004                }
1005            }
1006            done_w.store(true, Ordering::Release);
1007        });
1008        Self { buf, done }
1009    }
1010
1011    fn len(&self) -> usize {
1012        self.buf.lock().unwrap_or_else(|e| e.into_inner()).len()
1013    }
1014
1015    /// Everything received once the reader finishes, or once `grace` passes
1016    /// with no new bytes.
1017    ///
1018    /// The poll starts fine grained and backs off. A run makes hundreds of git
1019    /// and gh calls whose pipes are already at EOF by the time the child is
1020    /// reaped, and a flat ten millisecond wait on each one is real wall clock
1021    /// spent on nothing.
1022    fn collect(&self, grace: Duration) -> Vec<u8> {
1023        let mut last_len = self.len();
1024        let mut quiet_since = Instant::now();
1025        let mut poll = Duration::from_micros(100);
1026        while !self.done.load(Ordering::Acquire) {
1027            let now_len = self.len();
1028            if now_len != last_len {
1029                last_len = now_len;
1030                quiet_since = Instant::now();
1031            } else if quiet_since.elapsed() >= grace {
1032                break;
1033            }
1034            std::thread::sleep(poll);
1035            poll = (poll * 2).min(Duration::from_millis(10));
1036        }
1037        self.buf.lock().unwrap_or_else(|e| e.into_inner()).clone()
1038    }
1039}
1040
1041/// Run a command and return stdout. With `check` set, a non-zero exit is an
1042/// error carrying both streams.
1043pub fn run(argv: &[String], opts: &ExecOpts) -> Result<String> {
1044    let out = exec(argv, opts)?;
1045    if opts.check && !out.ok() {
1046        return Err(SparError::call_failed(failure_message(argv, &out)));
1047    }
1048    Ok(out.stdout)
1049}
1050
1051pub(crate) fn run_with_input_bytes(
1052    argv: &[String],
1053    opts: &ExecOpts,
1054    input: &[u8],
1055) -> Result<String> {
1056    let out = exec_with_input(argv, opts, Some(input))?;
1057    if opts.check && !out.ok() {
1058        return Err(SparError::call_failed(failure_message(argv, &out)));
1059    }
1060    Ok(out.stdout)
1061}
1062
1063/// Run a command and return stdout without changing non-UTF-8 bytes.
1064pub fn run_bytes(argv: &[String], opts: &ExecOpts) -> Result<Vec<u8>> {
1065    let out = exec(argv, opts)?;
1066    if opts.check && !out.ok() {
1067        return Err(SparError::call_failed(failure_message(argv, &out)));
1068    }
1069    Ok(out.stdout_bytes)
1070}
1071
1072/// Convenience for the many `run(&["git".into(), ...])` call sites.
1073pub fn run_str(argv: &[&str], opts: &ExecOpts) -> Result<String> {
1074    let owned: Vec<String> = argv.iter().map(|s| (*s).to_string()).collect();
1075    run(&owned, opts)
1076}
1077
1078/// Prompts run to many kilobytes. Echoing them whole buries the actual error.
1079pub fn abbreviate(argv: &[String]) -> String {
1080    argv.iter()
1081        .map(|arg| {
1082            let one_line = arg.split_whitespace().collect::<Vec<_>>().join(" ");
1083            if one_line.chars().count() <= 60 {
1084                one_line
1085            } else {
1086                let head: String = one_line.chars().take(57).collect();
1087                format!("{head}...")
1088            }
1089        })
1090        .collect::<Vec<_>>()
1091        .join(" ")
1092}
1093
1094/// Both streams, labelled. Preferring stderr is not enough: one agent CLI
1095/// writes chatter like "Reading additional input from stdin..." to stderr on
1096/// every run, which outranks the real reason and explains nothing, while
1097/// another reports fatal conditions on stdout with stderr empty.
1098pub fn failure_message(argv: &[String], out: &Output) -> String {
1099    let mut parts = vec![format!(
1100        "command failed ({}): {}",
1101        out.code,
1102        abbreviate(argv)
1103    )];
1104    for (label, stream) in [("stderr", &out.stderr), ("stdout", &out.stdout)] {
1105        let text = stream.trim();
1106        if !text.is_empty() {
1107            parts.push(format!("--- {label} ---\n{}", tail(text, 1500)));
1108        }
1109    }
1110    if parts.len() == 1 {
1111        parts.push("(no output on either stream)".to_string());
1112    }
1113    parts.join("\n")
1114}
1115
1116/// Last `max` characters, on a character boundary.
1117fn tail(text: &str, max: usize) -> &str {
1118    let count = text.chars().count();
1119    if count <= max {
1120        return text;
1121    }
1122    let start = text
1123        .char_indices()
1124        .nth(count - max)
1125        .map(|(i, _)| i)
1126        .unwrap_or(0);
1127    &text[start..]
1128}
1129
1130/// Whether a program is on PATH.
1131pub fn which(program: &str) -> Option<PathBuf> {
1132    if program.contains(std::path::MAIN_SEPARATOR) {
1133        let path = PathBuf::from(program);
1134        return is_executable(&path).then_some(path);
1135    }
1136    let paths = std::env::var_os("PATH")?;
1137    std::env::split_paths(&paths).find_map(|dir| {
1138        let candidate = dir.join(program);
1139        is_executable(&candidate).then_some(candidate)
1140    })
1141}
1142
1143pub fn is_executable(path: &Path) -> bool {
1144    #[cfg(unix)]
1145    {
1146        use std::os::unix::fs::PermissionsExt;
1147        match std::fs::metadata(path) {
1148            Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
1149            Err(_) => false,
1150        }
1151    }
1152    #[cfg(not(unix))]
1153    {
1154        path.is_file()
1155    }
1156}
1157
1158/// Expand a leading `~` against $HOME. Nothing else: a config path is not a
1159/// shell word and should not behave like one.
1160pub fn expand_tilde(path: &str) -> PathBuf {
1161    if path == "~" {
1162        if let Some(home) = home_dir() {
1163            return home;
1164        }
1165    }
1166    if let Some(rest) = path.strip_prefix("~/") {
1167        if let Some(home) = home_dir() {
1168            return home.join(rest);
1169        }
1170    }
1171    PathBuf::from(path)
1172}
1173
1174pub fn home_dir() -> Option<PathBuf> {
1175    std::env::var_os("HOME").map(PathBuf::from)
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181
1182    fn proc(out: &str, err: &str, code: i32) -> Output {
1183        Output {
1184            stdout: out.into(),
1185            stdout_bytes: out.as_bytes().to_vec(),
1186            stderr: err.into(),
1187            code,
1188        }
1189    }
1190
1191    fn argv(parts: &[&str]) -> Vec<String> {
1192        parts.iter().map(|s| s.to_string()).collect()
1193    }
1194
1195    #[test]
1196    fn a_poll_failure_attempts_cleanup_and_is_uncertain() {
1197        let stopped = std::cell::Cell::new(false);
1198        let error = poll_failure(
1199            std::io::Error::other("poll failed"),
1200            &argv(&["editor"]),
1201            || {
1202                stopped.set(true);
1203                Ok(())
1204            },
1205        );
1206
1207        assert!(stopped.get());
1208        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
1209        assert!(error.to_string().contains("poll failed"), "{error}");
1210    }
1211
1212    #[test]
1213    fn a_stop_or_reap_failure_is_uncertain() {
1214        let error =
1215            quiet_point_failure(std::io::Error::other("could not reap"), &argv(&["editor"]));
1216
1217        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
1218        assert!(error.to_string().contains("could not reap"), "{error}");
1219    }
1220
1221    #[test]
1222    fn stdin_reaches_the_child_byte_for_byte() {
1223        let body = "line one  \n\nline three\n";
1224        let out = run_str(&["/bin/sh", "-c", "cat"], &ExecOpts::new().stdin(body))
1225            .expect("the child reads stdin");
1226        assert_eq!(body, out);
1227    }
1228
1229    #[test]
1230    fn byte_input_reaches_the_child_without_text_conversion() {
1231        let input = [b'f', 0xff, 0];
1232        let command = argv(&["/bin/sh", "-c", "cat"]);
1233        let out = exec_with_input(&command, &ExecOpts::new(), Some(&input))
1234            .expect("the child reads byte input");
1235
1236        assert_eq!(input, out.stdout_bytes.as_slice());
1237    }
1238
1239    #[test]
1240    fn stdout_used_when_stderr_is_empty() {
1241        let msg = failure_message(&argv(&["claude"]), &proc("You've hit your limit.", "", 1));
1242        assert!(msg.contains("hit your limit"), "{msg}");
1243    }
1244
1245    #[test]
1246    fn stderr_shown_when_present() {
1247        let msg = failure_message(&argv(&["gh"]), &proc("noise", "real reason", 1));
1248        assert!(msg.contains("real reason"), "{msg}");
1249    }
1250
1251    #[test]
1252    fn both_streams_are_shown_not_just_one() {
1253        let msg = failure_message(&argv(&["gh"]), &proc("on stdout", "on stderr", 1));
1254        assert!(
1255            msg.contains("on stdout") && msg.contains("on stderr"),
1256            "{msg}"
1257        );
1258    }
1259
1260    #[test]
1261    fn says_something_when_both_are_empty() {
1262        assert!(failure_message(&argv(&["x"]), &proc("", "", 2)).contains("no output"));
1263    }
1264
1265    #[test]
1266    fn long_arguments_are_abbreviated() {
1267        let long = "word ".repeat(500);
1268        let out = abbreviate(&argv(&["claude", "-p", &long]));
1269        assert!(out.len() < 200, "{}", out.len());
1270    }
1271
1272    #[test]
1273    fn newlines_in_arguments_do_not_break_the_line() {
1274        assert!(!abbreviate(&argv(&["claude", "a\nb\nc"])).contains('\n'));
1275    }
1276
1277    #[test]
1278    fn short_arguments_survive_intact() {
1279        assert_eq!(
1280            "gh pr merge 17",
1281            abbreviate(&argv(&["gh", "pr", "merge", "17"]))
1282        );
1283    }
1284
1285    #[test]
1286    fn abbreviation_never_splits_a_character() {
1287        // A multi-byte argument longer than the cap must not panic on a slice
1288        // that lands mid-character.
1289        let wide = "\u{1f600}".repeat(200);
1290        let out = abbreviate(&argv(&[&wide]));
1291        assert!(out.ends_with("..."));
1292    }
1293
1294    #[test]
1295    fn exit_code_is_reported() {
1296        let out = exec(
1297            &argv(&["sh", "-c", "exit 3"]),
1298            &ExecOpts::new().check(false),
1299        )
1300        .unwrap();
1301        assert_eq!(3, out.code);
1302    }
1303
1304    #[test]
1305    fn check_false_returns_stdout_on_failure() {
1306        let text = run(
1307            &argv(&["sh", "-c", "echo partial; exit 1"]),
1308            &ExecOpts::new().check(false),
1309        )
1310        .unwrap();
1311        assert_eq!("partial\n", text);
1312    }
1313
1314    #[test]
1315    fn check_true_fails_loudly() {
1316        let err = run(
1317            &argv(&["sh", "-c", "echo why >&2; exit 1"]),
1318            &ExecOpts::new(),
1319        )
1320        .unwrap_err();
1321        assert!(err.to_string().contains("why"), "{err}");
1322    }
1323
1324    #[test]
1325    fn large_output_does_not_deadlock() {
1326        // Well past a pipe buffer on every platform spar runs on.
1327        let text = run(
1328            &argv(&["sh", "-c", "yes hello | head -c 400000"]),
1329            &ExecOpts::new().timeout_secs(60),
1330        )
1331        .unwrap();
1332        assert_eq!(400_000, text.len());
1333    }
1334
1335    /// Input larger than a pipe buffer, from a child that answers as it reads.
1336    ///
1337    /// This is the shape `git check-attr --stdin` has on a repository of a few
1338    /// thousand files. Writing stdin before the readers started deadlocked the
1339    /// pair: the child blocked writing to a stdout pipe nobody was draining,
1340    /// spar blocked writing the rest of stdin, and the timeout never fired
1341    /// because the poll loop had not been reached.
1342    #[test]
1343    fn large_input_and_large_output_together_do_not_deadlock() {
1344        let line = "a".repeat(199);
1345        let input = std::iter::repeat_n(line.as_str(), 5_000)
1346            .collect::<Vec<_>>()
1347            .join("\n");
1348        assert!(input.len() > 900_000, "the input has to outgrow the pipe");
1349
1350        let start = Instant::now();
1351        // `cat` on both streams, so the child is writing while it is still
1352        // reading and neither pipe can be left unattended.
1353        let out = run(
1354            &argv(&["sh", "-c", "tee /dev/stderr"]),
1355            &ExecOpts::new().timeout_secs(60).stdin(input.clone()),
1356        )
1357        .unwrap();
1358
1359        assert_eq!(input.len(), out.trim_end().len());
1360        assert!(
1361            start.elapsed() < Duration::from_secs(30),
1362            "the call did not return promptly: {:?}",
1363            start.elapsed()
1364        );
1365    }
1366
1367    /// The reason the readers are never joined. A grandchild that inherited
1368    /// the pipe holds its write end open after its parent exits, so
1369    /// `read_to_end` would never return and the deadline would bound nothing.
1370    #[test]
1371    fn a_surviving_grandchild_holding_the_pipe_cannot_hang_the_timeout() {
1372        let start = Instant::now();
1373        let err = run(
1374            &argv(&["sh", "-c", "sleep 120 & echo parent-output; sleep 60"]),
1375            &ExecOpts::new().timeout_secs(1),
1376        )
1377        .unwrap_err();
1378        let elapsed = start.elapsed();
1379
1380        assert!(err.to_string().contains("timed out"), "{err}");
1381        assert!(
1382            elapsed < Duration::from_secs(20),
1383            "the timeout did not bound the call: {elapsed:?}"
1384        );
1385    }
1386
1387    #[test]
1388    fn a_surviving_grandchild_does_not_hang_a_normal_exit_either() {
1389        let start = Instant::now();
1390        let out = run(
1391            &argv(&["sh", "-c", "sleep 120 & echo done"]),
1392            &ExecOpts::new().timeout_secs(60),
1393        )
1394        .unwrap();
1395        assert!(out.contains("done"), "{out:?}");
1396        assert!(
1397            start.elapsed() < Duration::from_secs(20),
1398            "waited on a grandchild that will never exit"
1399        );
1400    }
1401
1402    #[cfg(unix)]
1403    #[test]
1404    fn a_stopped_process_group_cannot_edit_after_the_parent_exits() {
1405        let late = std::env::temp_dir().join(format!(
1406            "spar-late-child-{}-{}",
1407            std::process::id(),
1408            std::thread::current().name().unwrap_or("test")
1409        ));
1410        let _ = std::fs::remove_file(&late);
1411        let out = run(
1412            &argv(&[
1413                "/bin/sh",
1414                "-c",
1415                "(sleep 1; touch \"$1\") & echo done",
1416                "sh",
1417                late.to_str().unwrap(),
1418            ]),
1419            &ExecOpts::new().stop_descendants(true),
1420        )
1421        .unwrap();
1422
1423        assert!(out.contains("done"), "{out:?}");
1424        std::thread::sleep(Duration::from_millis(1_200));
1425        assert!(!late.exists(), "a descendant edited after the quiet point");
1426    }
1427
1428    /// A file a descendant would write too late, unique to the test.
1429    #[cfg(unix)]
1430    fn late_file(name: &str) -> PathBuf {
1431        let path = std::env::temp_dir().join(format!("spar-late-{name}-{}", std::process::id()));
1432        let _ = std::fs::remove_file(&path);
1433        path
1434    }
1435
1436    /// A descendant that leaves for a session of its own, the way an agent CLI
1437    /// starts every shell, and writes the file named by `$1` after `secs`.
1438    #[cfg(unix)]
1439    fn leaves_then_writes(secs: u32) -> String {
1440        format!(
1441            "perl -MPOSIX -e 'POSIX::setsid() or die; sleep {secs}; \
1442             open(my $f, \">\", $ARGV[0]) or die' \"$1\""
1443        )
1444    }
1445
1446    #[cfg(unix)]
1447    fn run_shell(script: &str, late: &Path, opts: &ExecOpts) -> Result<String> {
1448        run(
1449            &argv(&["/bin/sh", "-c", script, "sh", late.to_str().unwrap()]),
1450            opts,
1451        )
1452    }
1453
1454    /// A sample sees the descendant while its parent is alive, which is what
1455    /// finds it afterwards on macOS, where perl does not show its environment
1456    /// to anybody else.
1457    #[cfg(unix)]
1458    #[test]
1459    fn a_descendant_in_a_session_of_its_own_cannot_edit_after_the_parent_exits() {
1460        let late = late_file("session");
1461        let script = format!("{} & sleep 1.5; echo done", leaves_then_writes(3));
1462        let out = run_shell(&script, &late, &ExecOpts::new().stop_descendants(true)).unwrap();
1463
1464        assert!(out.contains("done"), "{out:?}");
1465        std::thread::sleep(Duration::from_millis(2_500));
1466        assert!(!late.exists(), "a descendant edited after the quiet point");
1467    }
1468
1469    /// A sample sees perl in its own session. perl then starts a shell that no
1470    /// sample sees and exits, so on macOS, where neither shows its
1471    /// environment, only the session ties that shell to the call.
1472    #[cfg(unix)]
1473    #[test]
1474    fn a_process_no_sample_saw_is_found_through_the_session_it_was_born_in() {
1475        let late = late_file("born");
1476        let perl = r#"perl -MPOSIX -e 'POSIX::setsid() or die; select(undef, undef, undef, 0.8); if (fork() == 0) { exec("/bin/sh", "-c", "sleep 2; : > \"\$0\"", $ARGV[0]) or die }' "$1""#;
1477        let script = format!("{perl} & sleep 0.9; echo done");
1478        let out = run_shell(&script, &late, &ExecOpts::new().stop_descendants(true)).unwrap();
1479
1480        assert!(out.contains("done"), "{out:?}");
1481        std::thread::sleep(Duration::from_millis(2_500));
1482        assert!(
1483            !late.exists(),
1484            "a shell born in a descendant's session survived"
1485        );
1486    }
1487
1488    /// Two calls can run at once, as both triage calls do, and one ending must
1489    /// not reach into the other.
1490    #[cfg(unix)]
1491    #[test]
1492    fn one_call_ending_leaves_another_calls_descendants_alone() {
1493        let proof = late_file("concurrent");
1494        let other = {
1495            let proof = proof.clone();
1496            std::thread::spawn(move || {
1497                let script = format!("{} & sleep 3; echo done", leaves_then_writes(2));
1498                run_shell(&script, &proof, &ExecOpts::new().stop_descendants(true))
1499            })
1500        };
1501        std::thread::sleep(Duration::from_millis(200));
1502        run(
1503            &argv(&["/bin/sh", "-c", "sleep 1"]),
1504            &ExecOpts::new().stop_descendants(true),
1505        )
1506        .unwrap();
1507
1508        let out = other.join().unwrap().unwrap();
1509        assert!(out.contains("done"), "{out:?}");
1510        assert!(
1511            proof.exists(),
1512            "one call's sweep stopped another call's descendant"
1513        );
1514        let _ = std::fs::remove_file(&proof);
1515    }
1516
1517    /// What every one of the four timed out implementations did: the test
1518    /// suite an agent started kept running into the next issue's hour.
1519    #[cfg(unix)]
1520    #[test]
1521    fn a_timeout_stops_a_descendant_in_a_session_of_its_own() {
1522        let late = late_file("timeout");
1523        let script = format!("{} & sleep 30", leaves_then_writes(2));
1524        let err = run_shell(
1525            &script,
1526            &late,
1527            &ExecOpts::new().timeout_secs(1).stop_descendants(true),
1528        )
1529        .unwrap_err();
1530
1531        assert!(err.to_string().contains("timed out"), "{err}");
1532        std::thread::sleep(Duration::from_millis(2_500));
1533        assert!(!late.exists(), "a descendant outlived the timeout");
1534    }
1535
1536    /// Without the variable, a descendant is still found by its parent while
1537    /// the call it belongs to is alive.
1538    #[cfg(unix)]
1539    #[test]
1540    fn a_timeout_stops_a_descendant_that_dropped_its_environment() {
1541        let late = late_file("bare");
1542        let script = format!("env -i {} & sleep 30", leaves_then_writes(2));
1543        let err = run_shell(
1544            &script,
1545            &late,
1546            &ExecOpts::new().timeout_secs(1).stop_descendants(true),
1547        )
1548        .unwrap_err();
1549
1550        assert!(err.to_string().contains("timed out"), "{err}");
1551        std::thread::sleep(Duration::from_millis(2_500));
1552        assert!(!late.exists(), "a descendant without the variable survived");
1553    }
1554
1555    /// The parent exits before the first sample, so only the variable can
1556    /// find what it left behind.
1557    #[cfg(target_os = "linux")]
1558    #[test]
1559    fn a_descendant_no_sample_saw_is_found_by_its_environment() {
1560        let late = late_file("marked");
1561        let script = format!("{} & sleep 0.1; echo done", leaves_then_writes(1));
1562        let out = run_shell(&script, &late, &ExecOpts::new().stop_descendants(true)).unwrap();
1563
1564        assert!(out.contains("done"), "{out:?}");
1565        std::thread::sleep(Duration::from_millis(1_500));
1566        assert!(
1567            !late.exists(),
1568            "an unsampled descendant edited after the quiet point"
1569        );
1570    }
1571
1572    /// A spar started inside an agent call adds its own token to the list, and
1573    /// what it starts still belongs to the outer call.
1574    #[cfg(target_os = "linux")]
1575    #[test]
1576    fn a_descendant_of_a_nested_call_still_belongs_to_the_outer_one() {
1577        let late = late_file("nested");
1578        let script = format!(
1579            "SPAR_CALLS=\"$SPAR_CALLS:inner\" {} & sleep 0.1; echo done",
1580            leaves_then_writes(1)
1581        );
1582        let out = run_shell(&script, &late, &ExecOpts::new().stop_descendants(true)).unwrap();
1583
1584        assert!(out.contains("done"), "{out:?}");
1585        std::thread::sleep(Duration::from_millis(1_500));
1586        assert!(
1587            !late.exists(),
1588            "a nested call's descendant edited after the quiet point"
1589        );
1590    }
1591
1592    /// The marker is only worth anything if another process's environment can
1593    /// be read, and this binary is not one of Apple's.
1594    #[cfg(any(target_os = "macos", target_os = "linux"))]
1595    #[test]
1596    fn a_process_environment_can_be_read_back() {
1597        let env = table::environment(unsafe { libc::getpid() }).expect("readable");
1598        assert!(
1599            env.split(|&b| b == 0)
1600                .any(|entry| entry.starts_with(b"PATH=")),
1601            "{}",
1602            String::from_utf8_lossy(&env)
1603        );
1604    }
1605
1606    /// The sweep goes by ancestry, group, and the inherited variable, never by
1607    /// start time alone.
1608    #[cfg(unix)]
1609    #[test]
1610    fn a_process_that_only_started_during_the_call_is_left_alone() {
1611        let stranger = std::thread::spawn(|| {
1612            std::thread::sleep(Duration::from_millis(200));
1613            Command::new("sleep").arg("30").spawn()
1614        });
1615        run(
1616            &argv(&["/bin/sh", "-c", "sleep 1"]),
1617            &ExecOpts::new().stop_descendants(true),
1618        )
1619        .unwrap();
1620
1621        let mut stranger = stranger.join().unwrap().unwrap();
1622        let alive = stranger.try_wait().unwrap().is_none();
1623        let _ = stranger.kill();
1624        let _ = stranger.wait();
1625        assert!(alive, "the sweep killed a process the call did not start");
1626    }
1627
1628    /// A group that has just been killed holds only zombies, and macOS
1629    /// answers a signal to it with EPERM. Treated as a refusal, it turned a
1630    /// timeout into an uncertain write.
1631    #[cfg(unix)]
1632    #[test]
1633    fn a_group_of_zombies_is_not_a_refusal() {
1634        use std::os::unix::process::CommandExt;
1635        let mut child = Command::new("/bin/sh")
1636            .args(["-c", "exit 0"])
1637            .process_group(0)
1638            .spawn()
1639            .unwrap();
1640        let pid = libc::pid_t::try_from(child.id()).unwrap();
1641        // Exited and not yet reaped, so the group is still there.
1642        std::thread::sleep(Duration::from_millis(300));
1643        let result = signal_group(pid, libc::SIGKILL);
1644        let _ = child.wait();
1645        result.unwrap();
1646    }
1647
1648    #[cfg(unix)]
1649    #[test]
1650    fn a_nested_call_extends_the_list_rather_than_replacing_it() {
1651        assert_eq!("a", calls_value(None, "a"));
1652        assert_eq!("a", calls_value(Some(""), "a"));
1653        assert_eq!("outer:a", calls_value(Some("outer"), "a"));
1654    }
1655
1656    #[cfg(unix)]
1657    #[test]
1658    fn only_the_whole_token_in_the_variable_marks_a_process() {
1659        let env = b"HOME=/h\0SPAR_CALLS=1.2.3:9.8.7\0PATH=/bin\0";
1660        assert!(carries(env, "1.2.3"));
1661        assert!(carries(env, "9.8.7"));
1662        assert!(!carries(env, "1.2"));
1663        assert!(!carries(env, "2.3"));
1664        assert!(!carries(b"XSPAR_CALLS=1.2.3\0", "1.2.3"));
1665        assert!(!carries(b"OTHER=SPAR_CALLS=1.2.3\0", "1.2.3"));
1666    }
1667
1668    #[test]
1669    fn the_environment_is_read_past_the_arguments_and_stops_at_the_apple_strings() {
1670        let mut buf = 2i32.to_ne_bytes().to_vec();
1671        buf.extend_from_slice(b"/bin/sh\0\0\0\0sh\0SPAR_CALLS=in-an-argument\0");
1672        buf.extend_from_slice(b"SPAR_CALLS=t\0HOME=/h\0\0executable_path=/bin/sh\0");
1673
1674        assert_eq!(
1675            Some(&b"SPAR_CALLS=t\0HOME=/h\0"[..]),
1676            procargs_environment(&buf)
1677        );
1678        assert_eq!(None, procargs_environment(b"\x01\0"));
1679    }
1680
1681    #[cfg(unix)]
1682    #[test]
1683    fn a_stat_line_is_read_past_a_command_name_with_spaces_and_parentheses() {
1684        let stat = "4242 (a) (b c) S 17 4242 4242 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 \
1685                    987654 0 0";
1686        let entry = parse_stat(4242, stat, 501).unwrap();
1687
1688        assert_eq!(17, entry.ppid);
1689        assert_eq!(4242, entry.pgid);
1690        assert_eq!(4242, entry.sid);
1691        assert_eq!(501, entry.uid);
1692        assert_eq!(987_654, entry.start);
1693    }
1694
1695    #[test]
1696    fn timeout_kills_and_explains() {
1697        let err = run(
1698            &argv(&["sh", "-c", "sleep 30"]),
1699            &ExecOpts::new().timeout_secs(1),
1700        )
1701        .unwrap_err();
1702        assert!(err.to_string().contains("timed out"), "{err}");
1703    }
1704
1705    #[test]
1706    fn missing_binary_names_the_command() {
1707        let err = exec(
1708            &argv(&["spar-definitely-not-a-real-binary"]),
1709            &ExecOpts::new(),
1710        )
1711        .unwrap_err();
1712        assert!(
1713            err.to_string()
1714                .contains("spar-definitely-not-a-real-binary"),
1715            "{err}"
1716        );
1717    }
1718
1719    #[test]
1720    fn tilde_expands_against_home() {
1721        std::env::set_var("HOME", "/home/someone");
1722        assert_eq!(PathBuf::from("/home/someone/bin"), expand_tilde("~/bin"));
1723        assert_eq!(PathBuf::from("/absolute"), expand_tilde("/absolute"));
1724        assert_eq!(PathBuf::from("~notauser/x"), expand_tilde("~notauser/x"));
1725    }
1726}
1727
1728#[cfg(test)]
1729mod timeout_kind_tests {
1730    use super::*;
1731    use crate::error::ErrorKind;
1732
1733    fn argv(parts: &[&str]) -> Vec<String> {
1734        parts.iter().map(|s| s.to_string()).collect()
1735    }
1736
1737    /// A deadline is not a bad answer. Retrying one buys another wait of
1738    /// exactly the same length, which on a review at the highest effort
1739    /// setting turned a thirty minute failure into an hour of it.
1740    #[test]
1741    fn a_timeout_is_marked_as_one_and_is_not_worth_retrying() {
1742        let err = run(
1743            &["sh".to_string(), "-c".to_string(), "sleep 30".to_string()],
1744            &ExecOpts::new().timeout_secs(1),
1745        )
1746        .unwrap_err();
1747
1748        assert_eq!(ErrorKind::TimedOut, err.kind());
1749        assert!(!err.worth_retrying());
1750        assert!(err.to_string().contains("Not retried"), "{err}");
1751    }
1752
1753    /// `spar listen` runs for hours. Ctrl-C has to stop the agent call in
1754    /// flight rather than leave it running in its own process group with
1755    /// nobody to read its answer.
1756    #[test]
1757    fn a_cancelled_command_is_stopped_and_says_so() {
1758        let stop = Arc::new(AtomicBool::new(false));
1759        let flag = Arc::clone(&stop);
1760        std::thread::spawn(move || {
1761            std::thread::sleep(Duration::from_millis(200));
1762            flag.store(true, Ordering::SeqCst);
1763        });
1764        let started = Instant::now();
1765        let err = exec_until(
1766            &argv(&["sh", "-c", "sleep 30"]),
1767            &ExecOpts::new().timeout_secs(30).stop_descendants(true),
1768            None,
1769            &stop,
1770        )
1771        .unwrap_err();
1772
1773        assert_eq!(ErrorKind::Interrupted, err.kind());
1774        assert!(
1775            err.to_string().starts_with("interrupted: sh -c sleep 30"),
1776            "{err}"
1777        );
1778        assert!(
1779            started.elapsed() < Duration::from_secs(5),
1780            "took {:?}",
1781            started.elapsed()
1782        );
1783        assert!(!err.worth_retrying());
1784    }
1785
1786    #[test]
1787    fn a_command_asked_for_after_the_stop_is_refused_without_running() {
1788        let stop = AtomicBool::new(true);
1789        let err = exec_until(
1790            &argv(&["sh", "-c", "exit 0"]),
1791            &ExecOpts::new(),
1792            None,
1793            &stop,
1794        )
1795        .unwrap_err();
1796        assert_eq!(ErrorKind::Interrupted, err.kind());
1797        assert!(err.to_string().contains("before"), "{err}");
1798    }
1799
1800    #[test]
1801    fn a_sleep_ends_early_once_the_stop_is_set_and_on_time_otherwise() {
1802        let stop = Arc::new(AtomicBool::new(false));
1803        let flag = Arc::clone(&stop);
1804        std::thread::spawn(move || {
1805            std::thread::sleep(Duration::from_millis(100));
1806            flag.store(true, Ordering::SeqCst);
1807        });
1808        let started = Instant::now();
1809        assert!(sleep_until(Duration::from_secs(30), &stop));
1810        assert!(started.elapsed() < Duration::from_secs(5));
1811
1812        let calm = AtomicBool::new(false);
1813        let started = Instant::now();
1814        assert!(!sleep_until(Duration::from_millis(50), &calm));
1815        assert!(started.elapsed() >= Duration::from_millis(50));
1816    }
1817
1818    /// A non-zero exit is the CLI reporting that it could not answer, which is
1819    /// a different thing from an answer that arrived and could not be parsed.
1820    /// Only the second is what the retry exists for.
1821    #[test]
1822    fn a_non_zero_exit_is_the_call_failing_rather_than_the_answer() {
1823        let err = run(
1824            &["sh".to_string(), "-c".to_string(), "exit 1".to_string()],
1825            &ExecOpts::new(),
1826        )
1827        .unwrap_err();
1828
1829        assert_eq!(ErrorKind::CallFailed, err.kind());
1830        // Not hopeless in the abstract, since it could have been transient.
1831        // Whether to spend a second call on it is `Agent`'s decision, and it
1832        // turns on whether there is a stand in to send the call to instead.
1833        assert!(err.worth_retrying());
1834    }
1835}