Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ Oleg Sushchenko
Oleksandr Zavertniev
Olga Matoula
Oliver Bestwalter
Olivier Grisel
Omar Kohl
Omer Hadari
Ondřej Súkup
Expand Down
3 changes: 3 additions & 0 deletions changelog/13678.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added a new `faulthandler_exit_on_timeout` ini option set to "false" by default to let `faulthandler` interrupt the `pytest` process after a timeout in case of deadlock -- by :user:`ogrisel`.

Previously, a `faulthandler` timeout would only dump the traceback of all threads to stderr, but would not interrupt the `pytest` process.
22 changes: 22 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1484,6 +1484,25 @@ passed multiple times. The expected format is ``name=value``. For example::

For more information please refer to :ref:`faulthandler`.


.. confval:: faulthandler_exit_on_timeout

Exit the pytest process after the per-test timeout is reached by passing
`exit=True` to the :func:`faulthandler.dump_traceback_later` function. This
is particularly useful to avoid wasting CI resources for test suites that
are prone to putting the main Python interpreter into a deadlock state.

This option is set to 'false' by default.

.. code-block:: ini

# content of pytest.ini
[pytest]
faulthandler_timeout=5
faulthandler_exit_on_timeout=true

For more information please refer to :ref:`faulthandler`.

.. confval:: filterwarnings


Expand Down Expand Up @@ -2401,6 +2420,9 @@ All the command-line flags can be obtained by running ``pytest --help``::
faulthandler_timeout (string):
Dump the traceback of all threads if a test takes
more than TIMEOUT seconds to finish
faulthandler_exit_on_timeout (bool):
Exit the test process if a test takes more than
faulthandler_timeout seconds to finish
addopts (args): Extra command line options
minversion (string): Minimally required pytest version
pythonpath (paths): Add paths to sys.path
Expand Down
20 changes: 17 additions & 3 deletions src/_pytest/faulthandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@


def pytest_addoption(parser: Parser) -> None:
help = (
help_timeout = (
"Dump the traceback of all threads if a test takes "
"more than TIMEOUT seconds to finish"
)
parser.addini("faulthandler_timeout", help, default=0.0)
help_exit_on_timeout = (
"Exit the test process if a test takes more than "
"faulthandler_timeout seconds to finish"
)
parser.addini("faulthandler_timeout", help_timeout, default=0.0)
parser.addini(
"faulthandler_exit_on_timeout", help_exit_on_timeout, type="bool", default=False
)


def pytest_configure(config: Config) -> None:
Expand Down Expand Up @@ -72,14 +79,21 @@ def get_timeout_config_value(config: Config) -> float:
return float(config.getini("faulthandler_timeout") or 0.0)


def get_exit_on_timeout_config_value(config: Config) -> bool:
exit_on_timeout = config.getini("faulthandler_exit_on_timeout")
assert isinstance(exit_on_timeout, bool)
return exit_on_timeout


@pytest.hookimpl(wrapper=True, trylast=True)
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
timeout = get_timeout_config_value(item.config)
exit_on_timeout = get_exit_on_timeout_config_value(item.config)
if timeout > 0:
import faulthandler

stderr = item.config.stash[fault_handler_stderr_fd_key]
faulthandler.dump_traceback_later(timeout, file=stderr)
faulthandler.dump_traceback_later(timeout, file=stderr, exit=exit_on_timeout)
try:
return (yield)
finally:
Expand Down
37 changes: 37 additions & 0 deletions testing/test_faulthandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,43 @@ def test_timeout():
assert result.ret == 0


@pytest.mark.keep_ci_var
@pytest.mark.skipif(
"CI" in os.environ and sys.platform == "linux" and sys.version_info >= (3, 14),
reason="sometimes crashes on CI because of truncated outputs (#7022)",
)
@pytest.mark.parametrize("exit_on_timeout", [True, False])
def test_timeout_and_exit(pytester: Pytester, exit_on_timeout: bool) -> None:
"""Test option to force exit pytest process after a certain timeout."""
pytester.makepyfile(
"""
import os, time
def test_long_sleep_and_raise():
time.sleep(1 if "CI" in os.environ else 0.1)
raise AssertionError(
"This test should have been interrupted before reaching this point."
)
"""
)
pytester.makeini(
f"""
[pytest]
faulthandler_timeout = 0.01
faulthandler_exit_on_timeout = {"true" if exit_on_timeout else "false"}
"""
)
result = pytester.runpytest_subprocess()
tb_output = "most recent call first"
result.stderr.fnmatch_lines([f"*{tb_output}*"])
if exit_on_timeout:
result.stdout.no_fnmatch_line("*1 failed*")
result.stdout.no_fnmatch_line("*AssertionError*")
else:
result.stdout.fnmatch_lines(["*1 failed*"])
result.stdout.fnmatch_lines(["*AssertionError*"])
assert result.ret == 1


@pytest.mark.parametrize("hook_name", ["pytest_enter_pdb", "pytest_exception_interact"])
def test_cancel_timeout_on_hook(monkeypatch, hook_name) -> None:
"""Make sure that we are cancelling any scheduled traceback dumping due
Expand Down
Loading