Skip to content

Commit 0474735

Browse files
committed
refactor: implement exponentional backoff for sleep duration.
Also adds tests for this function and the usage of timeouts in git2-hooks
1 parent b932754 commit 0474735

File tree

3 files changed

+320
-82
lines changed

3 files changed

+320
-82
lines changed

asyncgit/src/sync/hooks.rs

+2-3
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,8 @@ mod tests {
328328
let file = temp_dir.path().join("test");
329329
let hook = format!(
330330
"#!/usr/bin/env sh
331-
sleep 1
332-
333-
echo 'after sleep' > {}
331+
sleep 1
332+
echo 'after sleep' > {}
334333
",
335334
file.as_path().to_str().unwrap()
336335
);

git2-hooks/src/hookspath.rs

+221-52
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ use crate::{error::Result, HookResult, HooksError};
66
use std::{
77
env,
88
path::{Path, PathBuf},
9-
process::{Command, Stdio},
9+
process::{Child, Command, Stdio},
1010
str::FromStr,
11+
thread,
1112
time::Duration,
1213
};
1314

@@ -108,75 +109,188 @@ impl HookPaths {
108109

109110
/// this function calls hook scripts based on conventions documented here
110111
/// see <https://git-scm.com/docs/githooks>
111-
pub fn run_hook(
112+
pub fn run_hook(&self, args: &[&str]) -> Result<HookResult> {
113+
let hook = self.hook.clone();
114+
let output = spawn_hook_process(&self.pwd, &hook, args)?
115+
.wait_with_output()?;
116+
117+
Ok(hook_result_from_output(hook, &output))
118+
}
119+
120+
/// this function calls hook scripts based on conventions documented here
121+
/// see <https://git-scm.com/docs/githooks>
122+
///
123+
/// With the addition of a timeout for the execution of the script.
124+
/// If the script takes longer than the specified timeout it will be killed.
125+
///
126+
/// This will add an additional 1ms at a minimum, up to a maximum of 50ms.
127+
/// see `timeout_with_quadratic_backoff` for more information
128+
pub fn run_hook_with_timeout(
112129
&self,
113130
args: &[&str],
114131
timeout: Duration,
115132
) -> Result<HookResult> {
116133
let hook = self.hook.clone();
117-
118-
let arg_str = format!("{:?} {}", hook, args.join(" "));
119-
// Use -l to avoid "command not found" on Windows.
120-
let bash_args =
121-
vec!["-l".to_string(), "-c".to_string(), arg_str];
122-
123-
log::trace!("run hook '{:?}' in '{:?}'", hook, self.pwd);
124-
125-
let git_shell = find_bash_executable()
126-
.or_else(find_default_unix_shell)
127-
.unwrap_or_else(|| "bash".into());
128-
let mut child = Command::new(git_shell)
129-
.args(bash_args)
130-
.with_no_window()
131-
.current_dir(&self.pwd)
132-
// This call forces Command to handle the Path environment correctly on windows,
133-
// the specific env set here does not matter
134-
// see https://github.com/rust-lang/rust/issues/37519
135-
.env(
136-
"DUMMY_ENV_TO_FIX_WINDOWS_CMD_RUNS",
137-
"FixPathHandlingOnWindows",
138-
)
139-
.stdout(Stdio::piped())
140-
.stderr(Stdio::piped())
141-
.stdin(Stdio::piped())
142-
.spawn()?;
134+
let mut child = spawn_hook_process(&self.pwd, &hook, args)?;
143135

144136
let output = if timeout.is_zero() {
145137
child.wait_with_output()?
146138
} else {
147-
let timer = std::time::Instant::now();
148-
while child.try_wait()?.is_none() {
149-
if timer.elapsed() > timeout {
150-
debug!("killing hook process");
151-
child.kill()?;
152-
return Ok(HookResult::TimedOut { hook });
153-
}
154-
155-
std::thread::yield_now();
156-
std::thread::sleep(Duration::from_millis(10));
139+
if !timeout_with_quadratic_backoff(timeout, || {
140+
Ok(child.try_wait()?.is_some())
141+
})? {
142+
debug!("killing hook process");
143+
child.kill()?;
144+
return Ok(HookResult::TimedOut { hook });
157145
}
158146

159147
child.wait_with_output()?
160148
};
161149

162-
if output.status.success() {
163-
Ok(HookResult::Ok { hook })
164-
} else {
165-
let stderr =
166-
String::from_utf8_lossy(&output.stderr).to_string();
167-
let stdout =
168-
String::from_utf8_lossy(&output.stdout).to_string();
169-
170-
Ok(HookResult::RunNotSuccessful {
171-
code: output.status.code(),
172-
stdout,
173-
stderr,
174-
hook,
175-
})
150+
Ok(hook_result_from_output(hook, &output))
151+
}
152+
}
153+
154+
/// This will loop, sleeping with exponentially increasing time until completion or timeout has been reached.
155+
///
156+
/// Formula:
157+
/// Base Duration: `BASE_MILLIS` is set to 1 millisecond.
158+
/// Max Sleep Duration: `MAX_SLEEP_MILLIS` is set to 50 milliseconds.
159+
/// Quadratic Calculation: Sleep time = (attempt^2) * `BASE_MILLIS`, capped by `MAX_SLEEP_MILLIS`.
160+
///
161+
/// The timing for each attempt up to the cap is as follows.
162+
///
163+
/// Attempt 1:
164+
/// Sleep Time=(1^2)×1=1
165+
/// Actual Sleep: 1 millisecond
166+
/// Total Sleep: 1 millisecond
167+
///
168+
/// Attempt 2:
169+
/// Sleep Time=(2^2)×1=4
170+
/// Actual Sleep: 4 milliseconds
171+
/// Total Sleep: 5 milliseconds
172+
///
173+
/// Attempt 3:
174+
/// Sleep Time=(3^2)×1=9
175+
/// Actual Sleep: 9 milliseconds
176+
/// Total Sleep: 14 milliseconds
177+
///
178+
/// Attempt 4:
179+
/// Sleep Time=(4^2)×1=16
180+
/// Actual Sleep: 16 milliseconds
181+
/// Total Sleep: 30 milliseconds
182+
///
183+
/// Attempt 5:
184+
/// Sleep Time=(5^2)×1=25
185+
/// Actual Sleep: 25 milliseconds
186+
/// Total Sleep: 55 milliseconds
187+
///
188+
/// Attempt 6:
189+
/// Sleep Time=(6^2)×1=36
190+
/// Actual Sleep: 36 milliseconds
191+
/// Total Sleep: 91 milliseconds
192+
///
193+
/// Attempt 7:
194+
/// Sleep Time=(7^2)×1=49
195+
/// Actual Sleep: 49 milliseconds
196+
/// Total Sleep: 140 milliseconds
197+
///
198+
/// Attempt 8:
199+
// Sleep Time=(8^2)×1=64, capped by `MAX_SLEEP_MILLIS` of 50
200+
// Actual Sleep: 50 milliseconds
201+
// Total Sleep: 190 milliseconds
202+
fn timeout_with_quadratic_backoff<F>(
203+
timeout: Duration,
204+
mut is_complete: F,
205+
) -> Result<bool>
206+
where
207+
F: FnMut() -> Result<bool>,
208+
{
209+
const BASE_MILLIS: u64 = 1;
210+
const MAX_SLEEP_MILLIS: u64 = 50;
211+
212+
let timer = std::time::Instant::now();
213+
let mut attempt: i32 = 1;
214+
215+
loop {
216+
if is_complete()? {
217+
return Ok(true);
218+
}
219+
220+
if timer.elapsed() > timeout {
221+
return Ok(false);
222+
}
223+
224+
let mut sleep_time = Duration::from_millis(
225+
(attempt.pow(2) as u64)
226+
.saturating_mul(BASE_MILLIS)
227+
.min(MAX_SLEEP_MILLIS),
228+
);
229+
230+
// Ensure we do not sleep more than the remaining time
231+
let remaining_time = timeout - timer.elapsed();
232+
if remaining_time < sleep_time {
233+
sleep_time = remaining_time;
234+
}
235+
236+
thread::sleep(sleep_time);
237+
attempt += 1;
238+
}
239+
}
240+
241+
fn hook_result_from_output(
242+
hook: PathBuf,
243+
output: &std::process::Output,
244+
) -> HookResult {
245+
if output.status.success() {
246+
HookResult::Ok { hook }
247+
} else {
248+
let stderr =
249+
String::from_utf8_lossy(&output.stderr).to_string();
250+
let stdout =
251+
String::from_utf8_lossy(&output.stdout).to_string();
252+
253+
HookResult::RunNotSuccessful {
254+
code: output.status.code(),
255+
stdout,
256+
stderr,
257+
hook,
176258
}
177259
}
178260
}
179261

262+
fn spawn_hook_process(
263+
directory: &PathBuf,
264+
hook: &PathBuf,
265+
args: &[&str],
266+
) -> Result<Child> {
267+
let arg_str = format!("{:?} {}", hook, args.join(" "));
268+
// Use -l to avoid "command not found" on Windows.
269+
let bash_args = vec!["-l".to_string(), "-c".to_string(), arg_str];
270+
271+
log::trace!("run hook '{:?}' in '{:?}'", hook, directory);
272+
273+
let git_shell = find_bash_executable()
274+
.or_else(find_default_unix_shell)
275+
.unwrap_or_else(|| "bash".into());
276+
let child = Command::new(git_shell)
277+
.args(bash_args)
278+
.with_no_window()
279+
.current_dir(directory)
280+
// This call forces Command to handle the Path environment correctly on windows,
281+
// the specific env set here does not matter
282+
// see https://github.com/rust-lang/rust/issues/37519
283+
.env(
284+
"DUMMY_ENV_TO_FIX_WINDOWS_CMD_RUNS",
285+
"FixPathHandlingOnWindows",
286+
)
287+
.stdout(Stdio::piped())
288+
.stderr(Stdio::piped())
289+
.spawn()?;
290+
291+
Ok(child)
292+
}
293+
180294
#[cfg(unix)]
181295
fn is_executable(path: &Path) -> bool {
182296
use std::os::unix::fs::PermissionsExt;
@@ -259,3 +373,58 @@ impl CommandExt for Command {
259373
self
260374
}
261375
}
376+
377+
#[cfg(test)]
378+
mod tests {
379+
use super::*;
380+
use pretty_assertions::assert_eq;
381+
382+
/// Ensures that the `timeout_with_quadratic_backoff` function
383+
/// does not cause the total execution time does not grealy increase the total execution time.
384+
#[test]
385+
fn test_timeout_with_quadratic_backoff_cost() {
386+
let timeout = Duration::from_millis(100);
387+
let start = std::time::Instant::now();
388+
let result =
389+
timeout_with_quadratic_backoff(timeout, || Ok(false));
390+
let elapsed = start.elapsed();
391+
392+
assert_eq!(result.unwrap(), false);
393+
assert!(elapsed < timeout + Duration::from_millis(10));
394+
}
395+
396+
/// Ensures that the `timeout_with_quadratic_backoff` function
397+
/// does not cause the execution time wait for much longer than the reason we are waiting.
398+
#[test]
399+
fn test_timeout_with_quadratic_backoff_timeout() {
400+
let timeout = Duration::from_millis(100);
401+
let wait_time = Duration::from_millis(5); // Attempt 1 + 2 = 5 ms
402+
403+
let start = std::time::Instant::now();
404+
let _ = timeout_with_quadratic_backoff(timeout, || {
405+
Ok(start.elapsed() > wait_time)
406+
});
407+
408+
let elapsed = start.elapsed();
409+
assert_eq!(5, elapsed.as_millis());
410+
}
411+
412+
/// Ensures that the overhead of the `timeout_with_quadratic_backoff` function
413+
/// does not exceed 15 microseconds per attempt.
414+
///
415+
/// This will obviously vary depending on the system, but this is a rough estimate.
416+
/// The overhead on an AMD 5900x is roughly 1 - 1.5 microseconds per attempt.
417+
#[test]
418+
fn test_timeout_with_quadratic_backoff_overhead() {
419+
// A timeout of 50 milliseconds should take 8 attempts to reach the timeout.
420+
const TARGET_ATTEMPTS: u128 = 8;
421+
const TIMEOUT: Duration = Duration::from_millis(190);
422+
423+
let start = std::time::Instant::now();
424+
let _ = timeout_with_quadratic_backoff(TIMEOUT, || Ok(false));
425+
let elapsed = start.elapsed();
426+
427+
let overhead = (elapsed - TIMEOUT).as_micros();
428+
assert!(overhead < TARGET_ATTEMPTS * 15);
429+
}
430+
}

0 commit comments

Comments
 (0)