@@ -6,8 +6,9 @@ use crate::{error::Result, HookResult, HooksError};
6
6
use std:: {
7
7
env,
8
8
path:: { Path , PathBuf } ,
9
- process:: { Command , Stdio } ,
9
+ process:: { Child , Command , Stdio } ,
10
10
str:: FromStr ,
11
+ thread,
11
12
time:: Duration ,
12
13
} ;
13
14
@@ -135,75 +136,188 @@ impl HookPaths {
135
136
136
137
/// this function calls hook scripts based on conventions documented here
137
138
/// 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 (
139
156
& self ,
140
157
args : & [ & str ] ,
141
158
timeout : Duration ,
142
159
) -> Result < HookResult > {
143
160
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) ?;
170
162
171
163
let output = if timeout. is_zero ( ) {
172
164
child. wait_with_output ( ) ?
173
165
} 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 } ) ;
184
172
}
185
173
186
174
child. wait_with_output ( ) ?
187
175
} ;
188
176
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,
203
285
}
204
286
}
205
287
}
206
288
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
+
207
321
#[ cfg( unix) ]
208
322
fn is_executable ( path : & Path ) -> bool {
209
323
use std:: os:: unix:: fs:: PermissionsExt ;
@@ -289,7 +403,8 @@ impl CommandExt for Command {
289
403
290
404
#[ cfg( test) ]
291
405
mod test {
292
- use super :: HookPaths ;
406
+ use super :: * ;
407
+ use pretty_assertions:: assert_eq;
293
408
use std:: path:: Path ;
294
409
295
410
#[ test]
@@ -317,4 +432,53 @@ mod test {
317
432
absolute_hook
318
433
) ;
319
434
}
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
+ }
320
484
}
0 commit comments