Skip to content

Commit 86f5832

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 86f5832

File tree

3 files changed

+314
-82
lines changed

3 files changed

+314
-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

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

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

0 commit comments

Comments
 (0)