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_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 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 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 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 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
217fn 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 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 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 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 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 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 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 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 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 assert!(start.elapsed() < Duration::from_secs(5));
476 }
477
478 #[test]
479 fn streaming_backgrounded_grandchild_returns_at_timeout_not_hang() {
480 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 assert!(start.elapsed() < Duration::from_secs(5));
507 }
508
509 #[test]
510 fn streaming_max_output_with_grandchild_returns_not_hang() {
511 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 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}