@@ -6,8 +6,9 @@ use crate::{error::Result, HookResult, HooksError};
66use 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) ]
208322fn is_executable ( path : & Path ) -> bool {
209323 use std:: os:: unix:: fs:: PermissionsExt ;
@@ -289,7 +403,8 @@ impl CommandExt for Command {
289403
290404#[ cfg( test) ]
291405mod 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