From c3a5b06ab4f3c34e60ebdc3483cf53447eb30127 Mon Sep 17 00:00:00 2001 From: bhabishnu <156663652+bhabishnu@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:14:13 +0530 Subject: [PATCH 1/5] Report cancelled async fixtures as fixture errors CancelledError raised during async fixture setup or teardown escaped into pytest as a BaseException, so a cancelled setup was not cached for later tests and a cancelled teardown prevented the remaining finalizers from running. Raise a PytestAsyncioError chained to the CancelledError instead. Also reset ContextVars changed by async generator fixtures in a finally block, so they are restored when teardown raises. Closes #1591 --- changelog.d/1591.fixed.rst | 1 + pytest_asyncio/plugin.py | 36 +++++++- .../test_async_fixtures_contextvars.py | 21 +++++ .../async_fixtures/test_cancelled_fixtures.py | 92 +++++++++++++++++++ 4 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1591.fixed.rst create mode 100644 tests/async_fixtures/test_cancelled_fixtures.py diff --git a/changelog.d/1591.fixed.rst b/changelog.d/1591.fixed.rst new file mode 100644 index 00000000..bf9b54bd --- /dev/null +++ b/changelog.d/1591.fixed.rst @@ -0,0 +1 @@ +Fixed cancelled async fixture setup and teardown being propagated as ``asyncio.CancelledError``, which could prevent fixture cleanup or break subsequent fixture setup. Also ensured ContextVars changed by async generator fixtures are restored when their teardown raises. diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 38b75e41..8c05ad1a 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -382,6 +382,22 @@ def _sync_fixture_wrapper( return _sync_fixture_wrapper +def _cancelled_fixture_error( + phase: Literal["setup", "teardown"], + request: FixtureRequest, +) -> PytestAsyncioError: + """ + Return the error to raise instead of a fixture's CancelledError. + + CancelledError is a BaseException, so pytest does not handle it like other + fixture errors: it does not cache it as a setup error, and it does not run + a node's remaining finalizers after one is raised during teardown. + """ + return PytestAsyncioError( + f"The {phase} of async fixture {request.fixturename!r} was cancelled." + ) + + AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams") AsyncGenFixtureYieldType = TypeVar("AsyncGenFixtureYieldType") @@ -405,7 +421,10 @@ async def setup(): return res context = contextvars.copy_context() - result = runner.run(setup(), context=context) + try: + result = runner.run(setup(), context=context) + except asyncio.CancelledError as e: + raise _cancelled_fixture_error("setup", request) from e reset_contextvars = _apply_contextvar_changes(context) @@ -422,9 +441,13 @@ async def async_finalizer() -> None: msg += "Yield only once." raise ValueError(msg) - runner.run(async_finalizer(), context=context) - if reset_contextvars is not None: - reset_contextvars() + try: + runner.run(async_finalizer(), context=context) + except asyncio.CancelledError as e: + raise _cancelled_fixture_error("teardown", request) from e + finally: + if reset_contextvars is not None: + reset_contextvars() request.addfinalizer(finalizer) return result @@ -453,7 +476,10 @@ async def setup(): return res context = contextvars.copy_context() - result = runner.run(setup(), context=context) + try: + result = runner.run(setup(), context=context) + except asyncio.CancelledError as e: + raise _cancelled_fixture_error("setup", request) from e # Copy the context vars modified by the setup task into the current # context, and (if needed) add a finalizer to reset them. diff --git a/tests/async_fixtures/test_async_fixtures_contextvars.py b/tests/async_fixtures/test_async_fixtures_contextvars.py index 0b9046eb..da6fe857 100644 --- a/tests/async_fixtures/test_async_fixtures_contextvars.py +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -159,6 +159,27 @@ async def test(var_fixture_2): result.assert_outcomes(passed=1) +def test_var_from_async_generator_reset_when_teardown_fails(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(_prelude + dedent(""" + @pytest_asyncio.fixture + async def var_fixture(): + with context_var_manager("value"): + yield + raise RuntimeError("teardown failed") + + @pytest.mark.asyncio + async def test(var_fixture): + assert _context_var.get() == "value" + + def test_after_failed_teardown(): + with pytest.raises(LookupError): + _context_var.get() + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2, errors=1) + + def test_var_set_to_existing_value_ok(pytester: Pytester): pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile(_prelude + dedent(""" diff --git a/tests/async_fixtures/test_cancelled_fixtures.py b/tests/async_fixtures/test_cancelled_fixtures.py new file mode 100644 index 00000000..25bd38ff --- /dev/null +++ b/tests/async_fixtures/test_cancelled_fixtures.py @@ -0,0 +1,92 @@ +"""Async fixtures whose setup or teardown is cancelled.""" + +from __future__ import annotations + +from textwrap import dedent + +import pytest +from pytest import Pytester + + +def test_cancelled_teardown_does_not_prevent_teardown_of_other_fixtures( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + torn_down = [] + + @pytest.fixture + def sync_fixture(): + yield + torn_down.append("sync_fixture") + + @pytest_asyncio.fixture + async def cancelled_teardown(): + task = asyncio.create_task(asyncio.sleep(3600)) + yield + task.cancel() + await task + + @pytest.mark.asyncio + async def test_requests_fixtures(sync_fixture, cancelled_teardown): + pass + + def test_other_fixture_was_torn_down(): + assert torn_down == ["sync_fixture"] + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2, errors=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at teardown of test_requests_fixtures*", + "E*CancelledError", + "The above exception was the direct cause of the following exception:", + "*PytestAsyncioError: The teardown of async fixture " + "'cancelled_teardown' was cancelled.", + ] + ) + + +@pytest.mark.parametrize("fixture_end", ("yield", "return")) +def test_cancelled_setup_of_module_scoped_fixture_is_setup_error_of_each_test( + pytester: Pytester, fixture_end: str +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = module") + pytester.makepyfile(dedent(f"""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture(scope="module") + async def cancelled_setup(): + task = asyncio.create_task(asyncio.sleep(3600)) + task.cancel() + await task + {fixture_end} + + @pytest.mark.asyncio(loop_scope="module") + async def test_first(cancelled_setup): + pass + + @pytest.mark.asyncio(loop_scope="module") + async def test_second(cancelled_setup): + pass + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(errors=2) + result.stdout.fnmatch_lines( + [ + "*ERROR at setup of test_first*", + "*PytestAsyncioError: The setup of async fixture " + "'cancelled_setup' was cancelled.", + "*ERROR at setup of test_second*", + "*PytestAsyncioError: The setup of async fixture " + "'cancelled_setup' was cancelled.", + ] + ) From 86b3d3cfb0db86b11c0dadbc6fe2eea635168cf2 Mon Sep 17 00:00:00 2001 From: bhabishnu <156663652+bhabishnu@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:13:32 +0530 Subject: [PATCH 2/5] clarify changelog wording --- changelog.d/1591.fixed.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/changelog.d/1591.fixed.rst b/changelog.d/1591.fixed.rst index bf9b54bd..c3a9b107 100644 --- a/changelog.d/1591.fixed.rst +++ b/changelog.d/1591.fixed.rst @@ -1 +1,4 @@ -Fixed cancelled async fixture setup and teardown being propagated as ``asyncio.CancelledError``, which could prevent fixture cleanup or break subsequent fixture setup. Also ensured ContextVars changed by async generator fixtures are restored when their teardown raises. +Fixed cancelled async fixture setup and teardown being propagated as +``asyncio.CancelledError``, which could stop other fixtures from being torn down or break later +tests that use the fixture. Also ensured ContextVars changed by async generator fixtures are +restored when their teardown raises. From 1e6b9ee2e5b743e05f846de4cc9bff553b4414ab Mon Sep 17 00:00:00 2001 From: Tom Kuson Date: Sun, 1 Nov 2026 12:04:22 +0000 Subject: [PATCH 3/5] adjust docstring --- pytest_asyncio/plugin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 8c05ad1a..66cc53ca 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -389,9 +389,10 @@ def _cancelled_fixture_error( """ Return the error to raise instead of a fixture's CancelledError. - CancelledError is a BaseException, so pytest does not handle it like other - fixture errors: it does not cache it as a setup error, and it does not run - a node's remaining finalizers after one is raised during teardown. + CancelledError is not an Exception subclass, so pytest does not + handle it like other fixture errors: it does not cache it as a setup + error, and it does not run a node's remaining finalizers after one + is raised during teardown. """ return PytestAsyncioError( f"The {phase} of async fixture {request.fixturename!r} was cancelled." From 21bfdd446acbd4efc14c309bb05c8273cad8bcf9 Mon Sep 17 00:00:00 2001 From: Tom Kuson Date: Sun, 1 Nov 2026 12:12:48 +0000 Subject: [PATCH 4/5] check that a cancelled fixture setup runs once --- tests/async_fixtures/test_cancelled_fixtures.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/async_fixtures/test_cancelled_fixtures.py b/tests/async_fixtures/test_cancelled_fixtures.py index 25bd38ff..03aeacc3 100644 --- a/tests/async_fixtures/test_cancelled_fixtures.py +++ b/tests/async_fixtures/test_cancelled_fixtures.py @@ -63,8 +63,11 @@ def test_cancelled_setup_of_module_scoped_fixture_is_setup_error_of_each_test( import pytest import pytest_asyncio + set_up = [] + @pytest_asyncio.fixture(scope="module") async def cancelled_setup(): + set_up.append("cancelled_setup") task = asyncio.create_task(asyncio.sleep(3600)) task.cancel() await task @@ -77,9 +80,12 @@ async def test_first(cancelled_setup): @pytest.mark.asyncio(loop_scope="module") async def test_second(cancelled_setup): pass + + def test_fixture_was_set_up_once(): + assert set_up == ["cancelled_setup"] """)) result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(errors=2) + result.assert_outcomes(passed=1, errors=2) result.stdout.fnmatch_lines( [ "*ERROR at setup of test_first*", From 18d24d0ef9e88056be7218dedaefad808cf5e9db Mon Sep 17 00:00:00 2001 From: Tom Kuson Date: Sun, 1 Nov 2026 12:32:51 +0000 Subject: [PATCH 5/5] wrap strings in parentheses --- .../async_fixtures/test_cancelled_fixtures.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/async_fixtures/test_cancelled_fixtures.py b/tests/async_fixtures/test_cancelled_fixtures.py index 03aeacc3..351a69a6 100644 --- a/tests/async_fixtures/test_cancelled_fixtures.py +++ b/tests/async_fixtures/test_cancelled_fixtures.py @@ -46,8 +46,10 @@ def test_other_fixture_was_torn_down(): "*ERROR at teardown of test_requests_fixtures*", "E*CancelledError", "The above exception was the direct cause of the following exception:", - "*PytestAsyncioError: The teardown of async fixture " - "'cancelled_teardown' was cancelled.", + ( + "*PytestAsyncioError: The teardown of async fixture " + "'cancelled_teardown' was cancelled." + ), ] ) @@ -89,10 +91,14 @@ def test_fixture_was_set_up_once(): result.stdout.fnmatch_lines( [ "*ERROR at setup of test_first*", - "*PytestAsyncioError: The setup of async fixture " - "'cancelled_setup' was cancelled.", + ( + "*PytestAsyncioError: The setup of async fixture " + "'cancelled_setup' was cancelled." + ), "*ERROR at setup of test_second*", - "*PytestAsyncioError: The setup of async fixture " - "'cancelled_setup' was cancelled.", + ( + "*PytestAsyncioError: The setup of async fixture " + "'cancelled_setup' was cancelled." + ), ] )