@@ -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
@@ -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) ]
181295fn 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