diff --git a/changelog/15180.bugfix.rst b/changelog/15180.bugfix.rst new file mode 100644 index 00000000000..a08a4ce1c38 --- /dev/null +++ b/changelog/15180.bugfix.rst @@ -0,0 +1 @@ +Fixed ``faulthandler_timeout`` being cancelled at the first failure in a test, so the timeout no longer covers the rest of the test body or fixture teardown. A hanging teardown after a failed test now dumps a traceback (and exits with ``faulthandler_exit_on_timeout``) as documented. diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index e92b9893622..4ce04aacd7a 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -108,12 +108,3 @@ def pytest_enter_pdb() -> None: import faulthandler faulthandler.cancel_dump_traceback_later() - - -@pytest.hookimpl(tryfirst=True) -def pytest_exception_interact() -> None: - """Cancel any traceback dumping due to an interactive exception being - raised.""" - import faulthandler - - faulthandler.cancel_dump_traceback_later() diff --git a/testing/test_faulthandler.py b/testing/test_faulthandler.py index 78681d572aa..6ff32278bf9 100644 --- a/testing/test_faulthandler.py +++ b/testing/test_faulthandler.py @@ -118,6 +118,49 @@ def test_timeout(): assert result.ret == 0 +@pytest.mark.parametrize("exit_on_timeout", [True, False]) +def test_timeout_still_active_after_test_failure( + pytester: Pytester, exit_on_timeout: bool +) -> None: + """The timeout must keep covering teardown after the test itself fails. + + Regression test for #15180: pytest_exception_interact used to cancel the + timer on any failure, so a hanging fixture teardown after a failed test + never produced a traceback dump. + """ + pytester.makepyfile( + """ + import time + + import pytest + + + @pytest.fixture + def hang_on_teardown(): + yield + time.sleep(5) + + + def test_fail(hang_on_teardown): + assert False + """ + ) + pytester.makeini( + f""" + [pytest] + faulthandler_timeout = 0.5 + faulthandler_exit_on_timeout = {"true" if exit_on_timeout else "false"} + """ + ) + result = pytester.runpytest_subprocess() + result.stderr.fnmatch_lines(["*most recent call first*", "*in hang_on_teardown*"]) + if exit_on_timeout: + result.stdout.no_fnmatch_line("*1 failed*") + else: + result.stdout.fnmatch_lines(["*1 failed*"]) + assert result.ret == 1 + + @pytest.mark.parametrize( ("value", "expected"), [(5, 5), (0.01, 0.01), ('"0.01"', "0.01")] ) @@ -172,11 +215,16 @@ def test_long_sleep_and_raise(): 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: +def test_cancel_timeout_on_hook(monkeypatch) -> None: """Make sure that we are cancelling any scheduled traceback dumping due - to timeout before entering pdb (pytest-dev/pytest-faulthandler#12) or any - other interactive exception (pytest-dev/pytest-faulthandler#14).""" + to timeout before entering pdb (pytest-dev/pytest-faulthandler#12). + + The timeout is intentionally *not* cancelled on pytest_exception_interact + anymore (#15180): that hook fires for every ordinary test failure, which + disabled the timeout for the rest of the item, including teardown. The + pdb case is still covered because entering pdb triggers pytest_enter_pdb. + """ + hook_name = "pytest_enter_pdb" import faulthandler from _pytest import faulthandler as faulthandler_plugin