Repository: agronholm/anyio

Issue:
Quadratic traceback-growth in TaskGroup nesting-level on 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.8.0

### Python version

3.11

### What happened?

Since python implicitly adds a context linking the current Exception to the new one when raising while exiting a context manager due to an unhandled Exception and task groups always wrap errors in their own `BaseExceptionGroup` the resulting traceback grows quadratically with the level of nested task groups due to the "During handling of the above..."


This seems to be straightforward to fix by simply explicitly raising from None in https://github.com/agronholm/anyio/blob/897d69aca35ff38ad230ee65f63186e420677f25/src/anyio/_backends/_asyncio.py#L767-L769

It looks like we already have added the exception to the group so breaking the context should be ok without loss of information.

### How can we reproduce the bug?

Simple example with 5 levels
```python
import anyio

async def main():
    async with(
        anyio.create_task_group(),
        anyio.create_task_group(),
        anyio.create_task_group(),
        anyio.create_task_group(),
        anyio.create_task_group(),
    ):
        raise Exception("Something went wrong")

if __name__ == "__main__":
    anyio.run(main)
```

Results in 
```
Traceback (most recent call last):
  File "/home/tobias/Projects/anyio/test_tb.py", line 12, in main
    raise Exception("Something went wrong")
Exception: Something went wrong

During handling of the above exception, another exception occurred:

  + Exception Group Traceback (most recent call last):
  |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
  |     async with(
  |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
  |     raise BaseExceptionGroup(
  | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "/home/tobias/Projects/anyio/test_tb.py", line 12, in main
    |     raise Exception("Something went wrong")
    | Exception: Something went wrong
    +------------------------------------

During handling of the above exception, another exception occurred:

  + Exception Group Traceback (most recent call last):
  |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
  |     async with(
  |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
  |     raise BaseExceptionGroup(
  | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
    |     async with(
    |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
    |     raise BaseExceptionGroup(
    | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
    +-+---------------- 1 ----------------
      | Traceback (most recent call last):
      |   File "/home/tobias/Projects/anyio/test_tb.py", line 12, in main
      |     raise Exception("Something went wrong")
      | Exception: Something went wrong
      +------------------------------------

During handling of the above exception, another exception occurred:

  + Exception Group Traceback (most recent call last):
  |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
  |     async with(
  |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
  |     raise BaseExceptionGroup(
  | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
    |     async with(
    |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
    |     raise BaseExceptionGroup(
    | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
    +-+---------------- 1 ----------------
      | Exception Group Traceback (most recent call last):
      |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
      |     async with(
      |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
      |     raise BaseExceptionGroup(
      | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
      +-+---------------- 1 ----------------
        | Traceback (most recent call last):
        |   File "/home/tobias/Projects/anyio/test_tb.py", line 12, in main
        |     raise Exception("Something went wrong")
        | Exception: Something went wrong
        +------------------------------------

During handling of the above exception, another exception occurred:

  + Exception Group Traceback (most recent call last):
  |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
  |     async with(
  |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
  |     raise BaseExceptionGroup(
  | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
    |     async with(
    |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
    |     raise BaseExceptionGroup(
    | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
    +-+---------------- 1 ----------------
      | Exception Group Traceback (most recent call last):
      |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
      |     async with(
      |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
      |     raise BaseExceptionGroup(
      | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
      +-+---------------- 1 ----------------
        | Exception Group Traceback (most recent call last):
        |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
        |     async with(
        |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
        |     raise BaseExceptionGroup(
        | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
        +-+---------------- 1 ----------------
          | Traceback (most recent call last):
          |   File "/home/tobias/Projects/anyio/test_tb.py", line 12, in main
          |     raise Exception("Something went wrong")
          | Exception: Something went wrong
          +------------------------------------

During handling of the above exception, another exception occurred:

  + Exception Group Traceback (most recent call last):
  |   File "/home/tobias/Projects/anyio/test_tb.py", line 15, in <module>
  |     anyio.run(main)
  |   File "/home/tobias/Projects/anyio/src/anyio/_core/_eventloop.py", line 74, in run
  |     return async_backend.run(func, args, {}, backend_options)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 2306, in run
  |     return runner.run(wrapper())
  |            ^^^^^^^^^^^^^^^^^^^^^
  |   File "/usr/lib/python3.11/asyncio/runners.py", line 118, in run
  |     return self._loop.run_until_complete(task)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/usr/lib/python3.11/asyncio/base_events.py", line 654, in run_until_complete
  |     return future.result()
  |            ^^^^^^^^^^^^^^^
  |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 2294, in wrapper
  |     return await func(*args)
  |            ^^^^^^^^^^^^^^^^^
  |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
  |     async with(
  |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
  |     raise BaseExceptionGroup(
  | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
    |     async with(
    |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
    |     raise BaseExceptionGroup(
    | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
    +-+---------------- 1 ----------------
      | Exception Group Traceback (most recent call last):
      |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
      |     async with(
      |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
      |     raise BaseExceptionGroup(
      | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
      +-+---------------- 1 ----------------
        | Exception Group Traceback (most recent call last):
        |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
        |     async with(
        |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
        |     raise BaseExceptionGroup(
        | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
        +-+---------------- 1 ----------------
          | Exception Group Traceback (most recent call last):
          |   File "/home/tobias/Projects/anyio/test_tb.py", line 5, in main
          |     async with(
          |   File "/home/tobias/Projects/anyio/src/anyio/_backends/_asyncio.py", line 771, in __aexit__
          |     raise BaseExceptionGroup(
          | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
          +-+---------------- 1 ----------------
            | Traceback (most recent call last):
            |   File "/home/tobias/Projects/anyio/test_tb.py", line 12, in main
            |     raise Exception("Something went wrong")
            | Exception: Something went wrong
            +------------------------------------
```


Discussion:
agronholm: `asyncio.TaskGroup` seems to do the same. @graingert I take it there's no reason for us not to do this too?
graingert: > `asyncio.TaskGroup` seems to do the same. @graingert I take it there's no reason for us not to do this too?

Yep makes sense here too.

What does trio do?
tapetersen: 
> It's not clear to me: https://github.com/python-trio/trio/blob/d7cb2fc3f96c57074678f3024c926fb4d2a99b6f/src/trio/_core/_run.py#L1046-L1066 Reading Trio code always makes my head hurt.

Hmm it looks like it's avoiding this problem by patching up the exception with the context it had before it was re-raised, effectively removing the context the `__aexit__` re-raise would've added.

I'm not sure about the `sys.exc_info` part in the finally if it just modified  the `combined_error_from_nursery` object anyway (as noted by the assert) unless the `sys.exc_info()` ball by itself (as opposed to the `raise`) is what triggers the modification of `__context__`


agronholm: Whoops, I accidentally clicked on the "Ready for review" button.
agronholm: Could you add a changelog entry? That should be the only thing missing.

Patch:
diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst
index 5c75f28..afb390c 100644
--- a/docs/versionhistory.rst
+++ b/docs/versionhistory.rst
@@ -3,6 +3,13 @@ Version history
 
 This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.
 
+ **UNRELEASED**
+
+- Fixed traceback formatting growing quadratically with level of ``TaskGroup``
+  nesting on asyncio due to exception chaining when raising ``ExceptionGroups``
+  in ``TaskGroup.__aexit__``
+  (`#863 <https://github.com/agronholm/anyio/issues/863>`_; PR by @tapetersen)
+
 **4.8.0**
 
 - Added **experimental** support for running functions in subinterpreters on Python
diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py
index ce8e52a..f199330 100644
--- a/src/anyio/_backends/_asyncio.py
+++ b/src/anyio/_backends/_asyncio.py
@@ -764,9 +764,13 @@ class TaskGroup(abc.TaskGroup):
 
                 self._active = False
                 if self._exceptions:
+                    # The exception that got us here should already have been
+                    # added to self._exceptions so it's ok to break exception
+                    # chaining and avoid adding a "During handling of above..."
+                    # for each nesting level.
                     raise BaseExceptionGroup(
                         "unhandled errors in a TaskGroup", self._exceptions
-                    )
+                    ) from None
                 elif exc_val:
                     raise exc_val
             except BaseException as exc:


Test patch:
diff --git a/tests/test_taskgroups.py b/tests/test_taskgroups.py
index 52f9660..7fe1322 100644
--- a/tests/test_taskgroups.py
+++ b/tests/test_taskgroups.py
@@ -1837,3 +1837,13 @@ async def test_patched_asyncio_task(monkeypatch: MonkeyPatch) -> None:
     )
     async with create_task_group() as tg:
         tg.start_soon(sleep, 0)
+
+
+async def test_exception_groups_suppresses_exc_context() -> None:
+    with pytest.raises(
+        cast(type[ExceptionGroup[Exception]], ExceptionGroup)
+    ) as exc_info:
+        async with create_task_group():
+            raise Exception("Error")
+
+    assert exc_info.value.__suppress_context__