Skip to main content

areev_loop/
proc.rs

1//! Bounded subprocess spawning for the engine's two host-command seams
2//! (`--llm-cmd`, `--analyzer-cmd`).
3//!
4//! **This is a deliberate duplicate of `areev_core::proc`.** This crate's
5//! `Cargo.toml` states the engine must never depend on an areev-* sibling, so
6//! it cannot use the canonical module. `areev-loop/tests/proc_contract.rs`
7//! pins the two to the same observable behaviour; change one and the test tells
8//! you to change the other.
9//!
10//! Scope is trimmed to what these two seams need: argv commands (never a
11//! shell), inherited stderr, no working-directory control, and no
12//! clear-the-environment mode. Everything load-bearing is identical — a
13//! wall-clock ceiling, an output cap that keeps draining, stdin on its own
14//! thread, and a registry of variables that must never reach a child.
15
16use std::collections::BTreeSet;
17use std::io::{self, Read, Write};
18use std::process::{Child, Command, ExitStatus, Stdio};
19use std::time::{Duration, Instant};
20
21/// Matches `areev_core::proc::DEFAULT_TIMEOUT`.
22pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
23/// Matches `areev_core::proc::DEFAULT_MAX_OUTPUT`.
24pub const DEFAULT_MAX_OUTPUT: usize = 64 * 1024 * 1024;
25
26static SECRET_ENV: std::sync::Mutex<Option<BTreeSet<String>>> = std::sync::Mutex::new(None);
27
28/// Register a variable whose value must never reach a child process.
29///
30/// A host that also uses `areev_core::proc` must register with both — the two
31/// registries cannot be shared without the dependency this crate refuses.
32pub fn deny_env_var(name: &str) {
33    if name.trim().is_empty() {
34        return;
35    }
36    let mut guard = SECRET_ENV.lock().unwrap_or_else(|e| e.into_inner());
37    guard.get_or_insert_with(BTreeSet::new).insert(name.to_string());
38}
39
40/// The registered secret variable names.
41pub fn secret_env_vars() -> Vec<String> {
42    let guard = SECRET_ENV.lock().unwrap_or_else(|e| e.into_inner());
43    guard.as_ref().map(|s| s.iter().cloned().collect()).unwrap_or_default()
44}
45
46/// What a spawn produced.
47#[derive(Debug)]
48pub struct SpawnOutput {
49    pub status: ExitStatus,
50    pub stdout: Vec<u8>,
51    pub timed_out: bool,
52    pub stdout_truncated: bool,
53}
54
55impl SpawnOutput {
56    /// The reason this spawn failed, or `None` if it succeeded.
57    pub fn failure(&self, what: &str) -> Option<String> {
58        if self.timed_out {
59            return Some(format!("{what} timed out and was killed"));
60        }
61        if !self.status.success() {
62            return Some(format!("{what} exited with {}", self.status));
63        }
64        None
65    }
66}
67
68/// Run `argv` with `stdin`, bounded by `timeout` and the output cap.
69///
70/// stderr is inherited: these seams are driven from a terminal where the
71/// command's own diagnostics are what an operator needs to see.
72pub fn run_argv(argv: &[String], stdin: &str, timeout: Option<Duration>) -> io::Result<SpawnOutput> {
73    let mut cmd = Command::new(&argv[0]);
74    cmd.args(&argv[1..]);
75    for var in secret_env_vars() {
76        cmd.env_remove(var);
77    }
78    cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit());
79
80    let mut child = cmd.spawn()?;
81
82    // stdin on its own thread: writing the whole payload before reading any
83    // output deadlocks once the child's output fills the pipe buffer.
84    let payload = stdin.as_bytes().to_vec();
85    let stdin_thread = child.stdin.take().map(|mut pipe| {
86        std::thread::spawn(move || {
87            let _ = pipe.write_all(&payload);
88        })
89    });
90    let out_thread = child.stdout.take().map(|pipe| std::thread::spawn(move || drain(pipe)));
91
92    let (status, timed_out) = wait_bounded(&mut child, timeout)?;
93
94    if let Some(t) = stdin_thread {
95        let _ = t.join();
96    }
97    let (stdout, stdout_truncated) =
98        out_thread.and_then(|t| t.join().ok()).unwrap_or((Vec::new(), false));
99
100    Ok(SpawnOutput { status, stdout, timed_out, stdout_truncated })
101}
102
103/// Read to EOF keeping at most [`DEFAULT_MAX_OUTPUT`]. Past the cap the bytes
104/// are read and dropped — leaving them unread would block the child forever.
105fn drain<R: Read>(mut src: R) -> (Vec<u8>, bool) {
106    let mut kept = Vec::new();
107    let mut buf = [0u8; 16 * 1024];
108    let mut truncated = false;
109    loop {
110        match src.read(&mut buf) {
111            Ok(0) => break,
112            Ok(n) => {
113                if kept.len() < DEFAULT_MAX_OUTPUT {
114                    let take = (DEFAULT_MAX_OUTPUT - kept.len()).min(n);
115                    kept.extend_from_slice(&buf[..take]);
116                    if take < n {
117                        truncated = true;
118                    }
119                } else {
120                    truncated = true;
121                }
122            }
123            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
124            Err(_) => break,
125        }
126    }
127    (kept, truncated)
128}
129
130fn wait_bounded(child: &mut Child, timeout: Option<Duration>) -> io::Result<(ExitStatus, bool)> {
131    let Some(limit) = timeout else {
132        return Ok((child.wait()?, false));
133    };
134    let deadline = Instant::now() + limit;
135    let mut nap = Duration::from_millis(1);
136    loop {
137        if let Some(status) = child.try_wait()? {
138            return Ok((status, false));
139        }
140        if Instant::now() >= deadline {
141            let _ = child.kill();
142            let status = child.wait()?;
143            return Ok((status, true));
144        }
145        std::thread::sleep(nap);
146        nap = (nap * 2).min(Duration::from_millis(50));
147    }
148}