Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 0 additions & 44 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ cli = [
"tempfile",
"thread_local",
"time-humanize",
"tokio",
"twox-hash",
]
coverage = []
Expand All @@ -64,11 +63,6 @@ target-triple = { version = "1.0.0", optional = true }
tempfile = { version = "3.27.0", optional = true }
thread_local = { version = "1.1.9", optional = true }
time-humanize = { version = "0.1.3", optional = true }
tokio = { version = "1.50.0", features = [
"process",
"rt",
"time",
], optional = true }
twox-hash = { version = "2.1.2", optional = true }

[lints.clippy]
Expand Down
11 changes: 0 additions & 11 deletions src/bin/cargo-ziggy/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,6 @@ pub struct Common {
terminate: Arc<AtomicBool>,
sigs_done: Option<()>,
pub cargo_path: PathBuf,
runtime: OnceLock<tokio::runtime::Runtime>,
metadata: OnceLock<Option<cargo_metadata::Metadata>>,
}

Expand All @@ -431,7 +430,6 @@ impl Common {
cargo_path: std::env::var("CARGO")
.unwrap_or_else(|_| String::from("cargo"))
.into(),
runtime: OnceLock::new(),
metadata: OnceLock::new(),
}
}
Expand Down Expand Up @@ -471,15 +469,6 @@ impl Common {
cmd
}

fn async_runtime(&self) -> &tokio::runtime::Runtime {
self.runtime.get_or_init(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed building tokio runtime")
})
}

/// Cached `cargo metadata`
fn metadata(&self) -> Option<&cargo_metadata::Metadata> {
self.metadata
Expand Down
200 changes: 165 additions & 35 deletions src/bin/cargo-ziggy/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use std::{
env, fs,
os::unix::process::ExitStatusExt,
path::{Path, PathBuf},
process, thread,
time::{Duration, Instant},
};

impl Run {
Expand Down Expand Up @@ -100,10 +102,8 @@ impl Run {
};

let runner = Runner::new(
common.async_runtime(),
runner_path.as_std_path(),
self.timeout
.map(|s| tokio::time::Duration::from_secs(u64::from(s))),
self.timeout.map(|s| Duration::from_secs(u64::from(s))),
);

for file in input_files {
Expand Down Expand Up @@ -154,47 +154,48 @@ fn collect_dirs_recursively(
}

struct Runner<'a> {
rt: &'a tokio::runtime::Runtime,
path: &'a Path,
timeout: Option<tokio::time::Duration>,
timeout: Option<Duration>,
}

impl<'a> Runner<'a> {
fn new(
rt: &'a tokio::runtime::Runtime,
path: &'a Path,
timeout: Option<tokio::time::Duration>,
) -> Self {
Self { rt, path, timeout }
fn new(path: &'a Path, timeout: Option<Duration>) -> Self {
Self { path, timeout }
}

fn run(&self, seed: &Path) -> Status {
self.rt.block_on(async {
let mut child = match tokio::process::Command::new(self.path)
.arg(seed)
.env("RUST_BACKTRACE", "full")
.spawn()
.context("⚠️ couldn't spawn the runner process")
{
Ok(child) => child,
Err(e) => return e.into(),
};
let res = if let Some(duration) = self.timeout {
if let Ok(res) = tokio::time::timeout(duration, child.wait()).await {
res
} else {
let _ = child.start_kill();
return Status::Timeout;
let mut child = match process::Command::new(self.path)
.arg(seed)
.env("RUST_BACKTRACE", "full")
.spawn()
.context("⚠️ couldn't spawn the runner process")
{
Ok(child) => child,
Err(e) => return e.into(),
};
let res = match self.timeout {
Some(duration) => {
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => break Ok(status),
Ok(None) if start.elapsed() >= duration => {
let _ = child.kill();
let _ = child.wait();
return Status::Timeout;
}
Ok(None) => thread::sleep(Duration::from_millis(10)),
Err(e) => break Err(e),
}
}
} else {
child.wait().await
}
.context("⚠️ couldn't wait for the runner process");
match res {
Ok(status) => Status::Ok(status),
Err(e) => e.into(),
}
})
None => child.wait(),
}
.context("⚠️ couldn't wait for the runner process");
match res {
Ok(status) => Status::Ok(status),
Err(e) => e.into(),
}
}
}

Expand All @@ -209,3 +210,132 @@ impl From<anyhow::Error> for Status {
Self::Err(err)
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::{io::Write, os::unix::fs::PermissionsExt};

/// Write a `/bin/sh` script with `body` into `dir`, mark it executable, and
/// return its path. Used as a stand-in for the compiled runner binary.
fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
let mut file = fs::File::create(&path).unwrap();
writeln!(file, "#!/bin/sh\n{body}").unwrap();
drop(file);
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
path
}

fn dummy_seed(dir: &Path) -> PathBuf {
let seed = dir.join("seed");
fs::write(&seed, b"input").unwrap();
seed
}

fn label(status: &Status) -> &'static str {
match status {
Status::Ok(_) => "Ok",
Status::Timeout => "Timeout",
Status::Err(_) => "Err",
}
}

/// A run that finishes well within the timeout reports its real exit status
/// instead of timing out.
#[test]
fn completes_within_timeout_reports_success() {
let dir = tempfile::tempdir().unwrap();
let runner_bin = executable_script(dir.path(), "fast-runner", "exit 0");
let seed = dummy_seed(dir.path());

let runner = Runner::new(&runner_bin, Some(Duration::from_secs(30)));
match runner.run(&seed) {
Status::Ok(status) => assert!(status.success()),
other => panic!(
"expected Status::Ok(success), got Status::{}",
label(&other)
),
}
}

/// A non-zero exit within the timeout is surfaced as a failing `Status::Ok`,
/// not misreported as a timeout.
#[test]
fn nonzero_exit_is_reported_not_timeout() {
let dir = tempfile::tempdir().unwrap();
let runner_bin = executable_script(dir.path(), "failing-runner", "exit 3");
let seed = dummy_seed(dir.path());

let runner = Runner::new(&runner_bin, Some(Duration::from_secs(30)));
match runner.run(&seed) {
Status::Ok(status) => {
assert!(!status.success());
assert_eq!(status.code(), Some(3));
}
other => panic!(
"expected Status::Ok(failure), got Status::{}",
label(&other)
),
}
}

/// A run that outlasts the timeout is killed and reported as a timeout, and
/// the call returns promptly rather than blocking for the full run.
#[test]
fn exceeding_timeout_is_killed_and_reported() {
let dir = tempfile::tempdir().unwrap();
// `exec` so the shell is replaced by `sleep`, ensuring the process we
// spawn is the one we kill on timeout.
let runner_bin = executable_script(dir.path(), "slow-runner", "exec sleep 30");
let seed = dummy_seed(dir.path());

let runner = Runner::new(&runner_bin, Some(Duration::from_millis(100)));
let start = Instant::now();
let status = runner.run(&seed);
let elapsed = start.elapsed();

assert!(
matches!(status, Status::Timeout),
"expected Status::Timeout, got Status::{}",
label(&status),
);
// If the child were waited on rather than killed, this would take ~30s.
assert!(
elapsed < Duration::from_secs(10),
"timeout should return promptly after killing the child, took {elapsed:?}",
);
}

/// Without a timeout the runner simply waits for the process to finish.
#[test]
fn no_timeout_waits_for_completion() {
let dir = tempfile::tempdir().unwrap();
let runner_bin = executable_script(dir.path(), "no-timeout-runner", "exit 0");
let seed = dummy_seed(dir.path());

let runner = Runner::new(&runner_bin, None);
match runner.run(&seed) {
Status::Ok(status) => assert!(status.success()),
other => panic!(
"expected Status::Ok(success), got Status::{}",
label(&other)
),
}
}

/// A runner binary that cannot be spawned yields an error rather than a
/// timeout or a phantom success.
#[test]
fn spawn_failure_is_reported_as_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
let seed = dummy_seed(dir.path());

let runner = Runner::new(&missing, Some(Duration::from_secs(30)));
match runner.run(&seed) {
Status::Err(_) => {}
other => panic!("expected Status::Err, got Status::{}", label(&other)),
}
}
}