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