Skip to main content

sim_lib_agent_runner_process/
process.rs

1use sim_kernel::{Error, Result};
2use std::{
3    io::{BufRead, BufReader, Read, Write},
4    process::{Child, Command, Stdio},
5    sync::{Arc, Mutex, mpsc},
6    thread,
7    time::{Duration, Instant},
8};
9
10/// Describes a subprocess command invocation and its resource bounds.
11#[derive(Clone, Debug)]
12pub struct ProcessCommandSpec {
13    command: String,
14    label: String,
15    timeout: Duration,
16    max_output_bytes: usize,
17}
18
19impl ProcessCommandSpec {
20    /// Builds a spec running `command` (labelled `label` for diagnostics),
21    /// bounded by `timeout` and `max_output_bytes`.
22    pub fn new(
23        command: impl Into<String>,
24        label: impl Into<String>,
25        timeout: Duration,
26        max_output_bytes: usize,
27    ) -> Self {
28        Self {
29            command: command.into(),
30            label: label.into(),
31            timeout,
32            max_output_bytes,
33        }
34    }
35}
36
37/// Runs the command in `spec`, feeding `stdin`, and returns its captured stdout.
38pub fn run_process_command(spec: &ProcessCommandSpec, stdin: Vec<u8>) -> Result<Vec<u8>> {
39    run_command(
40        &spec.command,
41        stdin,
42        &spec.label,
43        spec.timeout,
44        spec.max_output_bytes,
45    )
46}
47
48/// Runs the command in `spec`, feeding `stdin`, invoking `on_line` for each
49/// stdout line as it arrives, and returns the full captured stdout.
50pub fn stream_process_command_lines(
51    spec: &ProcessCommandSpec,
52    stdin: Vec<u8>,
53    on_line: impl FnMut(&[u8]) -> Result<()>,
54) -> Result<Vec<u8>> {
55    stream_command_lines(
56        &spec.command,
57        stdin,
58        &spec.label,
59        spec.timeout,
60        spec.max_output_bytes,
61        on_line,
62    )
63}
64
65pub(super) fn shell_child(command: &str) -> Command {
66    let mut child = Command::new("/bin/sh");
67    // `-c` (not `-lc`): a model call must not source the host's login profile
68    // files; it runs only the command it was given.
69    child.arg("-c").arg(command);
70    child
71        .stdin(Stdio::piped())
72        .stdout(Stdio::piped())
73        .stderr(Stdio::null());
74    child
75}
76
77pub(super) fn capture_child_output(
78    mut child: Child,
79    stdin: Vec<u8>,
80    label: &str,
81    timeout: Duration,
82    max_output_bytes: usize,
83) -> Result<Vec<u8>> {
84    let stdout = child
85        .stdout
86        .take()
87        .ok_or_else(|| Error::HostError(format!("{label} stdout was not captured")))?;
88    let stdin_handle = child
89        .stdin
90        .take()
91        .ok_or_else(|| Error::HostError(format!("{label} stdin was not captured")))?;
92    let child = Arc::new(Mutex::new(child));
93
94    // Keep the raw io error so the join site can tell a benign EPIPE/WriteZero
95    // (the child stopped reading early) from a real write failure.
96    let writer = thread::spawn(move || -> std::io::Result<()> {
97        let mut stdin_handle = stdin_handle;
98        stdin_handle.write_all(&stdin)
99    });
100    let (tx, rx) = mpsc::channel();
101    let reader = thread::spawn(move || {
102        let mut reader = stdout;
103        let mut bytes = Vec::new();
104        let mut chunk = [0_u8; 4096];
105        loop {
106            match reader.read(&mut chunk) {
107                Ok(0) => break,
108                Ok(read) => {
109                    let remaining = max_output_bytes
110                        .saturating_add(1)
111                        .saturating_sub(bytes.len());
112                    if remaining == 0 {
113                        break;
114                    }
115                    let take = remaining.min(read);
116                    bytes.extend_from_slice(&chunk[..take]);
117                    if bytes.len() > max_output_bytes {
118                        break;
119                    }
120                }
121                Err(err) => {
122                    let _ = tx.send(Err(io_error_to_host(err)));
123                    return;
124                }
125            }
126        }
127        let _ = tx.send(Ok(bytes));
128    });
129
130    // Bound BOTH the child exit and the stdout drain by the deadline. A
131    // backgrounded grandchild can hold the stdout pipe open after the shell
132    // exits, so an unbounded `rx.recv()` here would hang forever despite the
133    // timeout; pace the wait with `recv_timeout` and kill on expiry.
134    let deadline = Instant::now() + timeout;
135    let mut status: Option<std::process::ExitStatus> = None;
136    let mut captured: Option<Result<Vec<u8>>> = None;
137    loop {
138        if status.is_none() {
139            let mut child = child
140                .lock()
141                .map_err(|_| Error::HostError(format!("{label} mutex poisoned")))?;
142            status = child.try_wait().map_err(io_error_to_host)?;
143        }
144        if captured.is_none() {
145            match rx.recv_timeout(Duration::from_millis(10)) {
146                Ok(message) => captured = Some(message),
147                Err(mpsc::RecvTimeoutError::Timeout) => {}
148                Err(mpsc::RecvTimeoutError::Disconnected) => captured = Some(Ok(Vec::new())),
149            }
150        } else if status.is_none() {
151            thread::sleep(Duration::from_millis(10));
152        }
153        if status.is_some() && captured.is_some() {
154            break;
155        }
156        if Instant::now() >= deadline {
157            let mut child = child
158                .lock()
159                .map_err(|_| Error::HostError(format!("{label} mutex poisoned")))?;
160            let _ = child.kill();
161            let _ = child.wait();
162            // Do not join the reader/writer here: either may still be blocked on
163            // a pipe a grandchild holds open, which would re-introduce the hang.
164            return Err(Error::Eval(format!(
165                "{label} timed out after {}ms",
166                timeout.as_millis()
167            )));
168        }
169    }
170
171    let status =
172        status.ok_or_else(|| Error::HostError(format!("{label} status was not captured")))?;
173    let bytes =
174        captured.ok_or_else(|| Error::HostError(format!("{label} stdout reader failed")))??;
175    reader
176        .join()
177        .map_err(|_| Error::HostError(format!("{label} stdout reader panicked")))?;
178    let writer_outcome = writer
179        .join()
180        .map_err(|_| Error::HostError(format!("{label} stdin writer panicked")))?;
181    if bytes.len() > max_output_bytes {
182        return Err(Error::Eval(format!(
183            "{label} exceeded max output bytes {max_output_bytes}"
184        )));
185    }
186    if !status.success() {
187        // The exit status is the real diagnostic for an early-exiting command;
188        // a stdin EPIPE caused by that early exit must not mask it.
189        return Err(Error::Eval(format!(
190            "{label} exited with status {}",
191            status
192                .code()
193                .map(|code| code.to_string())
194                .unwrap_or_else(|| "signal".to_owned())
195        )));
196    }
197    // The child succeeded. Surface a stdin write error only when it is not a
198    // benign pipe closure from the child closing stdin after reading enough.
199    if let Err(err) = writer_outcome
200        && !is_benign_stdin_pipe_error(&err)
201    {
202        return Err(io_error_to_host(err));
203    }
204    Ok(bytes)
205}
206
207/// Whether a stdin-writer error is the benign "child stopped reading" closure
208/// (EPIPE / zero-length write) rather than a real I/O failure.
209fn is_benign_stdin_pipe_error(err: &std::io::Error) -> bool {
210    matches!(
211        err.kind(),
212        std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::WriteZero
213    )
214}
215
216pub(super) fn run_command(
217    command: &str,
218    stdin: Vec<u8>,
219    label: &str,
220    timeout: Duration,
221    max_output_bytes: usize,
222) -> Result<Vec<u8>> {
223    let child = shell_child(command).spawn().map_err(io_error_to_host)?;
224    capture_child_output(child, stdin, label, timeout, max_output_bytes)
225}
226
227pub(super) fn stream_command_lines(
228    command: &str,
229    stdin: Vec<u8>,
230    label: &str,
231    timeout: Duration,
232    max_output_bytes: usize,
233    mut on_line: impl FnMut(&[u8]) -> Result<()>,
234) -> Result<Vec<u8>> {
235    let mut child = shell_child(command).spawn().map_err(io_error_to_host)?;
236    let stdout = child
237        .stdout
238        .take()
239        .ok_or_else(|| Error::HostError(format!("{label} stdout was not captured")))?;
240    let stdin_handle = child
241        .stdin
242        .take()
243        .ok_or_else(|| Error::HostError(format!("{label} stdin was not captured")))?;
244    let child = Arc::new(Mutex::new(child));
245    // Return the raw io error so the join site can tell a benign EPIPE/WriteZero
246    // (the child stopped reading stdin early -- e.g. a command that ignores its
247    // input and exits) from a real write failure, matching capture_child_output.
248    let writer = thread::spawn(move || -> std::io::Result<()> {
249        let mut stdin_handle = stdin_handle;
250        stdin_handle.write_all(&stdin)
251    });
252    let (tx, rx) = mpsc::channel();
253    let reader = thread::spawn(move || {
254        let mut reader = BufReader::new(stdout);
255        loop {
256            let mut line = Vec::new();
257            match reader.read_until(b'\n', &mut line) {
258                Ok(0) => break,
259                Ok(_) => {
260                    if tx.send(Ok(line)).is_err() {
261                        return;
262                    }
263                }
264                Err(err) => {
265                    let _ = tx.send(Err(io_error_to_host(err)));
266                    return;
267                }
268            }
269        }
270    });
271
272    let deadline = Instant::now() + timeout;
273    let mut bytes = Vec::new();
274    let mut status = None;
275    let mut reader_done = false;
276    while !reader_done || status.is_none() {
277        match rx.recv_timeout(Duration::from_millis(10)) {
278            Ok(Ok(line)) => {
279                if bytes.len().saturating_add(line.len()) > max_output_bytes {
280                    kill_child(&child);
281                    // Do not join reader/writer: a backgrounded grandchild can
282                    // keep the stdout/stdin pipes open after the direct child is
283                    // killed, so joining would block past the bound. Mirror the
284                    // non-streaming timeout path in capture_child_output.
285                    return Err(Error::Eval(format!(
286                        "{label} exceeded max output bytes {max_output_bytes}"
287                    )));
288                }
289                on_line(&line)?;
290                bytes.extend_from_slice(&line);
291            }
292            Ok(Err(err)) => {
293                kill_child(&child);
294                // Do not join reader/writer: a grandchild may still hold the
295                // pipes open, which would re-introduce the hang.
296                return Err(err);
297            }
298            Err(mpsc::RecvTimeoutError::Timeout) => {}
299            Err(mpsc::RecvTimeoutError::Disconnected) => {
300                reader_done = true;
301            }
302        }
303        if status.is_none() {
304            let mut child = child
305                .lock()
306                .map_err(|_| Error::HostError(format!("{label} mutex poisoned")))?;
307            status = child.try_wait().map_err(io_error_to_host)?;
308        }
309        if Instant::now() >= deadline {
310            kill_child(&child);
311            // Do not join reader/writer here: either may still be blocked on a
312            // pipe a grandchild holds open, which would re-introduce the hang.
313            // Mirror the non-streaming timeout path in capture_child_output.
314            return Err(Error::Eval(format!(
315                "{label} timed out after {}ms",
316                timeout.as_millis()
317            )));
318        }
319    }
320    reader
321        .join()
322        .map_err(|_| Error::HostError(format!("{label} stdout reader panicked")))?;
323    let writer_outcome = writer
324        .join()
325        .map_err(|_| Error::HostError(format!("{label} stdin writer panicked")))?;
326    let status =
327        status.ok_or_else(|| Error::HostError(format!("{label} status was not captured")))?;
328    if !status.success() {
329        // The exit status is the real diagnostic for an early-exiting command;
330        // a stdin EPIPE caused by that early exit must not mask it.
331        return Err(Error::Eval(format!(
332            "{label} exited with status {}",
333            status
334                .code()
335                .map(|code| code.to_string())
336                .unwrap_or_else(|| "signal".to_owned())
337        )));
338    }
339    // The child succeeded. Surface a stdin write error only when it is not a
340    // benign pipe closure from the child closing stdin after reading enough.
341    if let Err(err) = writer_outcome
342        && !is_benign_stdin_pipe_error(&err)
343    {
344        return Err(io_error_to_host(err));
345    }
346    Ok(bytes)
347}
348
349fn kill_child(child: &Arc<Mutex<Child>>) {
350    if let Ok(mut child) = child.lock() {
351        let _ = child.kill();
352        let _ = child.wait();
353    }
354}
355
356pub(super) fn io_error_to_host(err: std::io::Error) -> Error {
357    Error::HostError(err.to_string())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::{ProcessProtocol, ProcessRunner, effects::host_process_capability};
364    use sim_kernel::{CapabilityName, Cx, DefaultFactory, Expr, NoopEvalPolicy, Symbol};
365    use sim_lib_agent_runner_core::ModelRequest;
366
367    #[test]
368    fn denied_host_process_refuses_before_spawn() {
369        let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
370        let runner = ProcessRunner::new(
371            Symbol::new("p"),
372            "m",
373            "echo should-not-run",
374            ProcessProtocol::LineText,
375            Duration::from_secs(5),
376            1024,
377        );
378        let request = ModelRequest::new(Expr::String("hi".to_owned()), Vec::new());
379
380        let err = crate::effects::resolve_process_effect(&runner, &mut cx, request, |_, _| {
381            panic!("subprocess spawned despite denied host-process capability")
382        })
383        .expect_err("denied capability must refuse before spawn");
384
385        assert!(matches!(
386            err,
387            Error::CapabilityDenied { capability }
388                if capability == host_process_capability()
389        ));
390    }
391
392    #[test]
393    fn granted_host_process_allows_spawn() {
394        let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
395        cx.grant(CapabilityName::new("host.process"));
396        // With the capability granted, the effect-path `cx.require` passes, so a
397        // real `infer` would proceed to spawn; exercise the spawn directly.
398        assert!(cx.require(&host_process_capability()).is_ok());
399        let bytes = run_command(
400            "printf ok",
401            Vec::new(),
402            "test",
403            Duration::from_secs(5),
404            1024,
405        )
406        .expect("granted command runs");
407        assert_eq!(bytes, b"ok");
408    }
409
410    #[test]
411    fn streaming_tolerates_benign_stdin_epipe_from_early_exit() {
412        // A streamed command that ignores stdin and exits closes its stdin
413        // read-end before the writer drains the payload. A 1 MiB payload exceeds
414        // the OS pipe buffer, so write_all is still blocked when the child exits,
415        // making the resulting EPIPE deterministic. It is benign and must NOT
416        // fail the call (regression: the streaming path used to map+propagate it,
417        // which flaked under load while the non-streaming path tolerated it).
418        let mut lines = Vec::new();
419        let bytes = stream_command_lines(
420            "printf 'one\\ntwo\\n'",
421            vec![b'x'; 1024 * 1024],
422            "test",
423            Duration::from_secs(5),
424            1024,
425            |line| {
426                lines.push(
427                    String::from_utf8_lossy(line)
428                        .trim_end_matches(['\r', '\n'])
429                        .to_owned(),
430                );
431                Ok(())
432            },
433        )
434        .expect("a stdin-ignoring streamed command must not fail on benign EPIPE");
435        assert_eq!(lines, vec!["one".to_owned(), "two".to_owned()]);
436        assert_eq!(bytes, b"one\ntwo\n");
437    }
438
439    #[test]
440    fn backgrounded_grandchild_returns_at_timeout_not_hang() {
441        let start = Instant::now();
442        let err = run_command(
443            "sleep 0.01; (sleep 60 &)",
444            Vec::new(),
445            "test",
446            Duration::from_millis(300),
447            4096,
448        )
449        .expect_err("a grandchild holding stdout must not hang past the deadline");
450
451        assert!(matches!(err, Error::Eval(message) if message.contains("timed out")));
452        // Far below the 60s grandchild sleep: the deadline, not the grandchild,
453        // bounded the wait.
454        assert!(start.elapsed() < Duration::from_secs(5));
455    }
456
457    #[test]
458    fn streaming_backgrounded_grandchild_returns_at_timeout_not_hang() {
459        // A shell grandchild inherits stdout and keeps it open after the direct
460        // child exits. The streaming reader thread stays blocked on that pipe, so
461        // joining it at the deadline would hang forever; the timeout branch must
462        // return without joining (regression for the streaming path).
463        let start = Instant::now();
464        let mut lines = Vec::new();
465        let err = stream_command_lines(
466            "printf 'ready\\n'; (sleep 60 &); sleep 60",
467            Vec::new(),
468            "test",
469            Duration::from_millis(300),
470            4096,
471            |line| {
472                lines.push(
473                    String::from_utf8_lossy(line)
474                        .trim_end_matches(['\r', '\n'])
475                        .to_owned(),
476                );
477                Ok(())
478            },
479        )
480        .expect_err("a streaming grandchild must not hang past the deadline");
481
482        assert!(matches!(err, Error::Eval(message) if message.contains("timed out")));
483        assert!(lines.contains(&"ready".to_owned()));
484        // Far below the 60s grandchild sleep: the deadline bounded the wait.
485        assert!(start.elapsed() < Duration::from_secs(5));
486    }
487
488    #[test]
489    fn streaming_max_output_with_grandchild_returns_not_hang() {
490        // The max-output branch also kills the child then used to join; a
491        // grandchild holding stdout would hang it. Emit past the cap while a
492        // grandchild keeps the pipe open and confirm a prompt bounded error.
493        let start = Instant::now();
494        let err = stream_command_lines(
495            "(sleep 60 &); printf 'aaaaaaaaaa\\nbbbbbbbbbb\\n'; sleep 60",
496            Vec::new(),
497            "test",
498            Duration::from_secs(5),
499            4,
500            |_line| Ok(()),
501        )
502        .expect_err("exceeding max output must return, not hang on a grandchild");
503
504        assert!(matches!(err, Error::Eval(message) if message.contains("max output bytes")));
505        assert!(start.elapsed() < Duration::from_secs(5));
506    }
507
508    #[test]
509    fn early_exit_reports_status_not_stdin_pipe_error() {
510        // `head -c1` consumes one byte then the shell exits 3, dropping the read
511        // end of stdin while the writer still has ~1 MiB to push (EPIPE).
512        let stdin = vec![b'x'; 1 << 20];
513        let err = run_command(
514            "head -c1 >/dev/null; exit 3",
515            stdin,
516            "test",
517            Duration::from_secs(5),
518            1 << 20,
519        )
520        .expect_err("a non-zero exit must surface as an error");
521
522        match err {
523            Error::Eval(message) => {
524                assert!(
525                    message.contains("exited with status 3"),
526                    "expected exit-3 status, got: {message}"
527                );
528                assert!(
529                    !message.to_lowercase().contains("pipe"),
530                    "stdin pipe error masked the real exit status: {message}"
531                );
532            }
533            other => panic!("expected an Eval status error, got: {other}"),
534        }
535    }
536}