From 8d573b2eaddd0397e5ff42e05d2386fe93e97428 Mon Sep 17 00:00:00 2001 From: ege-arhan Date: Tue, 3 Nov 2026 18:31:13 +0300 Subject: [PATCH] runner: catch BaseExceptionGroup during teardown (#15329) --- AUTHORS | 1 + changelog/15329.bugfix.rst | 1 + src/_pytest/runner.py | 2 +- testing/test_runner.py | 62 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 changelog/15329.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 136ffd6c5ee..fbd032b111d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -158,6 +158,7 @@ Edson Tadeu M. Manoel Eduardo Schettino Edward Haigh Eero Vaher +Ege Arhan Eli Boyarski Elizaveta Shashkova Éloi Rivard diff --git a/changelog/15329.bugfix.rst b/changelog/15329.bugfix.rst new file mode 100644 index 00000000000..040de79b305 --- /dev/null +++ b/changelog/15329.bugfix.rst @@ -0,0 +1 @@ +Fix BaseExceptionGroup during teardown skipping remaining finalizers (#15329). diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 629796598df..cc48d3a7f3b 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -587,7 +587,7 @@ def teardown_exact(self, nextitem: Item | None) -> None: fin = finalizers.pop() try: fin() - except TEST_OUTCOME as e: + except (*TEST_OUTCOME, BaseExceptionGroup) as e: these_exceptions.append(e) if len(these_exceptions) == 1: diff --git a/testing/test_runner.py b/testing/test_runner.py index 8b59a5ee8c1..7be78b34429 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -1,6 +1,7 @@ # mypy: allow-untyped-defs from __future__ import annotations +import asyncio from functools import partial import inspect import os @@ -23,6 +24,7 @@ if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup from exceptiongroup import ExceptionGroup @@ -141,6 +143,66 @@ def raiser(exc): assert isinstance(func.exceptions[0], TypeError) assert isinstance(func.exceptions[1], ValueError) + def test_teardown_base_exception_group(self, pytester: Pytester) -> None: + """Ensure BaseExceptionGroup during teardown does not abort remaining finalizers (#15329).""" + orders: list[str] = [] + + def fin_beg() -> None: + orders.append("beg") + raise BaseExceptionGroup("teardown", [asyncio.CancelledError("cancel")]) + + def fin_ok() -> None: + orders.append("ok") + + item = pytester.getitem("def test_func(): pass") + ss = item.session._setupstate + ss.setup(item) + ss.addfinalizer(fin_ok, item) + ss.addfinalizer(fin_beg, item) + + with pytest.raises(BaseExceptionGroup) as exc_info: + ss.teardown_exact(None) + + assert orders == ["beg", "ok"] + assert exc_info.value.message == "teardown" + + def test_teardown_base_exception_group_cleans_fixture_cache( + self, pytester: Pytester + ) -> None: + """Regression test for #15329: BaseExceptionGroup during teardown does not leak cached fixtures.""" + pytester.makepyfile( + """ + import asyncio + import pytest + + allocations = 0 + + @pytest.fixture + def owner(request): + global allocations + allocations += 1 + token = allocations + yield token + + @pytest.fixture + def resource(owner, request): + yield owner + if request.node.name == "test_first": + raise BaseExceptionGroup( + "cleanup", + [asyncio.CancelledError("cancel marker"), RuntimeError("ordinary marker")], + ) + + def test_first(resource): + assert resource == 1 + + def test_next(resource): + assert resource == 2 + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=2, errors=1) + def test_cached_exception_doesnt_get_longer(self, pytester: Pytester) -> None: """Regression test for #12204 (the "BTW" case).""" pytester.makepyfile(test="")