use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use crate::error::{Error, Result};
pub const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(900);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ExecOutcome {
Ran {
code: Option<i32>,
stdout: String,
stderr: String,
elided: usize,
},
TimedOut {
after: Duration,
},
Unavailable {
reason: String,
},
}
pub(crate) struct Exec {
workdir: PathBuf,
timeout: Duration,
cap: usize,
}
impl Exec {
pub(crate) fn new(workdir: impl Into<PathBuf>, timeout: Duration, cap: usize) -> Self {
Self {
workdir: workdir.into(),
timeout,
cap,
}
}
pub(crate) async fn run(&self, argv: &[String]) -> Result<ExecOutcome> {
let Some(program) = argv.first() else {
return Err(Error::Config("exec needs a non-empty argv".into()));
};
let mut cmd = command(program, &argv[1..], &self.workdir);
match tokio::time::timeout(self.timeout, cmd.output()).await {
Err(_elapsed) => Ok(ExecOutcome::TimedOut {
after: self.timeout,
}),
Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(ExecOutcome::Unavailable {
reason: format!("no `{program}` on PATH"),
})
}
Ok(Err(e)) => Err(Error::Io(e)),
Ok(Ok(out)) => {
let (stdout, cut_out) =
head_and_tail(&String::from_utf8_lossy(&out.stdout), self.cap);
let (stderr, cut_err) =
head_and_tail(&String::from_utf8_lossy(&out.stderr), self.cap);
Ok(ExecOutcome::Ran {
code: out.status.code(),
stdout,
stderr,
elided: cut_out + cut_err,
})
}
}
}
}
fn command(program: &str, args: &[String], workdir: &Path) -> Command {
let mut c = Command::new(program);
c.args(args)
.current_dir(workdir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
c
}
pub(crate) fn head_and_tail(s: &str, cap: usize) -> (String, usize) {
let total = s.chars().count();
if total <= cap {
return (s.to_string(), 0);
}
let head = cap / 2;
let tail = cap - head;
let elided = total - head - tail;
let head_end = char_offset(s, head);
let tail_start = char_offset(s, total - tail);
(
format!(
"{}\n[... {elided} characters elided ...]\n{}",
&s[..head_end],
&s[tail_start..]
),
elided,
)
}
fn char_offset(s: &str, n: usize) -> usize {
s.char_indices().nth(n).map_or(s.len(), |(i, _)| i)
}
#[cfg(test)]
mod tests {
use super::*;
const CAP: usize = 100_000;
fn exec(dir: &Path) -> Exec {
Exec::new(dir, Duration::from_secs(120), CAP)
}
#[test]
fn the_child_gets_the_argv_verbatim_with_no_shell_between() {
let dir = tempfile::tempdir().unwrap();
let nasty = "a;b && c $(id) `whoami` | d > e";
let cmd = command("prog", &[nasty.to_string(), "second".into()], dir.path());
let args: Vec<String> = cmd
.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(
args,
vec![nasty.to_string(), "second".to_string()],
"the metacharacters stayed inside one argument and nothing split it"
);
assert_eq!(cmd.as_std().get_current_dir(), Some(dir.path()));
}
#[tokio::test]
async fn a_missing_program_is_an_unavailable_outcome_not_a_run_failure() {
let dir = tempfile::tempdir().unwrap();
let out = exec(dir.path())
.run(&["io-harness-no-such-program".to_string()])
.await
.unwrap();
assert!(matches!(out, ExecOutcome::Unavailable { .. }), "{out:?}");
}
#[tokio::test]
async fn an_empty_argv_is_a_config_error_rather_than_a_panic() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(
exec(dir.path()).run(&[]).await,
Err(Error::Config(_))
));
}
#[tokio::test]
async fn a_real_command_reports_its_exit_status_and_its_output() {
let dir = tempfile::tempdir().unwrap();
let out = exec(dir.path())
.run(&["rustc".to_string(), "--version".to_string()])
.await
.unwrap();
let ExecOutcome::Ran { code, stdout, .. } = out else {
panic!("rustc must be present wherever cargo test runs: {out:?}");
};
assert_eq!(code, Some(0));
assert!(stdout.starts_with("rustc "), "{stdout:?}");
}
#[tokio::test]
async fn a_failing_command_comes_back_as_a_nonzero_exit_with_its_stderr() {
let dir = tempfile::tempdir().unwrap();
let out = exec(dir.path())
.run(&["rustc".to_string(), "no-such-file.rs".to_string()])
.await
.unwrap();
let ExecOutcome::Ran { code, stderr, .. } = out else {
panic!("expected a run: {out:?}");
};
assert_ne!(code, Some(0));
assert!(!stderr.trim().is_empty(), "the compiler said why");
}
#[tokio::test]
async fn the_working_directory_is_the_one_it_was_given() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lib.rs"), "pub fn f() -> u32 { 1 }\n").unwrap();
let out = exec(dir.path())
.run(&[
"rustc".to_string(),
"--crate-type".to_string(),
"lib".to_string(),
"lib.rs".to_string(),
"--out-dir".to_string(),
".".to_string(),
])
.await
.unwrap();
assert!(
matches!(out, ExecOutcome::Ran { code: Some(0), .. }),
"{out:?}"
);
assert!(
dir.path().join("liblib.rlib").exists(),
"the relative output path resolved against the workspace root"
);
}
#[test]
fn output_that_fits_is_returned_whole_and_reports_no_elision() {
let (out, elided) = head_and_tail("all of it", 100);
assert_eq!(out, "all of it");
assert_eq!(elided, 0);
}
#[test]
fn oversized_output_keeps_both_ends_and_says_how_much_it_dropped() {
let log: String = (0..200).map(|i| format!("line {i}\n")).collect();
let (out, elided) = head_and_tail(&log, 200);
assert!(out.contains("line 0\n"), "the head survived: {out}");
assert!(out.contains("line 199\n"), "the tail survived: {out}");
assert!(!out.contains("line 100\n"), "the middle went");
assert!(elided > 0);
assert!(
out.contains(&format!("{elided} characters elided")),
"the model is told what it is missing: {out}"
);
}
#[test]
fn the_elision_is_measured_in_characters_not_bytes() {
let s: String = std::iter::repeat_n('🌍', 100).collect();
let (out, elided) = head_and_tail(&s, 20);
assert_eq!(elided, 80);
assert_eq!(out.chars().filter(|c| *c == '🌍').count(), 20);
}
#[tokio::test]
async fn a_command_that_outlives_the_timeout_is_killed_and_named_as_a_timeout() {
let dir = tempfile::tempdir().unwrap();
let argv: Vec<String> = if cfg!(windows) {
["ping", "-n", "30", "127.0.0.1"]
} else {
["sleep", "30", "", ""]
}
.iter()
.filter(|a| !a.is_empty())
.map(|a| (*a).to_string())
.collect();
let started = std::time::Instant::now();
let out = Exec::new(dir.path(), Duration::from_millis(300), CAP)
.run(&argv)
.await
.unwrap();
assert!(
matches!(out, ExecOutcome::TimedOut { .. }),
"a wedged command must report itself, not the run's budget: {out:?}"
);
assert!(
started.elapsed() < Duration::from_secs(10),
"the run did not wait for the command to finish"
);
}
#[tokio::test]
async fn a_command_that_finishes_inside_the_timeout_is_not_reported_as_one() {
let dir = tempfile::tempdir().unwrap();
let out = Exec::new(dir.path(), Duration::from_secs(120), CAP)
.run(&["rustc".to_string(), "--version".to_string()])
.await
.unwrap();
assert!(matches!(out, ExecOutcome::Ran { .. }), "{out:?}");
}
}