From f31eed4d931bf2ff62a8df947aca1cc57adb1f78 Mon Sep 17 00:00:00 2001 From: Arham Begani <123586002+Arham-Begani@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:10:55 +0530 Subject: [PATCH 1/2] Preserve nursery cleanup when exception repr raises Guard diagnostic exception formatting in child-task and nursery-body completion. A repr failure must not replace the original exception or interrupt sibling cancellation and nursery bookkeeping. Add deterministic tests for recursive repr and ordinary/control-flow exceptions from repr, with and without nested exception groups, covering both failure origins and completed sibling cleanup. Related to https://github.com/python-trio/trio/issues/3290. This does not change the nursery-depth design discussed in #3289. Prepared with an automated AI coding agent; assistance is disclosed in the issue and pull request. --- newsfragments/3290.bugfix.rst | 3 ++ src/trio/_core/_run.py | 13 ++++- src/trio/_core/_tests/test_cancelled.py | 65 +++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 newsfragments/3290.bugfix.rst diff --git a/newsfragments/3290.bugfix.rst b/newsfragments/3290.bugfix.rst new file mode 100644 index 000000000..9d0fd68bd --- /dev/null +++ b/newsfragments/3290.bugfix.rst @@ -0,0 +1,3 @@ +A failing exception ``__repr__`` no longer interrupts nursery bookkeeping while +constructing cancellation diagnostics. Cancellation reasons use a placeholder +when representing the exception raises an error, including ``RecursionError``. diff --git a/src/trio/_core/_run.py b/src/trio/_core/_run.py index ddd4ea7ee..76980c197 100644 --- a/src/trio/_core/_run.py +++ b/src/trio/_core/_run.py @@ -319,6 +319,15 @@ def expire(self, now: float) -> bool: return did_something +def _safe_exception_repr(exc: BaseException) -> str: + try: + return repr(exc) + except BaseException: + # Formatting diagnostics must not interfere with nursery cleanup, even if + # a user-defined __repr__ raises KeyboardInterrupt or SystemExit. + return "" + + @attrs.define class CancelReason: """Attached to a :class:`CancelScope` upon cancellation with details of the source of the @@ -1265,7 +1274,7 @@ def _child_finished( CancelReason( source="nursery", source_task=repr(task), - reason=f"child task raised exception {outcome.error!r}", + reason=f"child task raised exception {_safe_exception_repr(outcome.error)}", ), ) self._check_nursery_closed() @@ -1282,7 +1291,7 @@ async def _nested_child_finished( reason=CancelReason( source="nursery", source_task=repr(self._parent_task), - reason=f"Code block inside nursery contextmanager raised exception {nested_child_exc!r}", + reason=f"Code block inside nursery contextmanager raised exception {_safe_exception_repr(nested_child_exc)}", ), ) self._nested_child_running = False diff --git a/src/trio/_core/_tests/test_cancelled.py b/src/trio/_core/_tests/test_cancelled.py index 0280b6068..55ef6083e 100644 --- a/src/trio/_core/_tests/test_cancelled.py +++ b/src/trio/_core/_tests/test_cancelled.py @@ -1,5 +1,6 @@ import pickle import re +import sys from math import inf import pytest @@ -11,6 +12,9 @@ from .test_ki import ki_self +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + def test_Cancelled_init() -> None: with pytest.raises(TypeError, match=r"^trio.Cancelled has no public constructor$"): @@ -154,6 +158,67 @@ async def cancelled_task() -> None: await nursery.start(failing_task) +@pytest.mark.parametrize("from_child", [False, True]) +@pytest.mark.parametrize("grouped", [False, True]) +@pytest.mark.parametrize( + "repr_error", [ValueError, RecursionError, KeyboardInterrupt, SystemExit] +) +async def test_nursery_exception_repr_failure( + from_child: bool, + grouped: bool, + repr_error: type[BaseException], +) -> None: + class UnprintableError(Exception): + def __repr__(self) -> str: + if repr_error is RecursionError: + return repr(self) + raise repr_error + + original = UnprintableError() + error: Exception = original + if grouped: + for _ in range(20): + error = ExceptionGroup("nested", [error]) + + reasons: list[str | None] = [] + sources: list[str] = [] + finished: list[bool] = [] + + async def sibling() -> None: + try: + await trio.sleep_forever() + except Cancelled as exc: + reasons.append(exc.reason) + sources.append(exc.source) + raise + finally: + finished.append(True) + + async def failing_task() -> None: + raise error + + with pytest.raises(ExceptionGroup) as excinfo: + async with trio.open_nursery() as nursery: + nursery.start_soon(sibling) + await wait_all_tasks_blocked() + if from_child: + nursery.start_soon(failing_task) + else: + raise error + + propagated: BaseException = excinfo.value + while isinstance(propagated, ExceptionGroup): + assert len(propagated.exceptions) == 1 + propagated = propagated.exceptions[0] + assert propagated is original + assert finished == [True] + assert sources == ["nursery"] + prefix = "child task" if from_child else "Code block inside nursery contextmanager" + assert reasons == [f"{prefix} raised exception "] + # The nursery has finished unwinding; cancellation must not leak into its parent. + await trio.lowlevel.checkpoint() + + async def test_cancel_reason_not_overwritten() -> None: with trio.CancelScope() as cs: cs.cancel() From 78160cec3ac9a142b3fa7b81ab5014b1bb236d40 Mon Sep 17 00:00:00 2001 From: Arham Begani <123586002+Arham-Begani@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:39:47 +0530 Subject: [PATCH 2/2] Isolate real repr recursion from PyPy coverage tracing Forcing a recursion overflow inside the main PyPy test process disables its coverage tracer for later tests. Keep the in-process failure matrix deterministic by raising RecursionError directly, and exercise genuine recursive repr in a subprocess that verifies original-error propagation. The subprocess still runs on PyPy and all other CI interpreters. Mark it slow for interpreter startup, following the contribution guide. This follow-up was prepared with automated AI assistance after reviewing the initial upstream CI and base-versus-patch comparison logs. --- src/trio/_core/_tests/test_cancelled.py | 48 +++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/trio/_core/_tests/test_cancelled.py b/src/trio/_core/_tests/test_cancelled.py index 55ef6083e..5af9f9ff9 100644 --- a/src/trio/_core/_tests/test_cancelled.py +++ b/src/trio/_core/_tests/test_cancelled.py @@ -1,6 +1,8 @@ import pickle import re +import subprocess import sys +import textwrap from math import inf import pytest @@ -11,6 +13,7 @@ from trio.testing import wait_all_tasks_blocked from .test_ki import ki_self +from .tutil import slow if sys.version_info < (3, 11): from exceptiongroup import ExceptionGroup @@ -170,8 +173,6 @@ async def test_nursery_exception_repr_failure( ) -> None: class UnprintableError(Exception): def __repr__(self) -> str: - if repr_error is RecursionError: - return repr(self) raise repr_error original = UnprintableError() @@ -219,6 +220,49 @@ async def failing_task() -> None: await trio.lowlevel.checkpoint() +@slow +def test_nursery_exception_repr_recursion() -> None: + # A real recursion overflow disables coverage tracing on PyPy. Isolate it so + # coverage can still measure the remaining tests in the parent process. + code = textwrap.dedent( + """ + import sys + import trio + + if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + + class RecursiveError(Exception): + def __repr__(self): + return repr(self) + + original = RecursiveError() + + async def failing_task(): + raise original + + async def main(): + try: + async with trio.open_nursery() as nursery: + nursery.start_soon(failing_task) + except ExceptionGroup as exc: + assert len(exc.exceptions) == 1 + assert exc.exceptions[0] is original + else: + raise AssertionError("original exception was lost") + + trio.run(main) + """, + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + + async def test_cancel_reason_not_overwritten() -> None: with trio.CancelScope() as cs: cs.cancel()