use std::io::{Read, Write};
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::Context;
use zygo_core::spec::{Layer, Spec};
const POLL: Duration = Duration::from_millis(10);
const COLLECT_GRACE: Duration = Duration::from_secs(2);
#[derive(Debug, Clone)]
pub struct Captured {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
pub abandoned: bool,
pub oom_killed: bool,
pub peak_rss_kb: u64,
pub wall_ms: f64,
pub started: bool,
pub phase: String,
}
#[derive(serde::Deserialize, Default)]
struct Outcome {
timed_out: bool,
oom_killed: bool,
#[serde(default)]
peak_rss_kb: u64,
#[serde(default)]
started: bool,
#[serde(default)]
phase: String,
}
pub fn run(
exe: &Path,
mut layer: Layer,
image: &str,
argv: &[String],
stdin: &[u8],
deadline: Duration,
) -> anyhow::Result<Captured> {
layer.image = None;
layer.cmd = None;
let spec = Spec {
defaults: layer,
..Default::default()
};
let toml = toml::to_string(&spec).context("cannot express that sandbox as a spec file")?;
let file = tempfile::Builder::new()
.prefix("zygo-run-")
.suffix(".toml")
.tempfile()
.context("cannot create a temporary spec file")?;
std::fs::write(file.path(), &toml)
.with_context(|| format!("cannot write {}", file.path().display()))?;
let outcome_path = file.path().with_extension("outcome");
let started = Instant::now();
let mut command = std::process::Command::new(exe);
command
.arg("run")
.arg("--outcome")
.arg(&outcome_path)
.arg("--quiet")
.arg("--file")
.arg(file.path())
.arg(image)
.args(argv)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.env_remove("ZYGO_API_TOKEN");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let mut child = command
.spawn()
.with_context(|| format!("cannot start `{}`", exe.display()))?;
let mut sink = child.stdin.take().expect("piped");
let input = stdin.to_vec();
std::thread::spawn(move || {
block_sigpipe();
let _ = sink.write_all(&input);
drop(sink);
});
let (out_tx, out_rx) = std::sync::mpsc::channel();
let (err_tx, err_rx) = std::sync::mpsc::channel();
let mut out = child.stdout.take().expect("piped");
let mut err = child.stderr.take().expect("piped");
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = out.read_to_end(&mut buf);
let _ = out_tx.send(buf);
});
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = err.read_to_end(&mut buf);
let _ = err_tx.send(buf);
});
let mut timed_out = false;
let status = loop {
match child.try_wait().context("cannot wait for the sandbox")? {
Some(status) => break status,
None if started.elapsed() >= deadline => {
timed_out = true;
kill_group(&mut child);
break child.wait().context("cannot reap the sandbox")?;
}
None => std::thread::sleep(POLL),
}
};
let stdout = out_rx.recv_timeout(COLLECT_GRACE).unwrap_or_default();
let stderr = err_rx.recv_timeout(COLLECT_GRACE).unwrap_or_default();
let outcome: Option<Outcome> = std::fs::read(&outcome_path)
.ok()
.and_then(|raw| serde_json::from_slice(&raw).ok());
let _ = std::fs::remove_file(&outcome_path);
let (ran, phase) = match &outcome {
Some(o) => (o.started, o.phase.clone()),
None => (false, String::new()),
};
let outcome = outcome.unwrap_or_default();
Ok(Captured {
exit_code: status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
timed_out: timed_out || outcome.timed_out,
abandoned: timed_out,
oom_killed: outcome.oom_killed,
peak_rss_kb: outcome.peak_rss_kb,
wall_ms: started.elapsed().as_secs_f64() * 1000.0,
started: ran,
phase,
})
}
#[cfg(unix)]
fn kill_group(child: &mut std::process::Child) {
let sent = unsafe { libc::killpg(child.id() as libc::pid_t, libc::SIGKILL) };
if sent != 0 {
let _ = child.kill();
}
}
#[cfg(not(unix))]
fn kill_group(child: &mut std::process::Child) {
let _ = child.kill();
}
#[cfg(unix)]
fn block_sigpipe() {
unsafe {
let mut set: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut set);
libc::sigaddset(&mut set, libc::SIGPIPE);
libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut());
}
}
#[cfg(not(unix))]
fn block_sigpipe() {}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
fn fake_zygo(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temporary directory");
let path = dir.path().join("zygo");
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write the stand-in");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("make it executable");
(dir, path)
}
#[test]
fn the_child_gets_the_command_as_arguments_and_the_limits_as_a_spec() {
let (_dir, exe) = fake_zygo(r#"echo "argv: $*"; echo "--- spec ---"; cat "$6""#);
let layer: Layer = serde_json::from_str(
r#"{"image":"python:3.12-slim","cmd":["ignored"],"mem":"128M","timeout":"5s"}"#,
)
.expect("a layer");
let captured = run(
&exe,
layer,
"python:3.12-slim",
&["python3".into(), "-c".into(), "print(1)".into()],
b"",
Duration::from_secs(10),
)
.expect("the stand-in ran");
assert_eq!(captured.exit_code, 0, "{captured:?}");
let (argv, spec) = captured
.stdout
.split_once("--- spec ---")
.expect("both halves");
assert!(
argv.contains("run --outcome")
&& argv.contains("--quiet --file")
&& argv.contains("python:3.12-slim python3 -c print(1)"),
"argument order changed: {argv}"
);
assert!(spec.contains("mem = \"128M\""), "limits missing: {spec}");
assert!(spec.contains("timeout = \"5s\""), "limits missing: {spec}");
assert!(
!spec.contains("image =") && !spec.contains("cmd ="),
"the spec should not also name the command: {spec}"
);
}
#[test]
fn why_the_sandbox_ended_comes_back_with_its_output() {
let (_dir, exe) = fake_zygo(
r#"printf '{"exit_code":0,"timed_out":false,"oom_killed":true,"peak_rss_kb":65536}' > "$3"
echo ran
exit 137"#,
);
let captured = run(
&exe,
Layer::default(),
"alpine:3",
&[],
b"",
Duration::from_secs(10),
)
.expect("the stand-in ran");
assert_eq!(captured.stdout.trim(), "ran");
assert!(captured.oom_killed, "{captured:?}");
assert!(!captured.timed_out, "{captured:?}");
assert!(
!captured.abandoned,
"a child that finished on its own was reported as abandoned"
);
assert_eq!(captured.peak_rss_kb, 65_536);
assert_eq!(
captured.exit_code, 137,
"the exit code must come from the wait status, not the file"
);
}
#[test]
fn a_child_that_wrote_no_outcome_still_reports_the_outer_deadline() {
let (_dir, exe) = fake_zygo("sleep 30");
let captured = run(
&exe,
Layer::default(),
"alpine:3",
&[],
b"",
Duration::from_millis(200),
)
.expect("the stand-in ran");
assert!(captured.timed_out, "{captured:?}");
assert!(captured.abandoned, "{captured:?}");
assert!(!captured.oom_killed, "{captured:?}");
}
#[test]
fn a_large_standard_input_reaches_the_child() {
let (_dir, exe) = fake_zygo("wc -c");
let input = vec![b'x'; 1 << 20];
let captured = run(
&exe,
Layer::default(),
"alpine:3",
&[],
&input,
Duration::from_secs(20),
)
.expect("the stand-in ran");
assert_eq!(captured.exit_code, 0, "{captured:?}");
assert_eq!(captured.stdout.trim(), (1 << 20).to_string());
}
#[test]
fn a_deadline_kills_the_whole_tree_and_reports_it() {
let dir = tempfile::tempdir().expect("a temporary directory");
let pidfile = dir.path().join("grandchild.pid");
let (_bin, exe) = fake_zygo(&format!(
"sleep \"$ZYGO_TEST_SLEEP\" &\necho $! > {}\nwait",
pidfile.display()
));
unsafe { std::env::set_var("ZYGO_TEST_SLEEP", "0") };
let quick = run(
&exe,
Layer::default(),
"alpine:3",
&[],
b"",
Duration::from_secs(10),
)
.expect("the stand-in ran");
assert!(!quick.timed_out, "a prompt child was called slow");
assert_eq!(quick.exit_code, 0, "{quick:?}");
unsafe { std::env::set_var("ZYGO_TEST_SLEEP", "30") };
let slow = run(
&exe,
Layer::default(),
"alpine:3",
&[],
b"",
Duration::from_millis(200),
)
.expect("the stand-in ran");
assert!(slow.timed_out, "the deadline did not fire: {slow:?}");
assert!(
slow.abandoned,
"the outer bound has to be distinguishable from a sandbox's own \
timeout: {slow:?}"
);
assert_ne!(slow.exit_code, 0, "a killed child reported success");
assert!(
slow.wall_ms < 5_000.0,
"it waited for the child instead of killing it: {slow:?}"
);
let pid: i32 = std::fs::read_to_string(&pidfile)
.expect("the stand-in recorded its grandchild")
.trim()
.parse()
.expect("a pid");
std::thread::sleep(Duration::from_millis(100));
let alive = unsafe { libc::kill(pid, 0) } == 0;
assert!(!alive, "the grandchild outlived the deadline (pid {pid})");
}
}