Skip to content

Commit 883c425

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 2fe5f28 commit 883c425

File tree

3 files changed

+316
-83
lines changed

3 files changed

+316
-83
lines changed

Diff for: asyncgit/src/sync/hooks.rs

+2-3
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,8 @@ mod tests {
378378
let file = temp_dir.path().join("test");
379379
let hook = format!(
380380
"#!/usr/bin/env sh
381-
sleep 1
382-
383-
echo 'after sleep' > {}
381+
sleep 1
382+
echo 'after sleep' > {}
384383
",
385384
file.as_path().to_str().unwrap()
386385
);

Diff for: git2-hooks/src/hookspath.rs

+217-53
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

@@ -135,75 +136,188 @@ impl HookPaths {
135136

136137
/// this function calls hook scripts based on conventions documented here
137138
/// see <https://git-scm.com/docs/githooks>
138-
pub fn run_hook(
139+
pub fn run_hook(&self, args: &[&str]) -> Result<HookResult> {
140+
let hook = self.hook.clone();
141+
let output = spawn_hook_process(&self.pwd, &hook, args)?
142+
.wait_with_output()?;
143+
144+
Ok(hook_result_from_output(hook, &output))
145+
}
146+
147+
/// this function calls hook scripts based on conventions documented here
148+
/// see <https://git-scm.com/docs/githooks>
149+
///
150+
/// With the addition of a timeout for the execution of the script.
151+
/// If the script takes longer than the specified timeout it will be killed.
152+
///
153+
/// This will add an additional 1ms at a minimum, up to a maximum of 50ms.
154+
/// see `timeout_with_quadratic_backoff` for more information
155+
pub fn run_hook_with_timeout(
139156
&self,
140157
args: &[&str],
141158
timeout: Duration,
142159
) -> Result<HookResult> {
143160
let hook = self.hook.clone();
144-
145-
let arg_str = format!("{:?} {}", hook, args.join(" "));
146-
// Use -l to avoid "command not found" on Windows.
147-
let bash_args =
148-
vec!["-l".to_string(), "-c".to_string(), arg_str];
149-
150-
log::trace!("run hook '{:?}' in '{:?}'", hook, self.pwd);
151-
152-
let git_shell = find_bash_executable()
153-
.or_else(find_default_unix_shell)
154-
.unwrap_or_else(|| "bash".into());
155-
let mut child = Command::new(git_shell)
156-
.args(bash_args)
157-
.with_no_window()
158-
.current_dir(&self.pwd)
159-
// This call forces Command to handle the Path environment correctly on windows,
160-
// the specific env set here does not matter
161-
// see https://github.com/rust-lang/rust/issues/37519
162-
.env(
163-
"DUMMY_ENV_TO_FIX_WINDOWS_CMD_RUNS",
164-
"FixPathHandlingOnWindows",
165-
)
166-
.stdout(Stdio::piped())
167-
.stderr(Stdio::piped())
168-
.stdin(Stdio::piped())
169-
.spawn()?;
161+
let mut child = spawn_hook_process(&self.pwd, &hook, args)?;
170162

171163
let output = if timeout.is_zero() {
172164
child.wait_with_output()?
173165
} else {
174-
let timer = std::time::Instant::now();
175-
while child.try_wait()?.is_none() {
176-
if timer.elapsed() > timeout {
177-
debug!("killing hook process");
178-
child.kill()?;
179-
return Ok(HookResult::TimedOut { hook });
180-
}
181-
182-
std::thread::yield_now();
183-
std::thread::sleep(Duration::from_millis(10));
166+
if !timeout_with_quadratic_backoff(timeout, || {
167+
Ok(child.try_wait()?.is_some())
168+
})? {
169+
debug!("killing hook process");
170+
child.kill()?;
171+
return Ok(HookResult::TimedOut { hook });
184172
}
185173

186174
child.wait_with_output()?
187175
};
188176

189-
if output.status.success() {
190-
Ok(HookResult::Ok { hook })
191-
} else {
192-
let stderr =
193-
String::from_utf8_lossy(&output.stderr).to_string();
194-
let stdout =
195-
String::from_utf8_lossy(&output.stdout).to_string();
196-
197-
Ok(HookResult::RunNotSuccessful {
198-
code: output.status.code(),
199-
stdout,
200-
stderr,
201-
hook,
202-
})
177+
Ok(hook_result_from_output(hook, &output))
178+
}
179+
}
180+
181+
/// This will loop, sleeping with exponentially increasing time until completion or timeout has been reached.
182+
///
183+
/// Formula:
184+
/// Base Duration: `BASE_MILLIS` is set to 1 millisecond.
185+
/// Max Sleep Duration: `MAX_SLEEP_MILLIS` is set to 50 milliseconds.
186+
/// Quadratic Calculation: Sleep time = (attempt^2) * `BASE_MILLIS`, capped by `MAX_SLEEP_MILLIS`.
187+
///
188+
/// The timing for each attempt up to the cap is as follows.
189+
///
190+
/// Attempt 1:
191+
/// Sleep Time=(1^2)×1=1
192+
/// Actual Sleep: 1 millisecond
193+
/// Total Sleep: 1 millisecond
194+
///
195+
/// Attempt 2:
196+
/// Sleep Time=(2^2)×1=4
197+
/// Actual Sleep: 4 milliseconds
198+
/// Total Sleep: 5 milliseconds
199+
///
200+
/// Attempt 3:
201+
/// Sleep Time=(3^2)×1=9
202+
/// Actual Sleep: 9 milliseconds
203+
/// Total Sleep: 14 milliseconds
204+
///
205+
/// Attempt 4:
206+
/// Sleep Time=(4^2)×1=16
207+
/// Actual Sleep: 16 milliseconds
208+
/// Total Sleep: 30 milliseconds
209+
///
210+
/// Attempt 5:
211+
/// Sleep Time=(5^2)×1=25
212+
/// Actual Sleep: 25 milliseconds
213+
/// Total Sleep: 55 milliseconds
214+
///
215+
/// Attempt 6:
216+
/// Sleep Time=(6^2)×1=36
217+
/// Actual Sleep: 36 milliseconds
218+
/// Total Sleep: 91 milliseconds
219+
///
220+
/// Attempt 7:
221+
/// Sleep Time=(7^2)×1=49
222+
/// Actual Sleep: 49 milliseconds
223+
/// Total Sleep: 140 milliseconds
224+
///
225+
/// Attempt 8:
226+
// Sleep Time=(8^2)×1=64, capped by `MAX_SLEEP_MILLIS` of 50
227+
// Actual Sleep: 50 milliseconds
228+
// Total Sleep: 190 milliseconds
229+
fn timeout_with_quadratic_backoff<F>(
230+
timeout: Duration,
231+
mut is_complete: F,
232+
) -> Result<bool>
233+
where
234+
F: FnMut() -> Result<bool>,
235+
{
236+
const BASE_MILLIS: u64 = 1;
237+
const MAX_SLEEP_MILLIS: u64 = 50;
238+
239+
let timer = std::time::Instant::now();
240+
let mut attempt: i32 = 1;
241+
242+
loop {
243+
if is_complete()? {
244+
return Ok(true);
245+
}
246+
247+
if timer.elapsed() > timeout {
248+
return Ok(false);
249+
}
250+
251+
let mut sleep_time = Duration::from_millis(
252+
(attempt.pow(2) as u64)
253+
.saturating_mul(BASE_MILLIS)
254+
.min(MAX_SLEEP_MILLIS),
255+
);
256+
257+
// Ensure we do not sleep more than the remaining time
258+
let remaining_time = timeout - timer.elapsed();
259+
if remaining_time < sleep_time {
260+
sleep_time = remaining_time;
261+
}
262+
263+
thread::sleep(sleep_time);
264+
attempt += 1;
265+
}
266+
}
267+
268+
fn hook_result_from_output(
269+
hook: PathBuf,
270+
output: &std::process::Output,
271+
) -> HookResult {
272+
if output.status.success() {
273+
HookResult::Ok { hook }
274+
} else {
275+
let stderr =
276+
String::from_utf8_lossy(&output.stderr).to_string();
277+
let stdout =
278+
String::from_utf8_lossy(&output.stdout).to_string();
279+
280+
HookResult::RunNotSuccessful {
281+
code: output.status.code(),
282+
stdout,
283+
stderr,
284+
hook,
203285
}
204286
}
205287
}
206288

289+
fn spawn_hook_process(
290+
directory: &PathBuf,
291+
hook: &PathBuf,
292+
args: &[&str],
293+
) -> Result<Child> {
294+
let arg_str = format!("{:?} {}", hook, args.join(" "));
295+
// Use -l to avoid "command not found" on Windows.
296+
let bash_args = vec!["-l".to_string(), "-c".to_string(), arg_str];
297+
298+
log::trace!("run hook '{:?}' in '{:?}'", hook, directory);
299+
300+
let git_shell = find_bash_executable()
301+
.or_else(find_default_unix_shell)
302+
.unwrap_or_else(|| "bash".into());
303+
let child = Command::new(git_shell)
304+
.args(bash_args)
305+
.with_no_window()
306+
.current_dir(directory)
307+
// This call forces Command to handle the Path environment correctly on windows,
308+
// the specific env set here does not matter
309+
// see https://github.com/rust-lang/rust/issues/37519
310+
.env(
311+
"DUMMY_ENV_TO_FIX_WINDOWS_CMD_RUNS",
312+
"FixPathHandlingOnWindows",
313+
)
314+
.stdout(Stdio::piped())
315+
.stderr(Stdio::piped())
316+
.spawn()?;
317+
318+
Ok(child)
319+
}
320+
207321
#[cfg(unix)]
208322
fn is_executable(path: &Path) -> bool {
209323
use std::os::unix::fs::PermissionsExt;
@@ -289,7 +403,8 @@ impl CommandExt for Command {
289403

290404
#[cfg(test)]
291405
mod test {
292-
use super::HookPaths;
406+
use super::*;
407+
use pretty_assertions::assert_eq;
293408
use std::path::Path;
294409

295410
#[test]
@@ -317,4 +432,53 @@ mod test {
317432
absolute_hook
318433
);
319434
}
435+
436+
/// Ensures that the `timeout_with_quadratic_backoff` function
437+
/// does not cause the total execution time does not grealy increase the total execution time.
438+
#[test]
439+
fn test_timeout_with_quadratic_backoff_cost() {
440+
let timeout = Duration::from_millis(100);
441+
let start = std::time::Instant::now();
442+
let result =
443+
timeout_with_quadratic_backoff(timeout, || Ok(false));
444+
let elapsed = start.elapsed();
445+
446+
assert_eq!(result.unwrap(), false);
447+
assert!(elapsed < timeout + Duration::from_millis(10));
448+
}
449+
450+
/// Ensures that the `timeout_with_quadratic_backoff` function
451+
/// does not cause the execution time wait for much longer than the reason we are waiting.
452+
#[test]
453+
fn test_timeout_with_quadratic_backoff_timeout() {
454+
let timeout = Duration::from_millis(100);
455+
let wait_time = Duration::from_millis(5); // Attempt 1 + 2 = 5 ms
456+
457+
let start = std::time::Instant::now();
458+
let _ = timeout_with_quadratic_backoff(timeout, || {
459+
Ok(start.elapsed() > wait_time)
460+
});
461+
462+
let elapsed = start.elapsed();
463+
assert_eq!(5, elapsed.as_millis());
464+
}
465+
466+
/// Ensures that the overhead of the `timeout_with_quadratic_backoff` function
467+
/// does not exceed 15 microseconds per attempt.
468+
///
469+
/// This will obviously vary depending on the system, but this is a rough estimate.
470+
/// The overhead on an AMD 5900x is roughly 1 - 1.5 microseconds per attempt.
471+
#[test]
472+
fn test_timeout_with_quadratic_backoff_overhead() {
473+
// A timeout of 50 milliseconds should take 8 attempts to reach the timeout.
474+
const TARGET_ATTEMPTS: u128 = 8;
475+
const TIMEOUT: Duration = Duration::from_millis(190);
476+
477+
let start = std::time::Instant::now();
478+
let _ = timeout_with_quadratic_backoff(TIMEOUT, || Ok(false));
479+
let elapsed = start.elapsed();
480+
481+
let overhead = (elapsed - TIMEOUT).as_micros();
482+
assert!(overhead < TARGET_ATTEMPTS * 15);
483+
}
320484
}

0 commit comments

Comments
 (0)