Skip to content

Commit 99edbf0

Browse files
committed
Add method = both: 1. signal + 2. thread
The new method aims at combining the advantages of `method = thread` (reliability) and `method = signal` (gracefulness, ability to continue). A new option `kill_delay` is added to configure how much time the test is granted to handle the timeout thrown by the signalling method. After that time has expired, the `thread` method is involved. Two new tests ensure that the new method works as expected.
1 parent fb28b8b commit 99edbf0

File tree

2 files changed

+109
-12
lines changed

2 files changed

+109
-12
lines changed

pytest_timeout.py

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,22 @@
3232
Timeout mechanism to use. 'signal' uses SIGALRM, 'thread' uses a timer
3333
thread. If unspecified 'signal' is used on platforms which support
3434
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.
3537
""".strip()
3638
FUNC_ONLY_DESC = """
3739
When set to True, defers the timeout evaluation to only the test
3840
function body, ignoring the time it takes when evaluating any fixtures
3941
used in the test.
4042
""".strip()
43+
KILL_DELAY_DESC = """
44+
Delay between sending SIGALRM and killing the run using a timer thread.
45+
""".strip()
4146

4247
# bdb covers pdb, ipdb, and possibly others
4348
# pydevd covers PyCharm, VSCode, and possibly others
4449
KNOWN_DEBUGGING_MODULES = {"pydevd", "bdb"}
45-
Settings = namedtuple("Settings", ["timeout", "method", "func_only"])
50+
Settings = namedtuple("Settings", ["timeout", "method", "func_only", "kill_delay"])
4651

4752

4853
@pytest.hookimpl
@@ -56,19 +61,21 @@ def pytest_addoption(parser):
5661
group.addoption(
5762
"--timeout_method",
5863
action="store",
59-
choices=["signal", "thread"],
64+
choices=["signal", "thread", "both"],
6065
help="Deprecated, use --timeout-method",
6166
)
6267
group.addoption(
6368
"--timeout-method",
6469
dest="timeout_method",
6570
action="store",
66-
choices=["signal", "thread"],
71+
choices=["signal", "thread", "both"],
6772
help=METHOD_DESC,
6873
)
74+
group.addoption("--timeout-kill-delay", type=float, help=KILL_DELAY_DESC)
6975
parser.addini("timeout", TIMEOUT_DESC)
7076
parser.addini("timeout_method", METHOD_DESC)
7177
parser.addini("timeout_func_only", FUNC_ONLY_DESC, type="bool")
78+
parser.addini("timeout_kill_delay", KILL_DELAY_DESC)
7279

7380

7481
@pytest.hookimpl
@@ -89,6 +96,7 @@ def pytest_configure(config):
8996
config._env_timeout = settings.timeout
9097
config._env_timeout_method = settings.method
9198
config._env_timeout_func_only = settings.func_only
99+
config._env_timeout_kill_delay = settings.kill_delay
92100

93101

94102
@pytest.hookimpl(hookwrapper=True)
@@ -127,11 +135,12 @@ def pytest_report_header(config):
127135
"""Add timeout config to pytest header."""
128136
if config._env_timeout:
129137
return [
130-
"timeout: %ss\ntimeout method: %s\ntimeout func_only: %s"
138+
"timeout: %ss\ntimeout method: %s\ntimeout func_only: %s\ntimeout kill_delay: %s"
131139
% (
132140
config._env_timeout,
133141
config._env_timeout_method,
134142
config._env_timeout_func_only,
143+
config._env_timeout_kill_delay,
135144
)
136145
]
137146

@@ -216,6 +225,28 @@ def cancel():
216225

217226
item.cancel_timeout = cancel
218227
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+
219250

220251

221252
def timeout_teardown(item):
@@ -258,7 +289,18 @@ def get_env_settings(config):
258289
func_only = None
259290
if func_only is not None:
260291
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)
262304

263305

264306
def get_func_only_setting(item):
@@ -277,21 +319,26 @@ def get_func_only_setting(item):
277319

278320
def get_params(item, marker=None):
279321
"""Return (timeout, method) for an item."""
280-
timeout = method = func_only = None
322+
timeout = method = func_only = kill_delay = None
281323
if not marker:
282324
marker = item.get_closest_marker("timeout")
283325
if marker is not None:
284326
settings = _parse_marker(item.get_closest_marker(name="timeout"))
285327
timeout = _validate_timeout(settings.timeout, "marker")
286328
method = _validate_method(settings.method, "marker")
287329
func_only = _validate_func_only(settings.func_only, "marker")
330+
kill_delay = _validate_timeout(settings.kill_delay, "marker", name="kill_delay")
288331
if timeout is None:
289332
timeout = item.config._env_timeout
290333
if method is None:
291334
method = item.config._env_timeout_method
292335
if func_only is None:
293336
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)
295342

296343

297344
def _parse_marker(marker):
@@ -302,14 +349,16 @@ def _parse_marker(marker):
302349
"""
303350
if not marker.args and not marker.kwargs:
304351
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()
306353
for kw, val in marker.kwargs.items():
307354
if kw == "timeout":
308355
timeout = val
309356
elif kw == "method":
310357
method = val
311358
elif kw == "func_only":
312359
func_only = val
360+
elif kw == "kill_delay":
361+
kill_delay = val
313362
else:
314363
raise TypeError("Invalid keyword argument for timeout marker: %s" % kw)
315364
if len(marker.args) >= 1 and timeout is not NOTSET:
@@ -328,22 +377,24 @@ def _parse_marker(marker):
328377
method = None
329378
if func_only is NOTSET:
330379
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)
332383

333384

334-
def _validate_timeout(timeout, where):
385+
def _validate_timeout(timeout, where, name: str = "timeout"):
335386
if timeout is None:
336387
return None
337388
try:
338389
return float(timeout)
339390
except ValueError:
340-
raise ValueError("Invalid timeout %s from %s" % (timeout, where))
391+
raise ValueError("Invalid %s %s from %s" % (name, timeout, where))
341392

342393

343394
def _validate_method(method, where):
344395
if method is None:
345396
return None
346-
if method not in ["signal", "thread"]:
397+
if method not in ["signal", "thread", "both"]:
347398
raise ValueError("Invalid method %s from %s" % (method, where))
348399
return method
349400

@@ -365,6 +416,9 @@ def timeout_sigalrm(item, timeout):
365416
"""
366417
if is_debugging():
367418
return
419+
cancel = getattr(item, 'cancel_kill_timeout', None)
420+
if cancel:
421+
cancel()
368422
__tracebackhide__ = True
369423
nthreads = len(threading.enumerate())
370424
if nthreads > 1:

test_pytest_timeout.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,49 @@ def test_foo():
7272
assert "++ Timeout ++" in result.stderr.lines[-1]
7373

7474

75+
@have_sigalrm
76+
def test_both_soft(testdir):
77+
testdir.makepyfile(
78+
"""
79+
import time
80+
81+
def test_foo():
82+
time.sleep(2)
83+
"""
84+
)
85+
result = testdir.runpytest("--timeout=1")
86+
result.stdout.fnmatch_lines(["*Failed: Timeout >1.0s*"])
87+
88+
89+
@have_sigalrm
90+
def test_both_hard(testdir):
91+
testdir.makepyfile(
92+
"""
93+
import signal
94+
import time
95+
96+
def test_foo():
97+
98+
def handler(signum, frame):
99+
time.sleep(2)
100+
101+
# so that the signal method does not succeed
102+
signal.signal(signal.SIGALRM, handler)
103+
time.sleep(2)
104+
"""
105+
)
106+
result = testdir.runpytest("--timeout=1", "--timeout-method=both", "--timeout-kill-delay=1")
107+
result.stderr.fnmatch_lines(
108+
[
109+
"*++ Timeout ++*",
110+
"*~~ Stack of MainThread* ~~*",
111+
"*File *, line *, in *",
112+
"*++ Timeout ++*",
113+
]
114+
)
115+
assert "++ Timeout ++" in result.stderr.lines[-1]
116+
117+
75118
@pytest.mark.skipif(
76119
hasattr(sys, "pypy_version_info"), reason="pypy coverage seems broken currently"
77120
)

0 commit comments

Comments
 (0)