sim_lib_agent_runner_process/
process.rs1use 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#[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 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
37pub 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
48pub 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 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 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 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 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 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 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
207fn 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 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 let _ = reader.join();
282 let _ = writer.join();
283 return Err(Error::Eval(format!(
284 "{label} exceeded max output bytes {max_output_bytes}"
285 )));
286 }
287 on_line(&line)?;
288 bytes.extend_from_slice(&line);
289 }
290 Ok(Err(err)) => {
291 kill_child(&child);
292 let _ = reader.join();
293 let _ = writer.join();
294 return Err(err);
295 }
296 Err(mpsc::RecvTimeoutError::Timeout) => {}
297 Err(mpsc::RecvTimeoutError::Disconnected) => {
298 reader_done = true;
299 }
300 }
301 if status.is_none() {
302 let mut child = child
303 .lock()
304 .map_err(|_| Error::HostError(format!("{label} mutex poisoned")))?;
305 status = child.try_wait().map_err(io_error_to_host)?;
306 }
307 if Instant::now() >= deadline {
308 kill_child(&child);
309 let _ = reader.join();
310 let _ = writer.join();
311 return Err(Error::Eval(format!(
312 "{label} timed out after {}ms",
313 timeout.as_millis()
314 )));
315 }
316 }
317 reader
318 .join()
319 .map_err(|_| Error::HostError(format!("{label} stdout reader panicked")))?;
320 let writer_outcome = writer
321 .join()
322 .map_err(|_| Error::HostError(format!("{label} stdin writer panicked")))?;
323 let status =
324 status.ok_or_else(|| Error::HostError(format!("{label} status was not captured")))?;
325 if !status.success() {
326 return Err(Error::Eval(format!(
329 "{label} exited with status {}",
330 status
331 .code()
332 .map(|code| code.to_string())
333 .unwrap_or_else(|| "signal".to_owned())
334 )));
335 }
336 if let Err(err) = writer_outcome
339 && !is_benign_stdin_pipe_error(&err)
340 {
341 return Err(io_error_to_host(err));
342 }
343 Ok(bytes)
344}
345
346fn kill_child(child: &Arc<Mutex<Child>>) {
347 if let Ok(mut child) = child.lock() {
348 let _ = child.kill();
349 let _ = child.wait();
350 }
351}
352
353pub(super) fn io_error_to_host(err: std::io::Error) -> Error {
354 Error::HostError(err.to_string())
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::{ProcessProtocol, ProcessRunner, effects::host_process_capability};
361 use sim_kernel::{CapabilityName, Cx, DefaultFactory, Expr, NoopEvalPolicy, Symbol};
362 use sim_lib_agent_runner_core::ModelRequest;
363
364 #[test]
365 fn denied_host_process_refuses_before_spawn() {
366 let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
367 let runner = ProcessRunner::new(
368 Symbol::new("p"),
369 "m",
370 "echo should-not-run",
371 ProcessProtocol::LineText,
372 Duration::from_secs(5),
373 1024,
374 );
375 let request = ModelRequest::new(Expr::String("hi".to_owned()), Vec::new());
376
377 let err = crate::effects::resolve_process_effect(&runner, &mut cx, request, |_, _| {
378 panic!("subprocess spawned despite denied host-process capability")
379 })
380 .expect_err("denied capability must refuse before spawn");
381
382 assert!(matches!(
383 err,
384 Error::CapabilityDenied { capability }
385 if capability == host_process_capability()
386 ));
387 }
388
389 #[test]
390 fn granted_host_process_allows_spawn() {
391 let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
392 cx.grant(CapabilityName::new("host.process"));
393 assert!(cx.require(&host_process_capability()).is_ok());
396 let bytes = run_command(
397 "printf ok",
398 Vec::new(),
399 "test",
400 Duration::from_secs(5),
401 1024,
402 )
403 .expect("granted command runs");
404 assert_eq!(bytes, b"ok");
405 }
406
407 #[test]
408 fn streaming_tolerates_benign_stdin_epipe_from_early_exit() {
409 let mut lines = Vec::new();
416 let bytes = stream_command_lines(
417 "printf 'one\\ntwo\\n'",
418 vec![b'x'; 1024 * 1024],
419 "test",
420 Duration::from_secs(5),
421 1024,
422 |line| {
423 lines.push(
424 String::from_utf8_lossy(line)
425 .trim_end_matches(['\r', '\n'])
426 .to_owned(),
427 );
428 Ok(())
429 },
430 )
431 .expect("a stdin-ignoring streamed command must not fail on benign EPIPE");
432 assert_eq!(lines, vec!["one".to_owned(), "two".to_owned()]);
433 assert_eq!(bytes, b"one\ntwo\n");
434 }
435
436 #[test]
437 fn backgrounded_grandchild_returns_at_timeout_not_hang() {
438 let start = Instant::now();
439 let err = run_command(
440 "sleep 0.01; (sleep 60 &)",
441 Vec::new(),
442 "test",
443 Duration::from_millis(300),
444 4096,
445 )
446 .expect_err("a grandchild holding stdout must not hang past the deadline");
447
448 assert!(matches!(err, Error::Eval(message) if message.contains("timed out")));
449 assert!(start.elapsed() < Duration::from_secs(5));
452 }
453
454 #[test]
455 fn early_exit_reports_status_not_stdin_pipe_error() {
456 let stdin = vec![b'x'; 1 << 20];
459 let err = run_command(
460 "head -c1 >/dev/null; exit 3",
461 stdin,
462 "test",
463 Duration::from_secs(5),
464 1 << 20,
465 )
466 .expect_err("a non-zero exit must surface as an error");
467
468 match err {
469 Error::Eval(message) => {
470 assert!(
471 message.contains("exited with status 3"),
472 "expected exit-3 status, got: {message}"
473 );
474 assert!(
475 !message.to_lowercase().contains("pipe"),
476 "stdin pipe error masked the real exit status: {message}"
477 );
478 }
479 other => panic!("expected an Eval status error, got: {other}"),
480 }
481 }
482}