32
32
Timeout mechanism to use. 'signal' uses SIGALRM, 'thread' uses a timer
33
33
thread. If unspecified 'signal' is used on platforms which support
34
34
SIGALRM, otherwise 'thread' is used.
35
+ 'both' tries to gracefully time out a test, after kill_delay seconds
36
+ a harsh kill is used to reliably stop the test.
35
37
""" .strip ()
36
38
FUNC_ONLY_DESC = """
37
39
When set to True, defers the timeout evaluation to only the test
38
40
function body, ignoring the time it takes when evaluating any fixtures
39
41
used in the test.
40
42
""" .strip ()
43
+ KILL_DELAY_DESC = """
44
+ Delay between sending SIGALRM and killing the run using a timer thread.
45
+ """ .strip ()
41
46
42
47
# bdb covers pdb, ipdb, and possibly others
43
48
# pydevd covers PyCharm, VSCode, and possibly others
44
49
KNOWN_DEBUGGING_MODULES = {"pydevd" , "bdb" }
45
- Settings = namedtuple ("Settings" , ["timeout" , "method" , "func_only" ])
50
+ Settings = namedtuple ("Settings" , ["timeout" , "method" , "func_only" , "kill_delay" ])
46
51
47
52
48
53
@pytest .hookimpl
@@ -56,19 +61,21 @@ def pytest_addoption(parser):
56
61
group .addoption (
57
62
"--timeout_method" ,
58
63
action = "store" ,
59
- choices = ["signal" , "thread" ],
64
+ choices = ["signal" , "thread" , "both" ],
60
65
help = "Deprecated, use --timeout-method" ,
61
66
)
62
67
group .addoption (
63
68
"--timeout-method" ,
64
69
dest = "timeout_method" ,
65
70
action = "store" ,
66
- choices = ["signal" , "thread" ],
71
+ choices = ["signal" , "thread" , "both" ],
67
72
help = METHOD_DESC ,
68
73
)
74
+ group .addoption ("--timeout-kill-delay" , type = float , help = KILL_DELAY_DESC )
69
75
parser .addini ("timeout" , TIMEOUT_DESC )
70
76
parser .addini ("timeout_method" , METHOD_DESC )
71
77
parser .addini ("timeout_func_only" , FUNC_ONLY_DESC , type = "bool" )
78
+ parser .addini ("timeout_kill_delay" , KILL_DELAY_DESC )
72
79
73
80
74
81
@pytest .hookimpl
@@ -89,6 +96,7 @@ def pytest_configure(config):
89
96
config ._env_timeout = settings .timeout
90
97
config ._env_timeout_method = settings .method
91
98
config ._env_timeout_func_only = settings .func_only
99
+ config ._env_timeout_kill_delay = settings .kill_delay
92
100
93
101
94
102
@pytest .hookimpl (hookwrapper = True )
@@ -127,11 +135,12 @@ def pytest_report_header(config):
127
135
"""Add timeout config to pytest header."""
128
136
if config ._env_timeout :
129
137
return [
130
- "timeout: %ss\n timeout method: %s\n timeout func_only: %s"
138
+ "timeout: %ss\n timeout method: %s\n timeout func_only: %s\n timeout kill_delay: %s "
131
139
% (
132
140
config ._env_timeout ,
133
141
config ._env_timeout_method ,
134
142
config ._env_timeout_func_only ,
143
+ config ._env_timeout_kill_delay ,
135
144
)
136
145
]
137
146
@@ -216,6 +225,28 @@ def cancel():
216
225
217
226
item .cancel_timeout = cancel
218
227
timer .start ()
228
+ elif params .method == "both" :
229
+ timer = threading .Timer (params .timeout + params .kill_delay , timeout_timer ,
230
+ (item , params .timeout + params .kill_delay ))
231
+ timer .name = "%s %s" % (__name__ , item .nodeid )
232
+
233
+ def handler_signal (signum , frame ):
234
+ __tracebackhide__ = True
235
+ timer .cancel ()
236
+ timer .join ()
237
+ timeout_sigalrm (item , params .timeout )
238
+
239
+ def cancel ():
240
+ signal .setitimer (signal .ITIMER_REAL , 0 )
241
+ signal .signal (signal .SIGALRM , signal .SIG_DFL )
242
+ timer .cancel ()
243
+ timer .join ()
244
+
245
+ item .cancel_timeout = cancel
246
+ signal .signal (signal .SIGALRM , handler_signal )
247
+ signal .setitimer (signal .ITIMER_REAL , params .timeout )
248
+ timer .start ()
249
+
219
250
220
251
221
252
def timeout_teardown (item ):
@@ -258,7 +289,18 @@ def get_env_settings(config):
258
289
func_only = None
259
290
if func_only is not None :
260
291
func_only = _validate_func_only (func_only , "config file" )
261
- return Settings (timeout , method , func_only or False )
292
+
293
+ kill_delay = config .getvalue ("timeout_kill_delay" )
294
+ if kill_delay is None :
295
+ kill_delay = _validate_timeout (
296
+ os .environ .get ("PYTEST_KILL_DELAY" ), "PYTEST_KILL_DELAY environment variable" ,
297
+ name = "kill_delay"
298
+ )
299
+ if kill_delay is None :
300
+ ini = config .getini ("timeout_kill_delay" )
301
+ if ini :
302
+ kill_delay = _validate_timeout (ini , "config file" , name = "kill_delay" )
303
+ return Settings (timeout , method , func_only or False , kill_delay )
262
304
263
305
264
306
def get_func_only_setting (item ):
@@ -277,21 +319,26 @@ def get_func_only_setting(item):
277
319
278
320
def get_params (item , marker = None ):
279
321
"""Return (timeout, method) for an item."""
280
- timeout = method = func_only = None
322
+ timeout = method = func_only = kill_delay = None
281
323
if not marker :
282
324
marker = item .get_closest_marker ("timeout" )
283
325
if marker is not None :
284
326
settings = _parse_marker (item .get_closest_marker (name = "timeout" ))
285
327
timeout = _validate_timeout (settings .timeout , "marker" )
286
328
method = _validate_method (settings .method , "marker" )
287
329
func_only = _validate_func_only (settings .func_only , "marker" )
330
+ kill_delay = _validate_timeout (settings .kill_delay , "marker" , name = "kill_delay" )
288
331
if timeout is None :
289
332
timeout = item .config ._env_timeout
290
333
if method is None :
291
334
method = item .config ._env_timeout_method
292
335
if func_only is None :
293
336
func_only = item .config ._env_timeout_func_only
294
- return Settings (timeout , method , func_only )
337
+ if kill_delay is None :
338
+ kill_delay = item .config ._env_timeout_kill_delay
339
+ if method == "both" and (kill_delay is None or kill_delay <= 0 ):
340
+ method = DEFAULT_METHOD
341
+ return Settings (timeout , method , func_only , kill_delay )
295
342
296
343
297
344
def _parse_marker (marker ):
@@ -302,14 +349,16 @@ def _parse_marker(marker):
302
349
"""
303
350
if not marker .args and not marker .kwargs :
304
351
raise TypeError ("Timeout marker must have at least one argument" )
305
- timeout = method = func_only = NOTSET = object ()
352
+ timeout = method = func_only = kill_delay = NOTSET = object ()
306
353
for kw , val in marker .kwargs .items ():
307
354
if kw == "timeout" :
308
355
timeout = val
309
356
elif kw == "method" :
310
357
method = val
311
358
elif kw == "func_only" :
312
359
func_only = val
360
+ elif kw == "kill_delay" :
361
+ kill_delay = val
313
362
else :
314
363
raise TypeError ("Invalid keyword argument for timeout marker: %s" % kw )
315
364
if len (marker .args ) >= 1 and timeout is not NOTSET :
@@ -328,22 +377,24 @@ def _parse_marker(marker):
328
377
method = None
329
378
if func_only is NOTSET :
330
379
func_only = None
331
- return Settings (timeout , method , func_only )
380
+ if kill_delay is NOTSET :
381
+ kill_delay = None
382
+ return Settings (timeout , method , func_only , kill_delay )
332
383
333
384
334
- def _validate_timeout (timeout , where ):
385
+ def _validate_timeout (timeout , where , name : str = "timeout" ):
335
386
if timeout is None :
336
387
return None
337
388
try :
338
389
return float (timeout )
339
390
except ValueError :
340
- raise ValueError ("Invalid timeout %s from %s" % (timeout , where ))
391
+ raise ValueError ("Invalid %s %s from %s" % (name , timeout , where ))
341
392
342
393
343
394
def _validate_method (method , where ):
344
395
if method is None :
345
396
return None
346
- if method not in ["signal" , "thread" ]:
397
+ if method not in ["signal" , "thread" , "both" ]:
347
398
raise ValueError ("Invalid method %s from %s" % (method , where ))
348
399
return method
349
400
@@ -365,6 +416,9 @@ def timeout_sigalrm(item, timeout):
365
416
"""
366
417
if is_debugging ():
367
418
return
419
+ cancel = getattr (item , 'cancel_kill_timeout' , None )
420
+ if cancel :
421
+ cancel ()
368
422
__tracebackhide__ = True
369
423
nthreads = len (threading .enumerate ())
370
424
if nthreads > 1 :
0 commit comments