diff --git a/README.rst b/README.rst index 747072ee..f7357f71 100644 --- a/README.rst +++ b/README.rst @@ -352,6 +352,11 @@ be marked as failed. Compatibility ------------- +* This plugin is compatible with + `pytest-timeout `_: each attempt + gets the full configured timeout. With ``timeout_func_only`` the timeout + covers the test function body only; otherwise it covers the whole + per-attempt protocol (setup, call and teardown). * This plugin is *not* compatible with pytest-xdist's --looponfail flag. * This plugin is *not* compatible with the core --pdb flag. * This plugin is *not* compatible with the plugin diff --git a/changes/99.bugfix.rst b/changes/99.bugfix.rst new file mode 100644 index 00000000..592654f7 --- /dev/null +++ b/changes/99.bugfix.rst @@ -0,0 +1,2 @@ +Re-arm the ``pytest-timeout`` timer for each attempt so that the configured +timeout also applies to re-runs, not only to the first attempt. diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index 616e658b..77c6490d 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -1045,6 +1045,25 @@ def _restore_suspended_finalizers(item): suspended_finalizers.clear() +def _get_pytest_timeout_settings(item): + """Return pytest-timeout's settings when its whole-protocol timer applies. + + The ``func_only`` mode hooks ``pytest_runtest_call`` and already works + per attempt; only the default mode needs the timer re-armed here. + """ + pm = item.session.config.pluginmanager + if not (pm.hasplugin("timeout") or pm.hasplugin("pytest_timeout")): + return None + try: + from pytest_timeout import _get_item_settings + except ImportError: + return None + settings = _get_item_settings(item) + if settings.timeout and settings.timeout > 0 and settings.func_only is False: + return settings + return None + + def _is_rerun_path_excluded(item): excluded_paths = item.config.getoption("rerun_exclude_path") or [] return any( @@ -1258,11 +1277,25 @@ def pytest_runtest_protocol(item, nextitem): if item.execution_count > reruns and item.execution_count > 0: return True + timeout_settings = _get_pytest_timeout_settings(item) need_to_run = True while need_to_run: item.execution_count += 1 + if timeout_settings is not None: + # pytest-timeout arms one timer around pytest_runtest_protocol, so + # without a re-arm the rerun would run unbounded after the first + # timeout fired. Re-arm the per-item timeout for each attempt. + hooks = item.config.pluginmanager.hook + hooks.pytest_timeout_cancel_timer(item=item) + hooks.pytest_timeout_set_timer(item=item, settings=timeout_settings) item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) - reports = runtestprotocol(item, nextitem=nextitem, log=False) + try: + reports = runtestprotocol(item, nextitem=nextitem, log=False) + finally: + if timeout_settings is not None: + # the per-attempt timer must not stay armed while reports are + # processed or reruns_delay sleeps before the next attempt + item.config.pluginmanager.hook.pytest_timeout_cancel_timer(item=item) condition = get_reruns_condition(item, _get_reruns_condition_failures(item)) rerun_triggered = False diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index a4208c50..f41324a0 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -548,6 +548,37 @@ def test_pass(): assert_outcomes(result, passed=1, rerun=2) +def test_pytest_timeout_applies_to_each_rerun(testdir): + pytest.importorskip("pytest_timeout") + testdir.makepyfile( + """ + import time + + def test_slow(): + time.sleep(5) + """ + ) + start = time.time() + result = testdir.runpytest("--reruns", "1", "--timeout", "1") + elapsed = time.time() - start + # Both attempts must be timed out (~2s), not just the first one. + assert elapsed < 4.5 + assert_outcomes(result, passed=0, failed=1, rerun=1) + + +def test_pytest_timeout_does_not_fire_during_rerun_delay(testdir): + pytest.importorskip("pytest_timeout") + testdir.makepyfile( + f""" + def test_fail(): + {temporary_failure(1)}""" + ) + # The per-attempt timer must be cancelled before the rerun delay, + # otherwise a delay longer than the timeout kills the next attempt. + result = testdir.runpytest("--reruns", "1", "--reruns-delay", "2", "--timeout", "1") + assert_outcomes(result, passed=1, rerun=1) + + def test_no_extra_test_summary_for_reruns_by_default(testdir): testdir.makepyfile( f""" diff --git a/tox.ini b/tox.ini index b069bbed..baab3daf 100644 --- a/tox.ini +++ b/tox.ini @@ -21,6 +21,7 @@ minversion = 4.0 commands = pytest tests/ {posargs} deps = xdist: pytest-xdist + pytest-timeout pytest82: pytest==8.2.* pytest83: pytest==8.3.* pytest84: pytest==8.4.*