diff --git a/Cargo.lock b/Cargo.lock index b3a03d32..9a2975f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,6 +441,7 @@ dependencies = [ "shlex", "tempfile", "toml", + "wait-timeout", ] [[package]] @@ -643,6 +644,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 192eeb61..c9affed1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ serde_json = "1" serde.workspace = true shlex = "1" toml.workspace = true +wait-timeout = "0.2" [target.'cfg(not(windows))'.dependencies] rustix = { version = "1.0", default-features = false, features = ["std", "stdio", "termios"] } diff --git a/src/app_state.rs b/src/app_state.rs index 789f3043..541534a2 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -437,7 +437,6 @@ impl AppState { // Drop this sender to detect when the last thread is done. drop(progress_sender); - // TODO: Timeout while let Ok((exercise_ind, progress)) = progress_receiver.recv() { let name = self.exercises[exercise_ind].name; match progress { diff --git a/src/cmd.rs b/src/cmd.rs index 6442e449..094db5a0 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -3,47 +3,73 @@ use serde::Deserialize; use std::{ io::{Read, pipe}, 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. /// 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>) -> Result { 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()) .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 reader, writer) = pipe().with_context(|| { - format!("Failed to create a pipe to run the command `{description}``") - })?; + let (mut reader, writer) = + pipe().with_context(|| format!("Failed to create a pipe to run `{description}``"))?; - let writer_clone = writer.try_clone().with_context(|| { - format!("Failed to clone the pipe writer for the command `{description}`") - })?; + let writer_clone = writer + .try_clone() + .with_context(|| format!("Failed to clone the pipe writer for `{description}`"))?; cmd.stdout(writer_clone).stderr(writer); - let handle = spawn(cmd)?; + let mut handle = spawn(cmd)?; - reader - .read_to_end(output) - .with_context(|| format!("Failed to read the output of the command `{description}`"))?; + let thread_handle = thread::Builder::new() + .spawn(move || { + 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")?; - output.push(b'\n'); + 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'); + return Ok(status.success()); + } handle } else { 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 - .wait() - .with_context(|| format!("Failed to wait on the command `{description}` to exit")) - .map(|status| status.success()) + .kill() + .with_context(|| format!("Failed to kill `{description}` after timeout"))?; + bail!("`{description}` timed out after {TIMEOUT_SECS} seconds"); } // Parses parts of the output of `cargo metadata`. @@ -71,13 +97,12 @@ impl CmdRunner { .context(CARGO_METADATA_ERR)?; 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) - .context( - "Failed to read the field `target_directory` from the output of the command `cargo metadata …`", - )?; + let metadata: CargoMetadata = serde_json::de::from_slice(&metadata_output.stdout).context( + "Failed to read the field `target_directory` from the output of `cargo metadata …`", + )?; Ok(Self { target_dir: metadata.target_directory, @@ -116,7 +141,7 @@ impl CmdRunner { bin_path.push("debug"); 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? Try running `cargo --version` to diagnose the problem."; diff --git a/src/init.rs b/src/init.rs index f043bd48..1210e9bc 100644 --- a/src/init.rs +++ b/src/init.rs @@ -37,7 +37,7 @@ pub fn init() -> Result<()> { .stderr(Stdio::null()) .output() .context( - "Failed to run the command `cargo locate-project …`\n\ + "Failed to run `cargo locate-project …`\n\ Did you already install Rust?\n\ Try running `cargo --version` to diagnose the problem.", )?; @@ -49,7 +49,7 @@ pub fn init() -> Result<()> { .stdout(Stdio::null()) .stderr(Stdio::null()) .status() - .context("Failed to run the command `cargo clippy --version`")? + .context("Failed to run `cargo clippy --version`")? .success() { bail!( diff --git a/src/watch.rs b/src/watch.rs index f3804a40..22817f2a 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -150,7 +150,7 @@ pub fn watch( app_state: &mut AppState, notify_exercise_names: Option<&'static [&'static [u8]]>, ) -> Result<()> { - // TODO: Use cfg_select! after bumping MSRV to at least 1.95 + // TODO: Use cfg_select! after MSRV 1.95 #[cfg(not(windows))] { let stdin_fd = rustix::stdio::stdin();