From c712725edd8183832bba0cc61ee185fc060bed5f Mon Sep 17 00:00:00 2001 From: Louis Deconinck Date: Thu, 17 Sep 2026 21:50:36 +0200 Subject: [PATCH 1/3] Re-arm pytest-timeout's timer for each rerun attempt pytest-timeout arms a single timer around pytest_runtest_protocol, so after it fired on the first attempt every rerun ran unbounded. Detect the plugin's whole-protocol timeout and cancel/re-arm the timer at the start of each attempt. timeout_func_only already works per attempt via pytest_runtest_call. Closes #99. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.rst | 5 +++++ changes/99.bugfix.rst | 2 ++ src/pytest_rerunfailures.py | 27 +++++++++++++++++++++++++++ tests/test_pytest_rerunfailures.py | 18 ++++++++++++++++++ tox.ini | 1 + 5 files changed, 53 insertions(+) create mode 100644 changes/99.bugfix.rst 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..a2716bea 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,9 +1277,17 @@ 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) diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index a4208c50..6a42b046 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -548,6 +548,24 @@ 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_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.* From ec2934ce8da62b3a1a43ddccd6de60533326d5e6 Mon Sep 17 00:00:00 2001 From: Louis Deconinck Date: Fri, 18 Sep 2026 08:21:49 +0200 Subject: [PATCH 2/3] Cancel the per-attempt pytest-timeout timer after each attempt The re-armed timer stayed armed while rerun reports were processed and during reruns_delay, so a delay longer than the timeout could kill the run before the next attempt got a fresh timer. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/pytest_rerunfailures.py | 10 +++++++++- tests/test_pytest_rerunfailures.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index a2716bea..a80ac7e0 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -1289,7 +1289,15 @@ def pytest_runtest_protocol(item, nextitem): 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 6a42b046..e61bb759 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -566,6 +566,21 @@ def test_slow(): 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""" From 1b54520160cca8b553a27628100720e85a0fda29 Mon Sep 17 00:00:00 2001 From: Louis Deconinck Date: Fri, 18 Sep 2026 08:40:36 +0200 Subject: [PATCH 3/3] Format with ruff --- src/pytest_rerunfailures.py | 4 +--- tests/test_pytest_rerunfailures.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index a80ac7e0..77c6490d 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -1295,9 +1295,7 @@ def pytest_runtest_protocol(item, nextitem): 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 - ) + 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 e61bb759..f41324a0 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -575,9 +575,7 @@ def test_fail(): ) # 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" - ) + result = testdir.runpytest("--reruns", "1", "--reruns-delay", "2", "--timeout", "1") assert_outcomes(result, passed=1, rerun=1)