From 6e4735a18da94aaa9856048697fce0314c66bcab Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Wed, 23 Jul 2025 15:54:04 +0200
Subject: [PATCH 1/7] check for nursery misnesting on task exit ... not working
---
src/trio/_core/_run.py | 2 +-
src/trio/_core/_tests/test_run.py | 50 +++++++++++++++++++++++++++++--
test-requirements.in | 2 +-
3 files changed, 50 insertions(+), 4 deletions(-)
diff --git a/src/trio/_core/_run.py b/src/trio/_core/_run.py
index aee025cb7a..bc4ff99606 100644
--- a/src/trio/_core/_run.py
+++ b/src/trio/_core/_run.py
@@ -2017,7 +2017,7 @@ def task_exited(self, task: Task, outcome: Outcome[object]) -> None:
task._cancel_status is not None
and task._cancel_status.abandoned_by_misnesting
and task._cancel_status.parent is None
- ):
+ ) or any(not nursery._closed for nursery in task._child_nurseries):
# The cancel scope surrounding this task's nursery was closed
# before the task exited. Force the task to exit with an error,
# since the error might not have been caught elsewhere. See the
diff --git a/src/trio/_core/_tests/test_run.py b/src/trio/_core/_tests/test_run.py
index b317c8d6c9..fe2334cecb 100644
--- a/src/trio/_core/_tests/test_run.py
+++ b/src/trio/_core/_tests/test_run.py
@@ -8,7 +8,13 @@
import time
import types
import weakref
-from contextlib import ExitStack, contextmanager, suppress
+from contextlib import (
+ AsyncExitStack,
+ ExitStack,
+ asynccontextmanager,
+ contextmanager,
+ suppress,
+)
from math import inf, nan
from typing import TYPE_CHECKING, NoReturn, TypeVar
from unittest import mock
@@ -761,7 +767,7 @@ async def enter_scope() -> None:
assert scope.cancel_called # never become un-cancelled
-async def test_cancel_scope_misnesting() -> None:
+async def test_cancel_scope_misnesting_1() -> None:
outer = _core.CancelScope()
inner = _core.CancelScope()
with ExitStack() as stack:
@@ -771,6 +777,8 @@ async def test_cancel_scope_misnesting() -> None:
stack.close()
# No further error is raised when exiting the inner context
+
+async def test_cancel_scope_misnesting_2() -> None:
# If there are other tasks inside the abandoned part of the cancel tree,
# they get cancelled when the misnesting is detected
async def task1() -> None:
@@ -828,6 +836,8 @@ def no_context(exc: RuntimeError) -> bool:
)
assert group.matches(exc_info.value.__context__)
+
+async def test_cancel_scope_misnesting_3() -> None:
# Trying to exit a cancel scope from an unrelated task raises an error
# without affecting any state
async def task3(task_status: _core.TaskStatus[_core.CancelScope]) -> None:
@@ -844,6 +854,42 @@ async def task3(task_status: _core.TaskStatus[_core.CancelScope]) -> None:
scope.cancel()
+async def test_nursery_misnest() -> None:
+ # See https://github.com/python-trio/trio/issues/3298
+ async def inner_func() -> None:
+ inner_nursery = await inner_cm.__aenter__()
+ inner_nursery.start_soon(sleep, 1)
+
+ with pytest.RaisesGroup(
+ pytest.RaisesExc(RuntimeError, match="Cancel scope stack corrupted")
+ ):
+ async with _core.open_nursery() as outer_nursery:
+ inner_cm = _core.open_nursery()
+ outer_nursery.start_soon(inner_func)
+
+
+async def test_asyncexitstack_nursery_misnest() -> None:
+ @asynccontextmanager
+ async def asynccontextmanager_that_creates_a_nursery_internally() -> (
+ AsyncGenerator[None]
+ ):
+ async with _core.open_nursery() as nursery:
+ nursery.start_soon(
+ print_sleep_print, "task_in_asynccontextmanager_nursery", 2.0
+ )
+ yield
+
+ async def print_sleep_print(name: str, sleep_time: float) -> None:
+ await sleep(sleep_time)
+
+ async with AsyncExitStack() as stack, _core.open_nursery() as nursery:
+ # The asynccontextmanager is going to create a nursery that outlives this nursery!
+ nursery.start_soon(
+ stack.enter_async_context,
+ asynccontextmanager_that_creates_a_nursery_internally(),
+ )
+
+
@slow
async def test_timekeeping() -> None:
# probably a good idea to use a real clock for *one* test anyway...
diff --git a/test-requirements.in b/test-requirements.in
index fd16b2d3bc..5b212ed951 100644
--- a/test-requirements.in
+++ b/test-requirements.in
@@ -1,5 +1,5 @@
# For tests
-pytest >= 5.0 # for faulthandler in core
+pytest >= 8.4 # for pytest.RaisesGroup
coverage >= 7.2.5
async_generator >= 1.9
pyright
From cb1aafcb5a7bc1e72c89b436862d683f2281f30a Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Tue, 29 Jul 2025 15:51:44 +0200
Subject: [PATCH 2/7] forcefully abort children of unclosed nurseries to avoid
internalerror
---
src/trio/_core/_run.py | 26 ++++++++++++++++++++++++--
src/trio/_core/_tests/test_run.py | 31 ++++++++++++++++++++++++-------
2 files changed, 48 insertions(+), 9 deletions(-)
diff --git a/src/trio/_core/_run.py b/src/trio/_core/_run.py
index bc4ff99606..8b9c5cd18e 100644
--- a/src/trio/_core/_run.py
+++ b/src/trio/_core/_run.py
@@ -2013,11 +2013,33 @@ def task_exited(self, task: Task, outcome: Outcome[object]) -> None:
lot.break_lot(task)
del GLOBAL_PARKING_LOT_BREAKER[task]
- if (
+ if task._child_nurseries:
+ # Forcefully abort any tasks spawned by the misnested nursery to
+ # avoid internal errors.
+ runner = GLOBAL_RUN_CONTEXT.runner
+ for nursery in task._child_nurseries:
+ nursery.cancel_scope.cancel()
+ for child in nursery._children:
+ if child in runner.runq:
+ runner.runq.remove(child)
+ runner.tasks.remove(child)
+ nursery._children.clear()
+ try:
+ # Raise this, rather than just constructing it, to get a
+ # traceback frame included
+ raise RuntimeError(
+ "Nursery stack corrupted: nurseries spawned by "
+ f"{task!r} was still live when the task exited\n{MISNESTING_ADVICE}",
+ )
+ except RuntimeError as new_exc:
+ if isinstance(outcome, Error):
+ new_exc.__context__ = outcome.error
+ outcome = Error(new_exc)
+ elif (
task._cancel_status is not None
and task._cancel_status.abandoned_by_misnesting
and task._cancel_status.parent is None
- ) or any(not nursery._closed for nursery in task._child_nurseries):
+ ):
# The cancel scope surrounding this task's nursery was closed
# before the task exited. Force the task to exit with an error,
# since the error might not have been caught elsewhere. See the
diff --git a/src/trio/_core/_tests/test_run.py b/src/trio/_core/_tests/test_run.py
index fe2334cecb..b23ec7bd20 100644
--- a/src/trio/_core/_tests/test_run.py
+++ b/src/trio/_core/_tests/test_run.py
@@ -861,7 +861,7 @@ async def inner_func() -> None:
inner_nursery.start_soon(sleep, 1)
with pytest.RaisesGroup(
- pytest.RaisesExc(RuntimeError, match="Cancel scope stack corrupted")
+ pytest.RaisesExc(RuntimeError, match="Nursery stack corrupted")
):
async with _core.open_nursery() as outer_nursery:
inner_cm = _core.open_nursery()
@@ -874,20 +874,37 @@ async def asynccontextmanager_that_creates_a_nursery_internally() -> (
AsyncGenerator[None]
):
async with _core.open_nursery() as nursery:
- nursery.start_soon(
- print_sleep_print, "task_in_asynccontextmanager_nursery", 2.0
- )
+ await nursery.start(started_sleeper)
+ nursery.start_soon(unstarted_task)
yield
- async def print_sleep_print(name: str, sleep_time: float) -> None:
- await sleep(sleep_time)
+ async def started_sleeper(task_status: _core.TaskStatus[None]) -> None:
+ task_status.started()
+ await sleep_forever()
- async with AsyncExitStack() as stack, _core.open_nursery() as nursery:
+ async def unstarted_task() -> None:
+ raise AssertionError("this should not even get a chance to run")
+
+ async with AsyncExitStack() as stack:
+ manager = _core.open_nursery()
+ nursery = await manager.__aenter__()
# The asynccontextmanager is going to create a nursery that outlives this nursery!
nursery.start_soon(
stack.enter_async_context,
asynccontextmanager_that_creates_a_nursery_internally(),
)
+ with pytest.RaisesGroup(
+ pytest.RaisesExc(RuntimeError, match="Nursery stack corrupted")
+ ):
+ await manager.__aexit__(None, None, None)
+
+ # The outer nursery forcefully aborts the inner nursery and stops `unstarted_task`
+ # from ever being started.
+ with pytest.warns(
+ RuntimeWarning,
+ match="^coroutine 'test_asyncexitstack_nursery_misnest..unstarted_task' was never awaited$",
+ ):
+ gc_collect_harder()
@slow
From b33194d880e9a0ffb103bc7fd39ed23110ccb056 Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Tue, 29 Jul 2025 16:12:15 +0200
Subject: [PATCH 3/7] call Runner.task_exited on aborted tasks to get errors
for aborted tasks and handle nested scenarios (needs test)
---
src/trio/_core/_run.py | 14 ++++++++++----
src/trio/_core/_tests/test_run.py | 32 +++++++++++++++++++------------
2 files changed, 30 insertions(+), 16 deletions(-)
diff --git a/src/trio/_core/_run.py b/src/trio/_core/_run.py
index 8b9c5cd18e..0fe7876123 100644
--- a/src/trio/_core/_run.py
+++ b/src/trio/_core/_run.py
@@ -2018,12 +2018,18 @@ def task_exited(self, task: Task, outcome: Outcome[object]) -> None:
# avoid internal errors.
runner = GLOBAL_RUN_CONTEXT.runner
for nursery in task._child_nurseries:
- nursery.cancel_scope.cancel()
- for child in nursery._children:
+ for child in nursery._children.copy():
if child in runner.runq:
runner.runq.remove(child)
- runner.tasks.remove(child)
- nursery._children.clear()
+ self.task_exited(
+ child,
+ Error(
+ RuntimeError(
+ f"Task {child} aborted after nursery was destroyed due to misnesting."
+ )
+ ),
+ )
+ assert not nursery._children
try:
# Raise this, rather than just constructing it, to get a
# traceback frame included
diff --git a/src/trio/_core/_tests/test_run.py b/src/trio/_core/_tests/test_run.py
index b23ec7bd20..a252768de9 100644
--- a/src/trio/_core/_tests/test_run.py
+++ b/src/trio/_core/_tests/test_run.py
@@ -885,24 +885,32 @@ async def started_sleeper(task_status: _core.TaskStatus[None]) -> None:
async def unstarted_task() -> None:
raise AssertionError("this should not even get a chance to run")
- async with AsyncExitStack() as stack:
- manager = _core.open_nursery()
- nursery = await manager.__aenter__()
- # The asynccontextmanager is going to create a nursery that outlives this nursery!
- nursery.start_soon(
- stack.enter_async_context,
- asynccontextmanager_that_creates_a_nursery_internally(),
- )
- with pytest.RaisesGroup(
+ with pytest.RaisesGroup(
+ pytest.RaisesExc(
+ RuntimeError,
+ match="Task .*unstarted_task.* aborted after nursery was destroyed due to misnesting.",
+ ),
+ pytest.RaisesExc(
+ RuntimeError,
+ match="Task .*started_sleeper.* aborted after nursery was destroyed due to misnesting.",
+ ),
+ pytest.RaisesGroup(
pytest.RaisesExc(RuntimeError, match="Nursery stack corrupted")
- ):
- await manager.__aexit__(None, None, None)
+ ),
+ ):
+ async with AsyncExitStack() as stack, _core.open_nursery() as nursery:
+ # The asynccontextmanager is going to create a nursery that outlives this nursery!
+ nursery.start_soon(
+ stack.enter_async_context,
+ asynccontextmanager_that_creates_a_nursery_internally(),
+ )
# The outer nursery forcefully aborts the inner nursery and stops `unstarted_task`
# from ever being started.
+ # `started_sleeper` is awaited, but not the internal `sleep`
with pytest.warns(
RuntimeWarning,
- match="^coroutine 'test_asyncexitstack_nursery_misnest..unstarted_task' was never awaited$",
+ match="^coroutine '(test_asyncexitstack_nursery_misnest..unstarted_task|sleep)' was never awaited$",
):
gc_collect_harder()
From 06640eb7288b0dc2bb3b81484daa5aebde937b67 Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Wed, 30 Jul 2025 15:33:33 +0200
Subject: [PATCH 4/7] new approach
---
src/trio/_core/_run.py | 44 +++++++++----------------------
src/trio/_core/_tests/test_run.py | 32 ++++++++++------------
2 files changed, 27 insertions(+), 49 deletions(-)
diff --git a/src/trio/_core/_run.py b/src/trio/_core/_run.py
index 0fe7876123..d32a20f8c6 100644
--- a/src/trio/_core/_run.py
+++ b/src/trio/_core/_run.py
@@ -1261,6 +1261,9 @@ def _child_finished(
outcome: Outcome[object],
) -> None:
self._children.remove(task)
+ if self._closed and not hasattr(self, "_pending_excs"):
+ # We're abandoned by misnested nurseries, the result of the task is lost.
+ return
if isinstance(outcome, Error):
self._add_exc(
outcome.error,
@@ -2007,45 +2010,24 @@ async def python_wrapper(orig_coro: Awaitable[RetT]) -> RetT:
return task
def task_exited(self, task: Task, outcome: Outcome[object]) -> None:
+ if task._child_nurseries:
+ for nursery in task._child_nurseries:
+ nursery.cancel_scope.cancel() # TODO: add reason
+ nursery._parent_waiting_in_aexit = False
+ nursery._closed = True
+
# break parking lots associated with the exiting task
if task in GLOBAL_PARKING_LOT_BREAKER:
for lot in GLOBAL_PARKING_LOT_BREAKER[task]:
lot.break_lot(task)
del GLOBAL_PARKING_LOT_BREAKER[task]
- if task._child_nurseries:
- # Forcefully abort any tasks spawned by the misnested nursery to
- # avoid internal errors.
- runner = GLOBAL_RUN_CONTEXT.runner
- for nursery in task._child_nurseries:
- for child in nursery._children.copy():
- if child in runner.runq:
- runner.runq.remove(child)
- self.task_exited(
- child,
- Error(
- RuntimeError(
- f"Task {child} aborted after nursery was destroyed due to misnesting."
- )
- ),
- )
- assert not nursery._children
- try:
- # Raise this, rather than just constructing it, to get a
- # traceback frame included
- raise RuntimeError(
- "Nursery stack corrupted: nurseries spawned by "
- f"{task!r} was still live when the task exited\n{MISNESTING_ADVICE}",
- )
- except RuntimeError as new_exc:
- if isinstance(outcome, Error):
- new_exc.__context__ = outcome.error
- outcome = Error(new_exc)
- elif (
+ if (
task._cancel_status is not None
and task._cancel_status.abandoned_by_misnesting
and task._cancel_status.parent is None
- ):
+ ) or task._child_nurseries:
+ reason = "Nursery" if task._child_nurseries else "Cancel scope"
# The cancel scope surrounding this task's nursery was closed
# before the task exited. Force the task to exit with an error,
# since the error might not have been caught elsewhere. See the
@@ -2054,7 +2036,7 @@ def task_exited(self, task: Task, outcome: Outcome[object]) -> None:
# Raise this, rather than just constructing it, to get a
# traceback frame included
raise RuntimeError(
- "Cancel scope stack corrupted: cancel scope surrounding "
+ f"{reason} stack corrupted: {reason} surrounding "
f"{task!r} was closed before the task exited\n{MISNESTING_ADVICE}",
)
except RuntimeError as new_exc:
diff --git a/src/trio/_core/_tests/test_run.py b/src/trio/_core/_tests/test_run.py
index a252768de9..657544bf94 100644
--- a/src/trio/_core/_tests/test_run.py
+++ b/src/trio/_core/_tests/test_run.py
@@ -868,6 +868,17 @@ async def inner_func() -> None:
outer_nursery.start_soon(inner_func)
+def test_nursery_nested_child_misnest() -> None:
+ # TODO: check context as well for the AssertionError (that will be a RuntimeError)
+ async def main() -> None:
+ async with _core.open_nursery():
+ inner_cm = _core.open_nursery()
+ await inner_cm.__aenter__()
+
+ with pytest.raises(RuntimeError, match="Nursery stack corrupted"):
+ _core.run(main)
+
+
async def test_asyncexitstack_nursery_misnest() -> None:
@asynccontextmanager
async def asynccontextmanager_that_creates_a_nursery_internally() -> (
@@ -883,17 +894,11 @@ async def started_sleeper(task_status: _core.TaskStatus[None]) -> None:
await sleep_forever()
async def unstarted_task() -> None:
- raise AssertionError("this should not even get a chance to run")
+ await _core.checkpoint()
with pytest.RaisesGroup(
- pytest.RaisesExc(
- RuntimeError,
- match="Task .*unstarted_task.* aborted after nursery was destroyed due to misnesting.",
- ),
- pytest.RaisesExc(
- RuntimeError,
- match="Task .*started_sleeper.* aborted after nursery was destroyed due to misnesting.",
- ),
+ _core.Cancelled, # this leaks out, likely the scope supposed to handle it is gone
+ # but one of them is handled, or lost, not sure (TODO).
pytest.RaisesGroup(
pytest.RaisesExc(RuntimeError, match="Nursery stack corrupted")
),
@@ -905,15 +910,6 @@ async def unstarted_task() -> None:
asynccontextmanager_that_creates_a_nursery_internally(),
)
- # The outer nursery forcefully aborts the inner nursery and stops `unstarted_task`
- # from ever being started.
- # `started_sleeper` is awaited, but not the internal `sleep`
- with pytest.warns(
- RuntimeWarning,
- match="^coroutine '(test_asyncexitstack_nursery_misnest..unstarted_task|sleep)' was never awaited$",
- ):
- gc_collect_harder()
-
@slow
async def test_timekeeping() -> None:
From 121ee3ddbc303630ddea37fd53aac783e162caf5 Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Fri, 8 Aug 2025 16:45:51 +0200
Subject: [PATCH 5/7] suppress Cancelled if a CancelScope is abandoned by
misnesting
---
src/trio/_core/_run.py | 14 +++++++++++---
src/trio/_core/_tests/test_run.py | 29 +++++++++++++++++++++++------
2 files changed, 34 insertions(+), 9 deletions(-)
diff --git a/src/trio/_core/_run.py b/src/trio/_core/_run.py
index d32a20f8c6..5303dfe75d 100644
--- a/src/trio/_core/_run.py
+++ b/src/trio/_core/_run.py
@@ -678,6 +678,9 @@ def _close(self, exc: BaseException | None) -> BaseException | None:
exc is not None
and self._cancel_status.effectively_cancelled
and not self._cancel_status.parent_cancellation_is_visible_to_us
+ ) or (
+ scope_task._cancel_status is not self._cancel_status
+ and self._cancel_status.abandoned_by_misnesting
):
if isinstance(exc, Cancelled):
self.cancelled_caught = True
@@ -1324,7 +1327,7 @@ def aborted(raise_cancel: _core.RaiseCancelT) -> Abort:
self._add_exc(exc, reason=None)
popped = self._parent_task._child_nurseries.pop()
- assert popped is self
+ assert popped is self, "Nursery misnesting detected!"
if self._pending_excs:
try:
if not self._strict_exception_groups and len(self._pending_excs) == 1:
@@ -2012,8 +2015,13 @@ async def python_wrapper(orig_coro: Awaitable[RetT]) -> RetT:
def task_exited(self, task: Task, outcome: Outcome[object]) -> None:
if task._child_nurseries:
for nursery in task._child_nurseries:
- nursery.cancel_scope.cancel() # TODO: add reason
- nursery._parent_waiting_in_aexit = False
+ nursery.cancel_scope._cancel(
+ CancelReason(
+ source="nursery",
+ reason="Parent Task exited prematurely, abandoning this nursery without exiting it properly.",
+ source_task=repr(task),
+ )
+ )
nursery._closed = True
# break parking lots associated with the exiting task
diff --git a/src/trio/_core/_tests/test_run.py b/src/trio/_core/_tests/test_run.py
index 657544bf94..a62b518464 100644
--- a/src/trio/_core/_tests/test_run.py
+++ b/src/trio/_core/_tests/test_run.py
@@ -854,6 +854,11 @@ async def task3(task_status: _core.TaskStatus[_core.CancelScope]) -> None:
scope.cancel()
+# helper to check we're not outputting overly verbose tracebacks
+def no_cause_or_context(e: BaseException) -> bool:
+ return e.__cause__ is None and e.__context__ is None
+
+
async def test_nursery_misnest() -> None:
# See https://github.com/python-trio/trio/issues/3298
async def inner_func() -> None:
@@ -861,7 +866,10 @@ async def inner_func() -> None:
inner_nursery.start_soon(sleep, 1)
with pytest.RaisesGroup(
- pytest.RaisesExc(RuntimeError, match="Nursery stack corrupted")
+ pytest.RaisesExc(
+ RuntimeError, match="Nursery stack corrupted", check=no_cause_or_context
+ ),
+ check=no_cause_or_context,
):
async with _core.open_nursery() as outer_nursery:
inner_cm = _core.open_nursery()
@@ -869,14 +877,21 @@ async def inner_func() -> None:
def test_nursery_nested_child_misnest() -> None:
- # TODO: check context as well for the AssertionError (that will be a RuntimeError)
async def main() -> None:
async with _core.open_nursery():
inner_cm = _core.open_nursery()
await inner_cm.__aenter__()
- with pytest.raises(RuntimeError, match="Nursery stack corrupted"):
+ with pytest.raises(RuntimeError, match="Nursery stack corrupted") as excinfo:
_core.run(main)
+ assert excinfo.value.__cause__ is None
+ # This AssertionError is kind of redundant, but I don't think we want to remove
+ # the assertion and don't think we care enough to suppress it in this specific case.
+ assert pytest.RaisesExc(
+ AssertionError, match="^Nursery misnesting detected!$"
+ ).matches(excinfo.value.__context__)
+ assert excinfo.value.__context__.__cause__ is None
+ assert excinfo.value.__context__.__context__ is None
async def test_asyncexitstack_nursery_misnest() -> None:
@@ -897,11 +912,13 @@ async def unstarted_task() -> None:
await _core.checkpoint()
with pytest.RaisesGroup(
- _core.Cancelled, # this leaks out, likely the scope supposed to handle it is gone
- # but one of them is handled, or lost, not sure (TODO).
pytest.RaisesGroup(
- pytest.RaisesExc(RuntimeError, match="Nursery stack corrupted")
+ pytest.RaisesExc(
+ RuntimeError, match="Nursery stack corrupted", check=no_cause_or_context
+ ),
+ check=no_cause_or_context,
),
+ check=no_cause_or_context,
):
async with AsyncExitStack() as stack, _core.open_nursery() as nursery:
# The asynccontextmanager is going to create a nursery that outlives this nursery!
From a729ebfe16f828a24c024cc5ea12bd061b833d70 Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Mon, 11 Aug 2025 13:30:38 +0200
Subject: [PATCH 6/7] add example of losing exception from abandoned task
---
src/trio/_core/_tests/test_run.py | 50 +++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
diff --git a/src/trio/_core/_tests/test_run.py b/src/trio/_core/_tests/test_run.py
index a62b518464..111ba9e5ec 100644
--- a/src/trio/_core/_tests/test_run.py
+++ b/src/trio/_core/_tests/test_run.py
@@ -877,6 +877,7 @@ async def inner_func() -> None:
def test_nursery_nested_child_misnest() -> None:
+ # Note that this example does *not* raise an exception group.
async def main() -> None:
async with _core.open_nursery():
inner_cm = _core.open_nursery()
@@ -895,6 +896,9 @@ async def main() -> None:
async def test_asyncexitstack_nursery_misnest() -> None:
+ # This example is trickier than the above ones, and is the one that requires
+ # special logic of abandoned nurseries to avoid nasty internal errors that masks
+ # the RuntimeError.
@asynccontextmanager
async def asynccontextmanager_that_creates_a_nursery_internally() -> (
AsyncGenerator[None]
@@ -928,6 +932,52 @@ async def unstarted_task() -> None:
)
+def test_asyncexitstack_nursery_misnest_cleanup() -> None:
+ # We guarantee that abandoned tasks get to do cleanup *eventually*, but exceptions
+ # are lost. With more effort it's possible we could reschedule child tasks to exit
+ # promptly.
+ finally_entered = []
+
+ async def main() -> None:
+ async def unstarted_task() -> None:
+ try:
+ await _core.checkpoint()
+ finally:
+ finally_entered.append(True)
+ raise ValueError("this exception is lost")
+
+ # rest of main() is ~identical to the above test
+ @asynccontextmanager
+ async def asynccontextmanager_that_creates_a_nursery_internally() -> (
+ AsyncGenerator[None]
+ ):
+ async with _core.open_nursery() as nursery:
+ nursery.start_soon(unstarted_task)
+ yield
+
+ with pytest.RaisesGroup(
+ pytest.RaisesGroup(
+ pytest.RaisesExc(
+ RuntimeError,
+ match="Nursery stack corrupted",
+ check=no_cause_or_context,
+ ),
+ check=no_cause_or_context,
+ ),
+ check=no_cause_or_context,
+ ):
+ async with AsyncExitStack() as stack, _core.open_nursery() as nursery:
+ # The asynccontextmanager is going to create a nursery that outlives this nursery!
+ nursery.start_soon(
+ stack.enter_async_context,
+ asynccontextmanager_that_creates_a_nursery_internally(),
+ )
+ assert not finally_entered # abandoned task still hasn't been cleaned up
+
+ _core.run(main)
+ assert finally_entered # now it has
+
+
@slow
async def test_timekeeping() -> None:
# probably a good idea to use a real clock for *one* test anyway...
From b7704d174c74ef229c5f52e2ce9514ee82bae39a Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Mon, 11 Aug 2025 13:34:46 +0200
Subject: [PATCH 7/7] add newsfragment
---
newsfragments/3307.misc.rst | 1 +
1 file changed, 1 insertion(+)
create mode 100644 newsfragments/3307.misc.rst
diff --git a/newsfragments/3307.misc.rst b/newsfragments/3307.misc.rst
new file mode 100644
index 0000000000..ab59183729
--- /dev/null
+++ b/newsfragments/3307.misc.rst
@@ -0,0 +1 @@
+When misnesting nurseries you now get a helpful :exc:`RuntimeError` instead of a catastrophic :exc:`TrioInternalError`.