1use std::io::Read;
2use std::path::Path;
3use std::process::Stdio;
4use std::time::{Duration, Instant};
5
6use callisto_model::{CommandError, CommandOutput, CommandRunner};
7
8const MAX_CAPTURED_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
15
16pub struct CliCommandRunner;
17
18impl CommandRunner for CliCommandRunner {
19 fn run(&self, program: &str, args: &[&str], cwd: &Path) -> Result<CommandOutput, CommandError> {
20 let output = std::process::Command::new(program)
21 .args(args)
22 .current_dir(cwd)
23 .stdin(Stdio::null())
24 .stdout(Stdio::piped())
25 .stderr(Stdio::piped())
26 .output();
27
28 match output {
29 Ok(o) => {
30 let stderr = String::from_utf8_lossy(&o.stderr).into_owned();
31 if !stderr.is_empty() {
34 eprint!("{stderr}");
35 }
36 Ok(CommandOutput {
37 exit_code: o.status.code(),
38 stdout: String::from_utf8_lossy(&o.stdout).into_owned(),
39 stderr,
40 })
41 }
42 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(CommandError::NotFound {
43 program: program.to_string(),
44 }),
45 Err(e) => Err(CommandError::Io {
46 program: program.to_string(),
47 message: e.to_string(),
48 }),
49 }
50 }
51
52 fn run_with_timeout(
53 &self,
54 program: &str,
55 args: &[&str],
56 cwd: &Path,
57 timeout: Duration,
58 ) -> Result<CommandOutput, CommandError> {
59 run_with_timeout_impl(program, args, cwd, timeout, StderrMode::Live)
60 }
61
62 fn run_quiet(
63 &self,
64 program: &str,
65 args: &[&str],
66 cwd: &Path,
67 timeout: Duration,
68 ) -> Result<CommandOutput, CommandError> {
69 run_with_timeout_impl(program, args, cwd, timeout, StderrMode::Quiet)
70 }
71}
72
73#[derive(Clone, Copy, PartialEq, Eq)]
78enum StderrMode {
79 Live,
80 Quiet,
81}
82
83fn run_with_timeout_impl(
84 program: &str,
85 args: &[&str],
86 cwd: &Path,
87 timeout: Duration,
88 stderr_mode: StderrMode,
89) -> Result<CommandOutput, CommandError> {
90 let mut child = std::process::Command::new(program)
91 .args(args)
92 .current_dir(cwd)
93 .stdin(Stdio::null())
94 .stdout(Stdio::piped())
95 .stderr(Stdio::piped())
96 .spawn()
97 .map_err(|e| {
98 if e.kind() == std::io::ErrorKind::NotFound {
99 CommandError::NotFound {
100 program: program.to_string(),
101 }
102 } else {
103 CommandError::Io {
104 program: program.to_string(),
105 message: e.to_string(),
106 }
107 }
108 })?;
109
110 let stdout_handle = child.stdout.take().unwrap();
113 let stderr_handle = child.stderr.take().unwrap();
114
115 let (stdout_tx, stdout_rx) = std::sync::mpsc::channel::<String>();
124 let (stderr_tx, stderr_rx) = std::sync::mpsc::channel::<String>();
125
126 std::thread::spawn(move || {
127 let mut reader = stdout_handle;
128 let mut captured: Vec<u8> = Vec::new();
129 let mut truncated = false;
130 let mut chunk = [0u8; 65536];
131 loop {
132 match reader.read(&mut chunk) {
133 Ok(0) => break,
134 Ok(n) => {
135 if !truncated {
139 let remaining = MAX_CAPTURED_OUTPUT_BYTES - captured.len();
140 let take = n.min(remaining);
141 captured.extend_from_slice(&chunk[..take]);
142 if take < n {
143 truncated = true;
144 }
145 }
146 }
147 Err(_) => break,
148 }
149 }
150 let mut text = String::from_utf8_lossy(&captured).into_owned();
151 if truncated {
152 text.push_str("\n...[output truncated]\n");
153 }
154 drop(stdout_tx.send(text));
155 });
156 std::thread::spawn(move || {
166 let mut reader = stderr_handle;
167 let mut captured: Vec<u8> = Vec::new();
168 let mut truncated = false;
169 let mut pending_line: Vec<u8> = Vec::new();
170 let mut chunk = [0u8; 65536];
171 loop {
172 match reader.read(&mut chunk) {
173 Ok(0) => break,
174 Ok(n) => {
175 let data = &chunk[..n];
176 for &b in data {
177 if b == b'\n' {
178 if pending_line.last() == Some(&b'\r') {
179 pending_line.pop();
180 }
181 if stderr_mode == StderrMode::Live {
182 eprintln!("{}", String::from_utf8_lossy(&pending_line));
183 }
184 pending_line.clear();
185 } else if pending_line.len() < MAX_CAPTURED_OUTPUT_BYTES {
186 pending_line.push(b);
187 }
188 }
189 if !truncated {
190 let remaining = MAX_CAPTURED_OUTPUT_BYTES - captured.len();
191 let take = data.len().min(remaining);
192 captured.extend_from_slice(&data[..take]);
193 if take < data.len() {
194 truncated = true;
195 }
196 }
197 }
198 Err(_) => break,
199 }
200 }
201 if !pending_line.is_empty() {
202 if pending_line.last() == Some(&b'\r') {
203 pending_line.pop();
204 }
205 if stderr_mode == StderrMode::Live {
206 eprintln!("{}", String::from_utf8_lossy(&pending_line));
207 }
208 }
209 let mut text = String::from_utf8_lossy(&captured).into_owned();
210 if truncated {
211 text.push_str("\n...[output truncated]\n");
212 }
213 drop(stderr_tx.send(text));
214 });
215
216 const READER_GRACE: Duration = Duration::from_secs(3);
220
221 let deadline = Instant::now() + timeout;
222 let status = loop {
223 match child.try_wait().map_err(|e| CommandError::Io {
224 program: program.to_string(),
225 message: e.to_string(),
226 })? {
227 Some(s) => break s,
228 None => {
229 if Instant::now() >= deadline {
230 drop(child.kill());
231 drop(child.wait());
232 let stdout = stdout_rx.recv_timeout(READER_GRACE).ok();
240 let stderr = stderr_rx.recv_timeout(READER_GRACE).ok();
241 if stdout.is_none() || stderr.is_none() {
242 eprintln!(
243 "warning: `{program}` timed out and a descendant process \
244 appears to still hold its output pipes open; captured \
245 output may be incomplete"
246 );
247 }
248 return Err(CommandError::TimedOut {
249 program: program.to_string(),
250 seconds: timeout.as_secs(),
251 });
252 }
253 std::thread::sleep(Duration::from_millis(50));
254 }
255 }
256 };
257
258 let stdout_result = stdout_rx.recv_timeout(READER_GRACE);
262 let stderr_result = stderr_rx.recv_timeout(READER_GRACE);
263 if stdout_result.is_err() || stderr_result.is_err() {
264 eprintln!(
265 "warning: `{program}` exited but a descendant process appears to still \
266 hold its output pipes open; captured output may be incomplete"
267 );
268 }
269
270 Ok(CommandOutput {
271 exit_code: status.code(),
272 stdout: stdout_result.unwrap_or_default(),
273 stderr: stderr_result.unwrap_or_default(),
274 })
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn run_with_timeout_kills_slow_process_and_returns_timed_out() {
283 let runner = CliCommandRunner;
284 let err = runner
286 .run_with_timeout("sleep", &["1"], std::path::Path::new("."), Duration::from_millis(100))
287 .unwrap_err();
288 assert!(
289 matches!(err, CommandError::TimedOut { .. }),
290 "expected TimedOut, got: {err:?}"
291 );
292 }
293
294 #[test]
295 fn run_with_timeout_returns_output_for_fast_process() {
296 let runner = CliCommandRunner;
297 let out = runner
298 .run_with_timeout("true", &[], std::path::Path::new("."), Duration::from_secs(5))
299 .unwrap();
300 assert!(out.success());
301 }
302
303 #[test]
312 fn run_with_timeout_bounds_reader_join_when_descendant_holds_pipe_open() {
313 let runner = CliCommandRunner;
314 let start = Instant::now();
315 let result = runner.run_with_timeout(
316 "sh",
317 &["-c", "sleep 30 >&2 & exit 0"],
318 std::path::Path::new("."),
319 Duration::from_secs(2),
320 );
321 let elapsed = start.elapsed();
322 assert!(
323 elapsed < Duration::from_secs(6),
324 "run_with_timeout must not block on a descendant process holding \
325 stdio pipes open; took {elapsed:?}, result: {result:?}"
326 );
327 assert!(
328 result.is_ok(),
329 "expected Ok despite a lingering descendant holding the pipe open, got: {result:?}"
330 );
331 }
332
333 #[test]
342 fn run_with_timeout_stderr_is_fully_captured_after_streaming() {
343 let runner = CliCommandRunner;
344 let out = runner
347 .run_with_timeout(
348 "sh",
349 &["-c", "echo line1 >&2; echo line2 >&2"],
350 std::path::Path::new("."),
351 Duration::from_secs(5),
352 )
353 .unwrap();
354
355 assert!(out.success());
356 assert!(
357 out.stderr.contains("line1") && out.stderr.contains("line2"),
358 "stderr must contain all emitted lines even when streamed; got: {:?}",
359 out.stderr
360 );
361 }
362
363 #[test]
381 fn run_with_timeout_warns_on_timeout_branch_when_descendant_holds_pipe_open() {
382 const CHILD_ENV: &str = "CALLISTO_RUN_TIMEOUT_KILL_CHILD";
383
384 if std::env::var(CHILD_ENV).is_ok() {
385 let runner = CliCommandRunner;
386 drop(runner.run_with_timeout(
398 "sh",
399 &["-c", "sleep 30 >&2 1>/dev/null & sleep 10 >/dev/null 2>&1"],
400 std::path::Path::new("."),
401 Duration::from_millis(800),
402 ));
403 return;
404 }
405
406 let exe = std::env::current_exe().expect("current_exe should be available in tests");
407 let start = Instant::now();
408 let output = std::process::Command::new(exe)
409 .arg("--exact")
410 .arg("runner::tests::run_with_timeout_warns_on_timeout_branch_when_descendant_holds_pipe_open")
411 .arg("--nocapture")
412 .env(CHILD_ENV, "1")
413 .output()
414 .expect("failed to re-exec test binary");
415 let elapsed = start.elapsed();
416
417 assert!(
418 elapsed < Duration::from_secs(6),
419 "run_with_timeout's timeout-kill branch must not block past its \
420 bounded grace period even when a descendant holds stdio pipes \
421 open; took {elapsed:?}"
422 );
423
424 let stderr = String::from_utf8_lossy(&output.stderr);
425 assert!(
426 stderr.contains("timed out and a descendant process appears to still hold its output pipes open"),
427 "expected the timeout branch's specific warning text in child stderr, got: {stderr}"
428 );
429 }
430
431 #[test]
440 fn run_with_timeout_caps_accumulated_stdout_and_still_drains_the_pipe() {
441 let runner = CliCommandRunner;
442 let over_cap = MAX_CAPTURED_OUTPUT_BYTES + 1_000_000;
443 let start = Instant::now();
444 let out = runner
445 .run_with_timeout(
446 "sh",
447 &["-c", &format!("head -c {over_cap} /dev/zero")],
448 std::path::Path::new("."),
449 Duration::from_secs(30),
450 )
451 .unwrap();
452 let elapsed = start.elapsed();
453
454 assert!(out.success());
455 assert!(
456 elapsed < Duration::from_secs(15),
457 "must not hang waiting for the child to finish writing past the cap; took {elapsed:?}"
458 );
459 assert!(
460 out.stdout.len() < over_cap,
461 "captured stdout must be bounded, not the full {over_cap} bytes written; got {} bytes",
462 out.stdout.len()
463 );
464 assert!(
465 out.stdout.contains("[output truncated]"),
466 "truncated output must say so"
467 );
468 }
469
470 #[test]
477 fn run_with_timeout_caps_accumulated_stderr_with_no_newlines_and_still_drains_the_pipe() {
478 let runner = CliCommandRunner;
479 let over_cap = MAX_CAPTURED_OUTPUT_BYTES + 1_000_000;
480 let start = Instant::now();
481 let out = runner
482 .run_with_timeout(
483 "sh",
484 &["-c", &format!("head -c {over_cap} /dev/zero >&2")],
485 std::path::Path::new("."),
486 Duration::from_secs(30),
487 )
488 .unwrap();
489 let elapsed = start.elapsed();
490
491 assert!(out.success());
492 assert!(
493 elapsed < Duration::from_secs(15),
494 "must not hang waiting for the child to finish writing past the cap; took {elapsed:?}"
495 );
496 assert!(
497 out.stderr.len() < over_cap,
498 "captured stderr must be bounded, not the full {over_cap} bytes written; got {} bytes",
499 out.stderr.len()
500 );
501 assert!(
502 out.stderr.contains("[output truncated]"),
503 "truncated output must say so"
504 );
505 }
506
507 #[test]
510 fn run_with_timeout_does_not_truncate_output_under_the_cap() {
511 let runner = CliCommandRunner;
512 let out = runner
513 .run_with_timeout(
514 "sh",
515 &["-c", "printf 'hello stdout'; printf 'hello stderr' >&2"],
516 std::path::Path::new("."),
517 Duration::from_secs(5),
518 )
519 .unwrap();
520
521 assert_eq!(out.stdout, "hello stdout");
522 assert!(!out.stdout.contains("[output truncated]"));
523 assert!(out.stderr.contains("hello stderr"));
524 assert!(!out.stderr.contains("[output truncated]"));
525 }
526
527 #[test]
532 fn run_quiet_still_captures_stderr_in_output() {
533 let runner = CliCommandRunner;
534 let out = runner
535 .run_quiet(
536 "sh",
537 &["-c", "echo captured-probe-text >&2"],
538 std::path::Path::new("."),
539 Duration::from_secs(5),
540 )
541 .unwrap();
542 assert!(
543 out.stderr.contains("captured-probe-text"),
544 "run_quiet must still capture stderr, got: {:?}",
545 out.stderr
546 );
547 }
548
549 #[test]
557 fn run_quiet_does_not_stream_stderr_live() {
558 const CHILD_ENV: &str = "CALLISTO_RUN_QUIET_CHILD";
559
560 if std::env::var(CHILD_ENV).is_ok() {
561 let runner = CliCommandRunner;
562 let out = runner
563 .run_quiet(
564 "sh",
565 &["-c", "echo should-not-appear-live >&2"],
566 std::path::Path::new("."),
567 Duration::from_secs(5),
568 )
569 .expect("run_quiet must succeed");
570 assert!(
576 out.stderr.contains("should-not-appear-live"),
577 "run_quiet must still capture the text it doesn't stream live"
578 );
579 println!("CHILD_REACHED_AND_VERIFIED_CAPTURE");
580 return;
581 }
582
583 let exe = std::env::current_exe().expect("current_exe should be available in tests");
584 let output = std::process::Command::new(exe)
585 .arg("--exact")
586 .arg("runner::tests::run_quiet_does_not_stream_stderr_live")
587 .arg("--nocapture")
588 .env(CHILD_ENV, "1")
589 .output()
590 .expect("failed to re-exec test binary");
591
592 assert!(
593 output.status.success(),
594 "child process must exit successfully, got: {:?}, stderr: {}",
595 output.status,
596 String::from_utf8_lossy(&output.stderr)
597 );
598 let stdout = String::from_utf8_lossy(&output.stdout);
599 assert!(
600 stdout.contains("CHILD_REACHED_AND_VERIFIED_CAPTURE"),
601 "child must have actually reached and verified the run_quiet call, not crashed \
602 or no-op'd before it; got stdout: {stdout}"
603 );
604
605 let stderr = String::from_utf8_lossy(&output.stderr);
606 assert!(
607 !stderr.contains("should-not-appear-live"),
608 "run_quiet must not stream stderr live to the terminal, got: {stderr}"
609 );
610 }
611
612 #[test]
617 fn run_with_timeout_still_streams_stderr_live() {
618 const CHILD_ENV: &str = "CALLISTO_RUN_TIMEOUT_LIVE_CHILD";
619
620 if std::env::var(CHILD_ENV).is_ok() {
621 let runner = CliCommandRunner;
622 drop(runner.run_with_timeout(
623 "sh",
624 &["-c", "echo should-appear-live >&2"],
625 std::path::Path::new("."),
626 Duration::from_secs(5),
627 ));
628 return;
629 }
630
631 let exe = std::env::current_exe().expect("current_exe should be available in tests");
632 let output = std::process::Command::new(exe)
633 .arg("--exact")
634 .arg("runner::tests::run_with_timeout_still_streams_stderr_live")
635 .arg("--nocapture")
636 .env(CHILD_ENV, "1")
637 .output()
638 .expect("failed to re-exec test binary");
639
640 let stderr = String::from_utf8_lossy(&output.stderr);
641 assert!(
642 stderr.contains("should-appear-live"),
643 "run_with_timeout must still stream stderr live, got: {stderr}"
644 );
645 }
646}