fallow_process/
spawn_retry.rs1use std::io;
14use std::time::{Duration, Instant};
15
16const EXECUTABLE_BUSY_BUDGET: Duration = Duration::from_secs(1);
18
19const EXECUTABLE_BUSY_FIRST_BACKOFF: Duration = Duration::from_micros(200);
21
22const EXECUTABLE_BUSY_MAX_BACKOFF: Duration = Duration::from_millis(20);
24
25struct ExecutableBusyRetry {
27 deadline: Instant,
28 backoff: Duration,
29}
30
31impl ExecutableBusyRetry {
32 fn start(budget: Duration) -> Self {
33 Self {
34 deadline: Instant::now() + budget,
35 backoff: EXECUTABLE_BUSY_FIRST_BACKOFF,
36 }
37 }
38
39 fn pause_after(&mut self, error: &io::Error) -> Option<Duration> {
42 if error.kind() != io::ErrorKind::ExecutableFileBusy || Instant::now() >= self.deadline {
43 return None;
44 }
45 let pause = self.backoff;
46 self.backoff = (self.backoff * 2).min(EXECUTABLE_BUSY_MAX_BACKOFF);
47 Some(pause)
48 }
49}
50
51pub fn spawn_retrying_busy_executable(
56 command: &mut std::process::Command,
57) -> io::Result<std::process::Child> {
58 retry_while_executable_busy(EXECUTABLE_BUSY_BUDGET, || command.spawn())
59}
60
61#[cfg(feature = "tokio")]
66pub async fn spawn_tokio_retrying_busy_executable(
67 command: &mut tokio::process::Command,
68) -> io::Result<tokio::process::Child> {
69 retry_while_executable_busy_async(EXECUTABLE_BUSY_BUDGET, || command.spawn()).await
70}
71
72#[cfg(feature = "tokio")]
76async fn retry_while_executable_busy_async<T>(
77 budget: Duration,
78 mut attempt: impl FnMut() -> io::Result<T>,
79) -> io::Result<T> {
80 let mut retry = ExecutableBusyRetry::start(budget);
81 loop {
82 let error = match attempt() {
83 Ok(value) => return Ok(value),
84 Err(error) => error,
85 };
86 let Some(pause) = retry.pause_after(&error) else {
87 return Err(error);
88 };
89 tokio::time::sleep(pause).await;
90 }
91}
92
93fn retry_while_executable_busy<T>(
96 budget: Duration,
97 mut attempt: impl FnMut() -> io::Result<T>,
98) -> io::Result<T> {
99 let mut retry = ExecutableBusyRetry::start(budget);
100 loop {
101 let error = match attempt() {
102 Ok(value) => return Ok(value),
103 Err(error) => error,
104 };
105 let Some(pause) = retry.pause_after(&error) else {
106 return Err(error);
107 };
108 std::thread::sleep(pause);
109 }
110}
111
112#[cfg(test)]
113#[expect(
114 clippy::expect_used,
115 reason = "test setup failures should fail at the exact setup operation"
116)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn a_busy_executable_is_retried_until_it_is_free() {
122 let mut attempts = 0;
123 let result = retry_while_executable_busy(Duration::from_secs(30), || {
124 attempts += 1;
125 if attempts < 3 {
126 return Err(io::Error::from(io::ErrorKind::ExecutableFileBusy));
127 }
128 Ok(())
129 });
130
131 assert!(result.is_ok(), "a target that frees itself should spawn");
132 assert_eq!(attempts, 3);
133 }
134
135 #[test]
136 fn a_permanently_busy_executable_fails_after_the_budget() {
137 let mut attempts = 0;
138 let started = Instant::now();
139 let error = retry_while_executable_busy(Duration::from_millis(20), || {
140 attempts += 1;
141 Err::<(), io::Error>(io::Error::from(io::ErrorKind::ExecutableFileBusy))
142 })
143 .expect_err("a target that stays busy keeps failing");
144
145 assert_eq!(error.kind(), io::ErrorKind::ExecutableFileBusy);
146 assert!(
147 attempts > 1,
148 "the budget should cover more than one attempt"
149 );
150 assert!(started.elapsed() >= Duration::from_millis(20));
151 }
152
153 #[test]
154 fn other_spawn_failures_are_reported_without_a_retry() {
155 let mut attempts = 0;
156 let error = retry_while_executable_busy(Duration::from_secs(30), || {
157 attempts += 1;
158 Err::<(), io::Error>(io::Error::from(io::ErrorKind::NotFound))
159 })
160 .expect_err("a missing executable stays missing");
161
162 assert_eq!(error.kind(), io::ErrorKind::NotFound);
163 assert_eq!(attempts, 1);
164 }
165
166 #[test]
167 fn the_pause_doubles_up_to_the_ceiling() {
168 let busy = io::Error::from(io::ErrorKind::ExecutableFileBusy);
169 let mut retry = ExecutableBusyRetry::start(Duration::from_secs(30));
170
171 let mut pauses = Vec::new();
172 for _ in 0..12 {
173 pauses.push(retry.pause_after(&busy).expect("the budget is not spent"));
174 }
175
176 assert_eq!(pauses[0], EXECUTABLE_BUSY_FIRST_BACKOFF);
177 assert_eq!(pauses[1], EXECUTABLE_BUSY_FIRST_BACKOFF * 2);
178 assert!(pauses.windows(2).all(|pair| pair[0] <= pair[1]));
179 assert_eq!(
180 *pauses.last().expect("pauses were recorded"),
181 EXECUTABLE_BUSY_MAX_BACKOFF
182 );
183 }
184
185 #[cfg(feature = "tokio")]
186 #[tokio::test]
187 async fn an_awaited_busy_executable_is_retried_until_it_is_free() {
188 let mut attempts = 0;
189 let result = retry_while_executable_busy_async(Duration::from_secs(30), || {
190 attempts += 1;
191 if attempts < 3 {
192 return Err(io::Error::from(io::ErrorKind::ExecutableFileBusy));
193 }
194 Ok(())
195 })
196 .await;
197
198 assert!(result.is_ok(), "a target that frees itself should spawn");
199 assert_eq!(attempts, 3);
200 }
201
202 #[cfg(feature = "tokio")]
203 #[tokio::test]
204 async fn an_awaited_permanently_busy_executable_fails_after_the_budget() {
205 let mut attempts = 0;
206 let started = Instant::now();
207 let error = retry_while_executable_busy_async(Duration::from_millis(20), || {
208 attempts += 1;
209 Err::<(), io::Error>(io::Error::from(io::ErrorKind::ExecutableFileBusy))
210 })
211 .await
212 .expect_err("a target that stays busy keeps failing");
213
214 assert_eq!(error.kind(), io::ErrorKind::ExecutableFileBusy);
215 assert!(
216 attempts > 1,
217 "the budget should cover more than one attempt"
218 );
219 assert!(started.elapsed() >= Duration::from_millis(20));
220 }
221
222 #[cfg(feature = "tokio")]
223 #[tokio::test]
224 async fn a_tokio_spawn_reports_a_missing_executable_without_a_retry() {
225 let mut command = tokio::process::Command::new("fallow-executable-that-does-not-exist");
226 let started = Instant::now();
227
228 let error = spawn_tokio_retrying_busy_executable(&mut command)
229 .await
230 .expect_err("a missing executable stays missing");
231
232 assert_eq!(error.kind(), io::ErrorKind::NotFound);
233 assert!(started.elapsed() < EXECUTABLE_BUSY_BUDGET);
234 }
235}