use std::io;
use std::time::{Duration, Instant};
const EXECUTABLE_BUSY_BUDGET: Duration = Duration::from_secs(1);
const EXECUTABLE_BUSY_FIRST_BACKOFF: Duration = Duration::from_micros(200);
const EXECUTABLE_BUSY_MAX_BACKOFF: Duration = Duration::from_millis(20);
struct ExecutableBusyRetry {
deadline: Instant,
backoff: Duration,
}
impl ExecutableBusyRetry {
fn start(budget: Duration) -> Self {
Self {
deadline: Instant::now() + budget,
backoff: EXECUTABLE_BUSY_FIRST_BACKOFF,
}
}
fn pause_after(&mut self, error: &io::Error) -> Option<Duration> {
if error.kind() != io::ErrorKind::ExecutableFileBusy || Instant::now() >= self.deadline {
return None;
}
let pause = self.backoff;
self.backoff = (self.backoff * 2).min(EXECUTABLE_BUSY_MAX_BACKOFF);
Some(pause)
}
}
pub fn spawn_retrying_busy_executable(
command: &mut std::process::Command,
) -> io::Result<std::process::Child> {
retry_while_executable_busy(EXECUTABLE_BUSY_BUDGET, || command.spawn())
}
#[cfg(feature = "tokio")]
pub async fn spawn_tokio_retrying_busy_executable(
command: &mut tokio::process::Command,
) -> io::Result<tokio::process::Child> {
retry_while_executable_busy_async(EXECUTABLE_BUSY_BUDGET, || command.spawn()).await
}
#[cfg(feature = "tokio")]
async fn retry_while_executable_busy_async<T>(
budget: Duration,
mut attempt: impl FnMut() -> io::Result<T>,
) -> io::Result<T> {
let mut retry = ExecutableBusyRetry::start(budget);
loop {
let error = match attempt() {
Ok(value) => return Ok(value),
Err(error) => error,
};
let Some(pause) = retry.pause_after(&error) else {
return Err(error);
};
tokio::time::sleep(pause).await;
}
}
fn retry_while_executable_busy<T>(
budget: Duration,
mut attempt: impl FnMut() -> io::Result<T>,
) -> io::Result<T> {
let mut retry = ExecutableBusyRetry::start(budget);
loop {
let error = match attempt() {
Ok(value) => return Ok(value),
Err(error) => error,
};
let Some(pause) = retry.pause_after(&error) else {
return Err(error);
};
std::thread::sleep(pause);
}
}
#[cfg(test)]
#[expect(
clippy::expect_used,
reason = "test setup failures should fail at the exact setup operation"
)]
mod tests {
use super::*;
#[test]
fn a_busy_executable_is_retried_until_it_is_free() {
let mut attempts = 0;
let result = retry_while_executable_busy(Duration::from_secs(30), || {
attempts += 1;
if attempts < 3 {
return Err(io::Error::from(io::ErrorKind::ExecutableFileBusy));
}
Ok(())
});
assert!(result.is_ok(), "a target that frees itself should spawn");
assert_eq!(attempts, 3);
}
#[test]
fn a_permanently_busy_executable_fails_after_the_budget() {
let mut attempts = 0;
let started = Instant::now();
let error = retry_while_executable_busy(Duration::from_millis(20), || {
attempts += 1;
Err::<(), io::Error>(io::Error::from(io::ErrorKind::ExecutableFileBusy))
})
.expect_err("a target that stays busy keeps failing");
assert_eq!(error.kind(), io::ErrorKind::ExecutableFileBusy);
assert!(
attempts > 1,
"the budget should cover more than one attempt"
);
assert!(started.elapsed() >= Duration::from_millis(20));
}
#[test]
fn other_spawn_failures_are_reported_without_a_retry() {
let mut attempts = 0;
let error = retry_while_executable_busy(Duration::from_secs(30), || {
attempts += 1;
Err::<(), io::Error>(io::Error::from(io::ErrorKind::NotFound))
})
.expect_err("a missing executable stays missing");
assert_eq!(error.kind(), io::ErrorKind::NotFound);
assert_eq!(attempts, 1);
}
#[test]
fn the_pause_doubles_up_to_the_ceiling() {
let busy = io::Error::from(io::ErrorKind::ExecutableFileBusy);
let mut retry = ExecutableBusyRetry::start(Duration::from_secs(30));
let mut pauses = Vec::new();
for _ in 0..12 {
pauses.push(retry.pause_after(&busy).expect("the budget is not spent"));
}
assert_eq!(pauses[0], EXECUTABLE_BUSY_FIRST_BACKOFF);
assert_eq!(pauses[1], EXECUTABLE_BUSY_FIRST_BACKOFF * 2);
assert!(pauses.windows(2).all(|pair| pair[0] <= pair[1]));
assert_eq!(
*pauses.last().expect("pauses were recorded"),
EXECUTABLE_BUSY_MAX_BACKOFF
);
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn an_awaited_busy_executable_is_retried_until_it_is_free() {
let mut attempts = 0;
let result = retry_while_executable_busy_async(Duration::from_secs(30), || {
attempts += 1;
if attempts < 3 {
return Err(io::Error::from(io::ErrorKind::ExecutableFileBusy));
}
Ok(())
})
.await;
assert!(result.is_ok(), "a target that frees itself should spawn");
assert_eq!(attempts, 3);
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn an_awaited_permanently_busy_executable_fails_after_the_budget() {
let mut attempts = 0;
let started = Instant::now();
let error = retry_while_executable_busy_async(Duration::from_millis(20), || {
attempts += 1;
Err::<(), io::Error>(io::Error::from(io::ErrorKind::ExecutableFileBusy))
})
.await
.expect_err("a target that stays busy keeps failing");
assert_eq!(error.kind(), io::ErrorKind::ExecutableFileBusy);
assert!(
attempts > 1,
"the budget should cover more than one attempt"
);
assert!(started.elapsed() >= Duration::from_millis(20));
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn a_tokio_spawn_reports_a_missing_executable_without_a_retry() {
let mut command = tokio::process::Command::new("fallow-executable-that-does-not-exist");
let started = Instant::now();
let error = spawn_tokio_retrying_busy_executable(&mut command)
.await
.expect_err("a missing executable stays missing");
assert_eq!(error.kind(), io::ErrorKind::NotFound);
assert!(started.elapsed() < EXECUTABLE_BUSY_BUDGET);
}
}