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]
378 fn run_with_timeout_warns_on_timeout_branch_when_descendant_holds_pipe_open() {
379 const CHILD_ENV: &str = "CALLISTO_RUN_TIMEOUT_KILL_CHILD";
380
381 if std::env::var(CHILD_ENV).is_ok() {
382 let runner = CliCommandRunner;
383 drop(runner.run_with_timeout(
395 "sh",
396 &["-c", "sleep 30 >&2 1>/dev/null & sleep 10 >/dev/null 2>&1"],
397 std::path::Path::new("."),
398 Duration::from_millis(800),
399 ));
400 return;
401 }
402
403 let exe = std::env::current_exe().expect("current_exe should be available in tests");
404 let start = Instant::now();
405 let output = std::process::Command::new(exe)
406 .arg("--exact")
407 .arg("runner::tests::run_with_timeout_warns_on_timeout_branch_when_descendant_holds_pipe_open")
408 .arg("--nocapture")
409 .env(CHILD_ENV, "1")
410 .output()
411 .expect("failed to re-exec test binary");
412 let elapsed = start.elapsed();
413
414 assert!(
415 elapsed < Duration::from_secs(6),
416 "run_with_timeout's timeout-kill branch must not block past its \
417 bounded grace period even when a descendant holds stdio pipes \
418 open; took {elapsed:?}"
419 );
420
421 let stderr = String::from_utf8_lossy(&output.stderr);
422 assert!(
423 stderr.contains("timed out and a descendant process appears to still hold its output pipes open"),
424 "expected the timeout branch's specific warning text in child stderr, got: {stderr}"
425 );
426 }
427
428 #[test]
437 fn run_with_timeout_caps_accumulated_stdout_and_still_drains_the_pipe() {
438 let runner = CliCommandRunner;
439 let over_cap = MAX_CAPTURED_OUTPUT_BYTES + 1_000_000;
440 let start = Instant::now();
441 let out = runner
442 .run_with_timeout(
443 "sh",
444 &["-c", &format!("head -c {over_cap} /dev/zero")],
445 std::path::Path::new("."),
446 Duration::from_secs(30),
447 )
448 .unwrap();
449 let elapsed = start.elapsed();
450
451 assert!(out.success());
452 assert!(
453 elapsed < Duration::from_secs(15),
454 "must not hang waiting for the child to finish writing past the cap; took {elapsed:?}"
455 );
456 assert!(
457 out.stdout.len() < over_cap,
458 "captured stdout must be bounded, not the full {over_cap} bytes written; got {} bytes",
459 out.stdout.len()
460 );
461 assert!(
462 out.stdout.contains("[output truncated]"),
463 "truncated output must say so"
464 );
465 }
466
467 #[test]
474 fn run_with_timeout_caps_accumulated_stderr_with_no_newlines_and_still_drains_the_pipe() {
475 let runner = CliCommandRunner;
476 let over_cap = MAX_CAPTURED_OUTPUT_BYTES + 1_000_000;
477 let start = Instant::now();
478 let out = runner
479 .run_with_timeout(
480 "sh",
481 &["-c", &format!("head -c {over_cap} /dev/zero >&2")],
482 std::path::Path::new("."),
483 Duration::from_secs(30),
484 )
485 .unwrap();
486 let elapsed = start.elapsed();
487
488 assert!(out.success());
489 assert!(
490 elapsed < Duration::from_secs(15),
491 "must not hang waiting for the child to finish writing past the cap; took {elapsed:?}"
492 );
493 assert!(
494 out.stderr.len() < over_cap,
495 "captured stderr must be bounded, not the full {over_cap} bytes written; got {} bytes",
496 out.stderr.len()
497 );
498 assert!(
499 out.stderr.contains("[output truncated]"),
500 "truncated output must say so"
501 );
502 }
503
504 #[test]
507 fn run_with_timeout_does_not_truncate_output_under_the_cap() {
508 let runner = CliCommandRunner;
509 let out = runner
510 .run_with_timeout(
511 "sh",
512 &["-c", "printf 'hello stdout'; printf 'hello stderr' >&2"],
513 std::path::Path::new("."),
514 Duration::from_secs(5),
515 )
516 .unwrap();
517
518 assert_eq!(out.stdout, "hello stdout");
519 assert!(!out.stdout.contains("[output truncated]"));
520 assert!(out.stderr.contains("hello stderr"));
521 assert!(!out.stderr.contains("[output truncated]"));
522 }
523
524 #[test]
529 fn run_quiet_still_captures_stderr_in_output() {
530 let runner = CliCommandRunner;
531 let out = runner
532 .run_quiet(
533 "sh",
534 &["-c", "echo captured-probe-text >&2"],
535 std::path::Path::new("."),
536 Duration::from_secs(5),
537 )
538 .unwrap();
539 assert!(
540 out.stderr.contains("captured-probe-text"),
541 "run_quiet must still capture stderr, got: {:?}",
542 out.stderr
543 );
544 }
545
546 #[test]
554 fn run_quiet_does_not_stream_stderr_live() {
555 const CHILD_ENV: &str = "CALLISTO_RUN_QUIET_CHILD";
556
557 if std::env::var(CHILD_ENV).is_ok() {
558 let runner = CliCommandRunner;
559 let out = runner
560 .run_quiet(
561 "sh",
562 &["-c", "echo should-not-appear-live >&2"],
563 std::path::Path::new("."),
564 Duration::from_secs(5),
565 )
566 .expect("run_quiet must succeed");
567 assert!(
573 out.stderr.contains("should-not-appear-live"),
574 "run_quiet must still capture the text it doesn't stream live"
575 );
576 println!("CHILD_REACHED_AND_VERIFIED_CAPTURE");
577 return;
578 }
579
580 let exe = std::env::current_exe().expect("current_exe should be available in tests");
581 let output = std::process::Command::new(exe)
582 .arg("--exact")
583 .arg("runner::tests::run_quiet_does_not_stream_stderr_live")
584 .arg("--nocapture")
585 .env(CHILD_ENV, "1")
586 .output()
587 .expect("failed to re-exec test binary");
588
589 assert!(
590 output.status.success(),
591 "child process must exit successfully, got: {:?}, stderr: {}",
592 output.status,
593 String::from_utf8_lossy(&output.stderr)
594 );
595 let stdout = String::from_utf8_lossy(&output.stdout);
596 assert!(
597 stdout.contains("CHILD_REACHED_AND_VERIFIED_CAPTURE"),
598 "child must have actually reached and verified the run_quiet call, not crashed \
599 or no-op'd before it; got stdout: {stdout}"
600 );
601
602 let stderr = String::from_utf8_lossy(&output.stderr);
603 assert!(
604 !stderr.contains("should-not-appear-live"),
605 "run_quiet must not stream stderr live to the terminal, got: {stderr}"
606 );
607 }
608
609 #[test]
614 fn run_with_timeout_still_streams_stderr_live() {
615 const CHILD_ENV: &str = "CALLISTO_RUN_TIMEOUT_LIVE_CHILD";
616
617 if std::env::var(CHILD_ENV).is_ok() {
618 let runner = CliCommandRunner;
619 drop(runner.run_with_timeout(
620 "sh",
621 &["-c", "echo should-appear-live >&2"],
622 std::path::Path::new("."),
623 Duration::from_secs(5),
624 ));
625 return;
626 }
627
628 let exe = std::env::current_exe().expect("current_exe should be available in tests");
629 let output = std::process::Command::new(exe)
630 .arg("--exact")
631 .arg("runner::tests::run_with_timeout_still_streams_stderr_live")
632 .arg("--nocapture")
633 .env(CHILD_ENV, "1")
634 .output()
635 .expect("failed to re-exec test binary");
636
637 let stderr = String::from_utf8_lossy(&output.stderr);
638 assert!(
639 stderr.contains("should-appear-live"),
640 "run_with_timeout must still stream stderr live, got: {stderr}"
641 );
642 }
643}