Skip to content

On asyncio, CancelScope doesn't filter a level-cancellation exception that it should #1091

@gschaffner

Description

@gschaffner

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

master (d68670b)

Python version

CPython 3.13.12

What happened?

On the asyncio backend, CancelScope.__exit__ doesn't filter/swallow a level-cancellation exception that it should if the cancellation exception is in a group. The cancellation exception instead propagates and causes the run() to crash and raise the level-cancellation exception (as part of a group).

How can we reproduce the bug?

Minimal reproducers that fail on asyncio and pass on Trio:

async def test_exception_group_filtering() -> None:
    """Test that CancelledErrors are filtered out of exception groups."""

    body_exc = RuntimeError()

    def check_body_exc(exc: RuntimeError, /) -> bool:
        return exc is body_exc

    with pytest.RaisesGroup(pytest.RaisesExc(RuntimeError, check=check_body_exc)):
        with CancelScope() as cs:
            cs.cancel()
            try:
                raise body_exc
            except BaseException as exc:
                exceptions = [exc]

                try:
                    await checkpoint()
                except BaseException as exc2:
                    exceptions.append(exc2)
                else:
                    pytest.fail("Did not raise a cancellation exception")

                try:
                    raise BaseExceptionGroup("", exceptions)
                finally:
                    # Prevent reference cycles.
                    del exceptions


async def test_nested_exception_group_filtering() -> None:
    """Test that CancelledErrors are filtered out of nested exception groups."""

    body_exc = RuntimeError()

    def check_body_exc(exc: RuntimeError, /) -> bool:
        return exc is body_exc

    with pytest.RaisesGroup(
        pytest.RaisesGroup(pytest.RaisesExc(RuntimeError, check=check_body_exc))
    ):
        with CancelScope() as cs:
            cs.cancel()
            try:
                raise body_exc
            except BaseException as exc:
                exceptions = [exc]

                try:
                    await checkpoint()
                except BaseException as exc2:
                    exceptions.append(exc2)
                else:
                    pytest.fail("Did not raise a cancellation exception")

                try:
                    raise BaseExceptionGroup("", (BaseExceptionGroup("", exceptions),))
                finally:
                    # Prevent reference cycles.
                    del exceptions

These ^ were minimized from the following less-minimized reproducer:

from anyio import CancelScope
from anyio.abc import AsyncResource
import anyio
import anyio.lowlevel
from types import TracebackType
import logging


class SafeAsyncResource(AsyncResource):
    # This is an AsyncResource that always wraps its exceptions in a group. This
    # prevents exceptions from the body of the `async with` from getting mistakenly
    # swallowed & discarded. See also https://github.com/python-trio/trio/issues/455 /
    # https://github.com/python-trio/trio/issues/1559.

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> None:
        exceptions = list[BaseException]()
        try:
            if exc_val is not None:
                exceptions.append(exc_val)

            # aclose() can raise an arbitrary exception or a cancellation exception,
            # both of which may get caught later. Therefore we must not allow such an
            # exception to eat `exc_val`; we should never swallow `exc_val`. So we raise
            # both.
            try:
                await self.aclose()
            except BaseException as exc:
                exceptions.append(exc)
        finally:
            if exceptions:
                try:
                    raise BaseExceptionGroup(
                        "Exceptions from AsyncResource __aexit__", exceptions
                    )
                finally:
                    del exceptions


class SomeSafeAsyncResource(SafeAsyncResource):
    async def aclose(self) -> None:
        await anyio.lowlevel.checkpoint()


async def main() -> None:
    try:
        with CancelScope() as cs:
            async with SomeSafeAsyncResource():
                cs.cancel()
                raise RuntimeError(
                    "exception raised by user code running under `async with SomeSafeAsyncResource`"
                )
    except* RuntimeError:
        # Swallow it.
        # logging.getLogger(__name__).exception("appropriate message:")
        pass



if __name__ == "__main__":
    anyio.run(main, backend="asyncio")
    # anyio.run(main, backend="trio")
    print("run() exited cleanly")

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