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`.