From 0d406a113b799d8f10e8afbbb2860c75ba4b84e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 00:19:36 +0000 Subject: [PATCH 1/3] Keep the generator's Cancelled when as_safe_channel is cancelled When a cancel scope around an `async with` on an @as_safe_channel generator fires while the generator's own code is running, the body and the generator each raise a Cancelled, and the one that propagated was whichever reached the nursery first, which is effectively random. If the generator had already raised and was still awaiting in its cleanup, its Cancelled carries that exception as __context__, so the exception was missing from the traceback about half the time. A plain `async for` over the same generator always raises the generator's Cancelled. Do the same here: remember a Cancelled that comes out of the generator's code, and raise that one when the group holds nothing but Cancelled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VPZoDDtuKy1bbLLR2nsnmW --- newsfragments/XXXX.bugfix.rst | 4 ++++ src/trio/_channel.py | 23 ++++++++++++++++++++++- src/trio/_tests/test_channel.py | 26 ++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 newsfragments/XXXX.bugfix.rst diff --git a/newsfragments/XXXX.bugfix.rst b/newsfragments/XXXX.bugfix.rst new file mode 100644 index 0000000000..edd5efa642 --- /dev/null +++ b/newsfragments/XXXX.bugfix.rst @@ -0,0 +1,4 @@ +When an ``async with`` on an `@as_safe_channel ` generator is cancelled +while the generator's own code is running, the generator's `~trio.Cancelled` now propagates, as +it would from a plain ``async for``. Previously the choice was random, so an exception the +generator raised before its cleanup was cancelled could be missing from the traceback. diff --git a/src/trio/_channel.py b/src/trio/_channel.py index c785434e7a..871d4c04ea 100644 --- a/src/trio/_channel.py +++ b/src/trio/_channel.py @@ -22,6 +22,7 @@ MultipleExceptionError, NoPublicConstructor, final, + raise_saving_context, raise_single_exception_from_group, ) @@ -559,6 +560,7 @@ async def context_manager( *args: P.args, **kwargs: P.kwargs ) -> AsyncGenerator[trio._channel.RecvChanWrapper[T], None]: send_chan, recv_chan = trio.open_memory_channel[T](0) + agen_cancelled: list[trio.Cancelled] = [] try: async with trio.open_nursery(strict_exception_groups=True) as nursery: agen = fn(*args, **kwargs) @@ -567,7 +569,11 @@ async def context_manager( # If this errors we don't close `recv_chan`, but the caller # never gets access to it, so that's not a problem. await nursery.start( - _move_elems_to_channel, agen, send_chan, send_semaphore + _move_elems_to_channel, + agen, + send_chan, + send_semaphore, + agen_cancelled, ) # `async with recv_chan` could eat exceptions, so use sync cm with RecvChanWrapper(recv_chan, send_semaphore) as wrapped_recv_chan: @@ -578,6 +584,13 @@ async def context_manager( "exited trio.as_safe_channel context manager" ) except BaseExceptionGroup as eg: + # If the generator's own code was cancelled and the group holds nothing but + # Cancelled, raise what a plain `async for` would: the generator's Cancelled, + # with its context. (A group raised by the body is not a Cancelled.) + if agen_cancelled and all( + isinstance(e, trio.Cancelled) for e in eg.exceptions + ): + raise_saving_context(agen_cancelled[0]) try: raise_single_exception_from_group(eg) except MultipleExceptionError: @@ -590,11 +603,16 @@ async def context_manager( ) raise eg from None + finally: + # Break the reference cycle: the Cancelled's traceback holds this frame, + # which holds the list. + agen_cancelled.clear() async def _move_elems_to_channel( agen: AsyncGenerator[T, None], send_chan: trio.MemorySendChannel[T], send_semaphore: trio.Semaphore, + agen_cancelled: list[trio.Cancelled], task_status: trio.TaskStatus, ) -> None: # `async with send_chan` will eat exceptions, @@ -615,6 +633,9 @@ async def _move_elems_to_channel( value = await agen.__anext__() except StopAsyncIteration: return + except trio.Cancelled as exc: + agen_cancelled.append(exc) + raise # Send the value to the channel try: await send_chan.send(value) diff --git a/src/trio/_tests/test_channel.py b/src/trio/_tests/test_channel.py index 831f8eb483..2a609393fe 100644 --- a/src/trio/_tests/test_channel.py +++ b/src/trio/_tests/test_channel.py @@ -638,6 +638,32 @@ async def agen(events: list[str]) -> AsyncGenerator[None]: assert events == ["body cancel", "agen cancel"] +async def test_as_safe_channel_cancelled_in_cleanup_keeps_context( + autojump_clock: trio.abc.Clock, +) -> None: + error = ValueError("agen") + + @as_safe_channel + async def agen() -> AsyncGenerator[None]: + try: + yield + raise error + finally: + # The timeout cancels the body and this task. The shield makes this task's + # Cancelled, which carries `error`, reach the nursery after the body's; + # unshielded, the order is random. + with trio.CancelScope(shield=True): + await trio.sleep(2) + await trio.lowlevel.checkpoint() + + with trio.move_on_after(1): + with pytest.raises(trio.Cancelled) as excinfo: + async with agen() as recv_chan: + async for _ in recv_chan: # pragma: no branch + pass + assert excinfo.value.__context__ is error + + async def test_as_safe_channel_genexit_exception_group() -> None: @as_safe_channel async def agen() -> AsyncGenerator[None]: From 7e4c4a8a20e58286c342fc9bfbbf8bc1e52a963e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 00:22:23 +0000 Subject: [PATCH 2/3] Fix raise_single_exception_from_group docstring: it raises MultipleExceptionError Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VPZoDDtuKy1bbLLR2nsnmW --- src/trio/_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trio/_util.py b/src/trio/_util.py index bae6608d88..55cd03a119 100644 --- a/src/trio/_util.py +++ b/src/trio/_util.py @@ -358,7 +358,7 @@ def raise_single_exception_from_group( the cause/context of the group(s). If multiple non-cancelled exceptions are encountered, it raises - :exc:`AssertionError`. + :exc:`MultipleExceptionError`. """ # immediately bail out if there's any KI or SystemExit for e in eg.exceptions: From 61696cc940e5ea5f3bff0879d4bbaa0e102edbaa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 00:38:55 +0000 Subject: [PATCH 3/3] Rename except variable to fix mypy; number the newsfragment `except trio.Cancelled as exc` deletes `exc` at the end of the handler, and the function's later `exc = AssertionError(...)` reuses the name, which mypy rejects. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VPZoDDtuKy1bbLLR2nsnmW --- newsfragments/{XXXX.bugfix.rst => 3554.bugfix.rst} | 0 src/trio/_channel.py | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename newsfragments/{XXXX.bugfix.rst => 3554.bugfix.rst} (100%) diff --git a/newsfragments/XXXX.bugfix.rst b/newsfragments/3554.bugfix.rst similarity index 100% rename from newsfragments/XXXX.bugfix.rst rename to newsfragments/3554.bugfix.rst diff --git a/src/trio/_channel.py b/src/trio/_channel.py index 871d4c04ea..da3c3afbda 100644 --- a/src/trio/_channel.py +++ b/src/trio/_channel.py @@ -633,8 +633,8 @@ async def _move_elems_to_channel( value = await agen.__anext__() except StopAsyncIteration: return - except trio.Cancelled as exc: - agen_cancelled.append(exc) + except trio.Cancelled as cancelled: + agen_cancelled.append(cancelled) raise # Send the value to the channel try: