@@ -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
@@ -108,75 +109,188 @@ impl HookPaths {
108
109
109
110
/// this function calls hook scripts based on conventions documented here
110
111
/// 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 (
112
129
& self ,
113
130
args : & [ & str ] ,
114
131
timeout : Duration ,
115
132
) -> Result < HookResult > {
116
133
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) ?;
143
135
144
136
let output = if timeout. is_zero ( ) {
145
137
child. wait_with_output ( ) ?
146
138
} 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 } ) ;
157
145
}
158
146
159
147
child. wait_with_output ( ) ?
160
148
} ;
161
149
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,
176
258
}
177
259
}
178
260
}
179
261
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
+
180
294
#[ cfg( unix) ]
181
295
fn is_executable ( path : & Path ) -> bool {
182
296
use std:: os:: unix:: fs:: PermissionsExt ;
@@ -259,3 +373,58 @@ impl CommandExt for Command {
259
373
self
260
374
}
261
375
}
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