Repository navigation
pytest plugin: a test raising OutcomeException (skip/xfail/failed pytest.raises) breaks teardown of higher-scoped async fixtures holding a cancel scope #1179
Description
AI Disclaimer: This issue report was largely created with the help of Claude Opus 4.8 with max effort. I have personally checked that the reproduction works and that the description is accurate.
Things to check first
- I have searched the existing issues and didn't find my bug already reported there
- I have checked that my bug is still present in the latest release
AnyIO version
4.14.0
Python version
3.14.3
What happened?
Since 4.14.0, if any test in a session raises a pytest OutcomeException — i.e. pytest.skip(), pytest.xfail(), pytest.fail(), or an unmet pytest.raises(...) (including via @pytest.mark.xfail(strict=True)) — then the teardown of any higher-scoped (session/module/package) async-generator fixture that holds a cancel scope or task group open across its yield fails with:
RuntimeError: Attempted to exit cancel scope in a different task than it was entered in
The error is reported confusingly against the teardown of the last test of the session (not the test that triggered it). It worked fine in 4.13.0.
This is a regression from #1121 (the fix for #1060).
How can we reproduce the bug?
# test_anyio_repro.py
import anyio
import pytest
pytestmark = pytest.mark.anyio
@pytest.fixture(scope="session")
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture(scope="session")
async def background_resource():
"""Session-scoped fixture holding a task group open across its `yield`."""
async with anyio.create_task_group() as tg:
tg.start_soon(anyio.sleep_forever)
yield "resource"
tg.cancel_scope.cancel()
async def test_uses_the_resource(background_resource: str) -> None:
assert background_resource == "resource"
async def test_that_skips() -> None:
# Any pytest OutcomeException reproduces it: pytest.skip(), pytest.xfail(),
# pytest.fail(), or an unmet pytest.raises(...).
pytest.skip("anything that raises pytest's OutcomeException")$ pytest test_anyio_repro.py
...
_______________________________ ERROR at teardown of test_that_skips _______________________________
@pytest.fixture(scope="session")
async def background_resource():
"""Session-scoped fixture holding a task group open across its `yield`
(stands in for a uvicorn server, a background worker, ...)."""
> async with anyio.create_task_group() as tg:
^^^^^^^^^^^^^^^^^^^^^^^^^
...
ERROR test_anyio_repro.py::test_that_skips - RuntimeError: Attempted to exit cancel scope in a different task than it was entered in
========================= 1 passed, 1 skipped, 1 error in 0.09s =========================
Removing test_that_skips (or making it not raise an OutcomeException) makes the error disappear.
Root cause
4.14.0's TestRunner.run_test (asyncio backend) added an except BaseException handler that cancels and discards the shared _runner_task before fixture teardown:
except BaseException:
# A BaseException (e.g. KeyboardInterrupt, SystemExit) interrupted the event loop ...
if self._runner_task is not None and not self._runner_task.done():
self._runner_task.cancel()
self._send_stream.close()
try:
self.get_loop().run_until_complete(self._runner_task)
except CancelledError:
pass
finally:
self._runner_task = None
raisepytest signals skip/xfail/failed assertions by raising OutcomeException, which subclasses BaseException but not Exception — so a perfectly ordinary skipped/xfailed test takes this branch and tears down _runner_task. The next _call_in_runner_task lazily spawns a fresh runner task, and any cancel scope a higher-scoped fixture entered in the old task can no longer be exited (its _host_task is gone) → the "different task" error at teardown.
Notably, _run_tests_and_fixtures already makes exactly this distinction and deliberately does not treat OutcomeException as fatal:
except BaseException as exc:
if not future.cancelled():
future.set_exception(exc)
if not isinstance(exc, (Exception, OutcomeException)):
raiseSo run_test is inconsistent with _run_tests_and_fixtures.
Suggested fix
Let OutcomeException propagate from run_test without resetting the runner task — mirroring _run_tests_and_fixtures. e.g. add a handler before the catch-all:
except OutcomeException:
raise
except BaseException:
... # existing KeyboardInterrupt/SystemExit handlingRelated
- Fix test resumption after KeyboardInterrupt in asyncio backend #1121 — the PR that introduced this (fix for Pytest resumes after KeyboardInterrupt when using async fixture on asyncio backend #1060); its
except BaseExceptionis the trigger. - Pytest resumes after KeyboardInterrupt when using async fixture on asyncio backend #1060 — the motivating bug (KeyboardInterrupt not interrupting async-fixture tests).
- pytest-anyio and crashed background task in taskgroup fixture #805 / contextvars setup when mixing sync and async pytest fixtures in async test #614 — the broader, known architectural issue (task groups in fixtures bound to the single shared runner task). The per-scope "layered runner tasks" idea discussed in pytest-anyio and crashed background task in taskgroup fixture #805 would resolve this class of bug properly; the fix above is a narrow stopgap.
Activity
lschmelzeisen commentedon Jun 17, 2026
As a temporary workaround, I have added the following to my project's
conftest.py:agronholm commentedon Jun 17, 2026
@EmmanuelNiyonshuti thoughts? The linked PR was yours. I didn't spot any obvious problems with it at the time, and the tests obviously passed fine too.
EmmanuelNiyonshuti commentedon Jun 17, 2026
Yeah, This does seem like a Bug. we essentially set the
runner_taskto None when we encounter theBaseExceptionmid async test . and in this specific case Thetaskgroup.cancel_scope.cancelwill fail since the original task will be gone by then. I didn't think of this specific case. But I am not sure if lettingOutcomeExceptionpropagate inrun_testwould be the right and reasonable approach here yet!EmmanuelNiyonshuti commentedon Jun 17, 2026
@lschmelzeisen thanks for descriptive report.
_pytest.outcomes.OutcomeExceptionsubclass BaseException indeed! there is no reason to treat a user deliberate signal_pytest.outcomes.OutcomeExceptionas the forcibly stopped executions(KeyboardInterruptandSystemExit) that PR #1121 addressed. glad you found a fix!