Repository: agronholm/anyio

Issue:
Re-raising a CancelledError after catching it in an ExceptionGroup confuses asyncio backend 
### Things to check first

- [X] I have searched the existing issues and didn't find my bug already reported there

- [X] I have checked that my bug is still present in the latest release


### AnyIO version

4.0.0

### Python version

3.11

### What happened?

If you catch a CancelledError as part of an ExceptionGroup and then re-raise it when using the asyncio backend, the surrounding CacnelScope won't recognize it as a cancellation exception anymore. The end result, is that instead of cancellation working as expected and the exception being swallowed by the CancelScope, its instead re-raised.

This issue only occurs when using the asyncio backend. It does not occur on the trio backend.

I _think_ the issue is that trio internally will unwrap cancellation exception inside of Exception Groups: https://github.com/python-trio/trio/blob/d5c0fb83bba6b56c35956c65092411cc9a2be8c3/src/trio/_core/_run.py#L595-L604. However, I don't _think_ the asyncio backend in anyio does the same thing.

### How can we reproduce the bug?

Run the following code, which exits with an exception. Change the backend to "trio" and it exits without an exception.

```python
import anyio


async def main():
    async with anyio.create_task_group() as tg:
        tg.cancel_scope.cancel()
        try:
            await anyio.sleep_forever()
        except* anyio.get_cancelled_exc_class():
            raise


if __name__ == "__main__":
    anyio.run(main, backend="asyncio")
```

Discussion:
agronholm: @dagenix I'm only unwrapping one layer of exception groups; do you think it's necessary to unwrap them all?
agronholm: I replaced some dead code with a helper function that should cover all our bases.

Patch:
diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst
index 020c473..b7432df 100644
--- a/docs/versionhistory.rst
+++ b/docs/versionhistory.rst
@@ -14,6 +14,8 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.
 - Exposed the ``ResourceGuard`` class in the public API
 - Fixed ``RuntimeError: Runner is closed`` when running higher-scoped async generator
   fixtures in some cases (`#619 <https://github.com/agronholm/anyio/issues/619>`_)
+- Fixed discrepancy between ``asyncio`` and ``trio`` where reraising a cancellation
+  exception in an ``except*`` block would incorrectly bubble out of its cancel scope
 
 **4.0.0**
 
diff --git a/pyproject.toml b/pyproject.toml
index 66ad7d1..5c4af55 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,7 @@ trio = ["trio >= 0.23"]
 test = [
     "anyio[trio]",
     "coverage[toml] >= 7",
+    "exceptiongroup >= 1.2.0",
     "hypothesis >= 4.0",
     "psutil >= 5.9",
     "pytest >= 7.0",
diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py
index b27a538..a3eb647 100644
--- a/src/anyio/_backends/_asyncio.py
+++ b/src/anyio/_backends/_asyncio.py
@@ -19,7 +19,7 @@ from asyncio import (
 from asyncio import run as native_run
 from asyncio.base_events import _run_until_complete_cb  # type: ignore[attr-defined]
 from collections import OrderedDict, deque
-from collections.abc import AsyncIterator, Iterable
+from collections.abc import AsyncIterator, Generator, Iterable
 from concurrent.futures import Future
 from contextlib import suppress
 from contextvars import Context, copy_context
@@ -418,8 +418,13 @@ class CancelScope(BaseCancelScope):
         if self._shield:
             self._deliver_cancellation_to_parent()
 
-        if isinstance(exc_val, CancelledError) and self._cancel_called:
-            self._cancelled_caught = self._uncancel(exc_val)
+        if self._cancel_called and exc_val is not None:
+            for exc in iterate_exceptions(exc_val):
+                if isinstance(exc, CancelledError):
+                    self._cancelled_caught = self._uncancel(exc)
+                    if self._cancelled_caught:
+                        break
+
             return self._cancelled_caught
 
         return None
@@ -604,22 +609,14 @@ class _AsyncioTaskStatus(abc.TaskStatus):
         _task_states[task].parent_id = self._parent_id
 
 
-def collapse_exception_group(excgroup: BaseExceptionGroup) -> BaseException:
-    exceptions = list(excgroup.exceptions)
-    modified = False
-    for i, exc in enumerate(exceptions):
-        if isinstance(exc, BaseExceptionGroup):
-            new_exc = collapse_exception_group(exc)
-            if new_exc is not exc:
-                modified = True
-                exceptions[i] = new_exc
-
-    if len(exceptions) == 1:
-        return exceptions[0]
-    elif modified:
-        return excgroup.derive(exceptions)
+def iterate_exceptions(
+    exception: BaseException
+) -> Generator[BaseException, None, None]:
+    if isinstance(exception, BaseExceptionGroup):
+        for exc in exception.exceptions:
+            yield from iterate_exceptions(exc)
     else:
-        return excgroup
+        yield exception
 
 
 class TaskGroup(abc.TaskGroup):


Test patch:
diff --git a/tests/test_taskgroups.py b/tests/test_taskgroups.py
index bc22760..221f00f 100644
--- a/tests/test_taskgroups.py
+++ b/tests/test_taskgroups.py
@@ -8,6 +8,7 @@ from collections.abc import AsyncGenerator, Coroutine, Generator
 from typing import Any, NoReturn, cast
 
 import pytest
+from exceptiongroup import catch
 
 import anyio
 from anyio import (
@@ -1282,6 +1283,16 @@ async def test_cancel_before_entering_task_group() -> None:
             pytest.fail("This should not raise a cancellation exception")
 
 
+async def test_reraise_cancelled_in_excgroup() -> None:
+    def handler(excgrp: BaseExceptionGroup) -> None:
+        raise
+
+    with CancelScope() as scope:
+        scope.cancel()
+        with catch({get_cancelled_exc_class(): handler}):
+            await anyio.sleep_forever()
+
+
 class TestTaskStatusTyping:
     """
     These tests do not do anything at run time, but since the test suite is also checked