Skip to content

pytest plugin: a test raising OutcomeException (skip/xfail/failed pytest.raises) breaks teardown of higher-scoped async fixtures holding a cancel scope #1179

Closed

Description

@lschmelzeisen

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
    raise

pytest 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)):
        raise

So 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 handling

Related

Activity

  1. lschmelzeisen commented on Jun 17, 2026

    @lschmelzeisen
    Author

    As a temporary workaround, I have added the following to my project's conftest.py:

    from asyncio import CancelledError
    from collections.abc import Callable, Coroutine
    from typing import Any
    
    from _pytest.outcomes import OutcomeException
    from anyio._backends._asyncio import TestRunner
    
    
    def _run_test_preserving_outcome_exceptions(
        self: TestRunner,
        test_func: Callable[..., Coroutine[Any, Any, Any]],
        kwargs: dict[str, Any],
    ) -> None:
        try:
            self.get_loop().run_until_complete(
                self._call_in_runner_task(test_func, **kwargs)
            )
        except Exception as exc:
            self._exceptions.append(exc)
        except OutcomeException:
            raise
        except BaseException:
            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
            raise
        self._raise_async_exceptions()
    
    
    TestRunner.run_test = _run_test_preserving_outcome_exceptions
  2. agronholm commented on Jun 17, 2026

    @agronholm
    Owner

    @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.

  3. EmmanuelNiyonshuti commented on Jun 17, 2026

    @EmmanuelNiyonshuti
    Collaborator

    @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.

    Yeah, This does seem like a Bug. we essentially set the runner_task to None when we encounter the BaseException mid async test . and in this specific case The taskgroup.cancel_scope.cancel will fail since the original task will be gone by then. I didn't think of this specific case. But I am not sure if letting OutcomeException propagate in run_test would be the right and reasonable approach here yet!

  4. EmmanuelNiyonshuti commented on Jun 17, 2026

    @EmmanuelNiyonshuti
    Collaborator

    @lschmelzeisen thanks for descriptive report. _pytest.outcomes.OutcomeException subclass BaseException indeed! there is no reason to treat a user deliberate signal _pytest.outcomes.OutcomeException as the forcibly stopped executions(KeyboardInterrupt and SystemExit) that PR #1121 addressed. glad you found a fix!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions