Add command timeout

This commit is contained in:
mo8it 2026-08-02 11:18:34 +02:00
parent b970e17bdf
commit a5765fcb96
6 changed files with 64 additions and 29 deletions

10
Cargo.lock generated
View File

@ -441,6 +441,7 @@ dependencies = [
"shlex", "shlex",
"tempfile", "tempfile",
"toml", "toml",
"wait-timeout",
] ]
[[package]] [[package]]
@ -643,6 +644,15 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"

View File

@ -54,6 +54,7 @@ serde_json = "1"
serde.workspace = true serde.workspace = true
shlex = "1" shlex = "1"
toml.workspace = true toml.workspace = true
wait-timeout = "0.2"
[target.'cfg(not(windows))'.dependencies] [target.'cfg(not(windows))'.dependencies]
rustix = { version = "1.0", default-features = false, features = ["std", "stdio", "termios"] } rustix = { version = "1.0", default-features = false, features = ["std", "stdio", "termios"] }

View File

@ -437,7 +437,6 @@ impl AppState {
// Drop this sender to detect when the last thread is done. // Drop this sender to detect when the last thread is done.
drop(progress_sender); drop(progress_sender);
// TODO: Timeout
while let Ok((exercise_ind, progress)) = progress_receiver.recv() { while let Ok((exercise_ind, progress)) = progress_receiver.recv() {
let name = self.exercises[exercise_ind].name; let name = self.exercises[exercise_ind].name;
match progress { match progress {

View File

@ -3,47 +3,73 @@ use serde::Deserialize;
use std::{ use std::{
io::{Read, pipe}, io::{Read, pipe},
path::PathBuf, path::PathBuf,
process::{Command, Stdio}, process::{Child, Command, Stdio},
thread,
time::Duration,
}; };
use wait_timeout::ChildExt;
const TIMEOUT_SECS: u64 = 30;
/// Run a command with a description for a possible error and append the merged stdout and stderr. /// Run a command with a description for a possible error and append the merged stdout and stderr.
/// The boolean in the returned `Result` is true if the command's exit status is success. /// The boolean in the returned `Result` is true if the command's exit status is success.
fn run_cmd(mut cmd: Command, description: &str, output: Option<&mut Vec<u8>>) -> Result<bool> { fn run_cmd(mut cmd: Command, description: &str, output: Option<&mut Vec<u8>>) -> Result<bool> {
let spawn = |mut cmd: Command| { let spawn = |mut cmd: Command| {
// NOTE: The closure drops `cmd` which prevents a pipe deadlock. // The closure drops `cmd` which prevents a pipe deadlock.
cmd.stdin(Stdio::null()) cmd.stdin(Stdio::null())
.spawn() .spawn()
.with_context(|| format!("Failed to run the command `{description}`")) .with_context(|| format!("Failed to run `{description}`"))
};
let wait = |handle: &mut Child| {
handle
.wait_timeout(Duration::from_secs(TIMEOUT_SECS))
.with_context(|| format!("Failed to wait on `{description}` to exit"))
}; };
let mut handle = if let Some(output) = output { let mut handle = if let Some(output) = output {
let (mut reader, writer) = pipe().with_context(|| { let (mut reader, writer) =
format!("Failed to create a pipe to run the command `{description}``") pipe().with_context(|| format!("Failed to create a pipe to run `{description}``"))?;
})?;
let writer_clone = writer.try_clone().with_context(|| { let writer_clone = writer
format!("Failed to clone the pipe writer for the command `{description}`") .try_clone()
})?; .with_context(|| format!("Failed to clone the pipe writer for `{description}`"))?;
cmd.stdout(writer_clone).stderr(writer); cmd.stdout(writer_clone).stderr(writer);
let handle = spawn(cmd)?; let mut handle = spawn(cmd)?;
reader let thread_handle = thread::Builder::new()
.read_to_end(output) .spawn(move || {
.with_context(|| format!("Failed to read the output of the command `{description}`"))?; let mut out = Vec::with_capacity(128);
reader.read_to_end(&mut out).map(|_| out)
})
.context("Failed to spawn a thread to collect a command's output")?;
if let Some(status) = wait(&mut handle)? {
let out = thread_handle
.join()
.unwrap()
.with_context(|| format!("Failed to read the output of `{description}`"))?;
output.extend_from_slice(&out);
output.push(b'\n'); output.push(b'\n');
return Ok(status.success());
}
handle handle
} else { } else {
cmd.stdout(Stdio::null()).stderr(Stdio::null()); cmd.stdout(Stdio::null()).stderr(Stdio::null());
spawn(cmd)? let mut handle = spawn(cmd)?;
if let Some(status) = wait(&mut handle)? {
return Ok(status.success());
}
handle
}; };
handle handle
.wait() .kill()
.with_context(|| format!("Failed to wait on the command `{description}` to exit")) .with_context(|| format!("Failed to kill `{description}` after timeout"))?;
.map(|status| status.success()) bail!("`{description}` timed out after {TIMEOUT_SECS} seconds");
} }
// Parses parts of the output of `cargo metadata`. // Parses parts of the output of `cargo metadata`.
@ -71,12 +97,11 @@ impl CmdRunner {
.context(CARGO_METADATA_ERR)?; .context(CARGO_METADATA_ERR)?;
if !metadata_output.status.success() { if !metadata_output.status.success() {
bail!("The command `cargo metadata …` failed. Are you in the `rustlings/` directory?"); bail!("`cargo metadata …` failed. Are you in the `rustlings/` directory?");
} }
let metadata: CargoMetadata = serde_json::de::from_slice(&metadata_output.stdout) let metadata: CargoMetadata = serde_json::de::from_slice(&metadata_output.stdout).context(
.context( "Failed to read the field `target_directory` from the output of `cargo metadata …`",
"Failed to read the field `target_directory` from the output of the command `cargo metadata …`",
)?; )?;
Ok(Self { Ok(Self {
@ -116,7 +141,7 @@ impl CmdRunner {
bin_path.push("debug"); bin_path.push("debug");
bin_path.push(bin_name); bin_path.push(bin_name);
run_cmd(Command::new(&bin_path), &bin_path.to_string_lossy(), output) run_cmd(Command::new(&bin_path), bin_name, output)
} }
} }
@ -140,7 +165,7 @@ impl CargoSubcommand<'_> {
} }
} }
const CARGO_METADATA_ERR: &str = "Failed to run the command `cargo metadata …` const CARGO_METADATA_ERR: &str = "Failed to run `cargo metadata …`
Did you already install Rust? Did you already install Rust?
Try running `cargo --version` to diagnose the problem."; Try running `cargo --version` to diagnose the problem.";

View File

@ -37,7 +37,7 @@ pub fn init() -> Result<()> {
.stderr(Stdio::null()) .stderr(Stdio::null())
.output() .output()
.context( .context(
"Failed to run the command `cargo locate-project …`\n\ "Failed to run `cargo locate-project …`\n\
Did you already install Rust?\n\ Did you already install Rust?\n\
Try running `cargo --version` to diagnose the problem.", Try running `cargo --version` to diagnose the problem.",
)?; )?;
@ -49,7 +49,7 @@ pub fn init() -> Result<()> {
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.status() .status()
.context("Failed to run the command `cargo clippy --version`")? .context("Failed to run `cargo clippy --version`")?
.success() .success()
{ {
bail!( bail!(

View File

@ -150,7 +150,7 @@ pub fn watch(
app_state: &mut AppState, app_state: &mut AppState,
notify_exercise_names: Option<&'static [&'static [u8]]>, notify_exercise_names: Option<&'static [&'static [u8]]>,
) -> Result<()> { ) -> Result<()> {
// TODO: Use cfg_select! after bumping MSRV to at least 1.95 // TODO: Use cfg_select! after MSRV 1.95
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
let stdin_fd = rustix::stdio::stdin(); let stdin_fd = rustix::stdio::stdin();