Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nursery: don't act as a checkpoint when not running anything #1696

Merged
merged 9 commits into from
Oct 21, 2023
7 changes: 7 additions & 0 deletions newsfragments/1457.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
When exiting a nursery block, the parent task always waits for child
tasks to exit. This wait cannot be cancelled. However, previously, if
you tried to cancel it, it *would* inject a `Cancelled` exception,
even though it wasn't cancelled. Most users probably never noticed
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wait cannot be cancelled. However, previously, if
you tried to cancel it, it would inject a Cancelled exception,
even though it wasn't cancelled.

So this means the Cancelled is still injected but now is ignored via the new isinstance check?

Also if the following is true:

This wait cannot be cancelled. However, previously, if
you tried to cancel it, it would inject a Cancelled exception,
even though it wasn't cancelled.

Then why the change to cancel_shielded_checkpoint()?

either way, but injecting a `Cancelled` here is not really useful, and
in some rare cases caused confusion or problems, so Trio no longer
does that.
18 changes: 10 additions & 8 deletions trio/_core/_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -916,21 +916,23 @@ async def _nested_child_finished(self, nested_child_exc):
self._check_nursery_closed()

if not self._closed:
# If we get cancelled (or have an exception injected, like
# KeyboardInterrupt), then save that, but still wait until our
# children finish.
# If we have a KeyboardInterrupt injected, we want to save it in
# the nursery's final exceptions list. But if it's just a
# Cancelled, then we don't -- see gh-1457.
def aborted(raise_cancel):
self._add_exc(capture(raise_cancel).error)
exn = capture(raise_cancel).error
if not isinstance(exn, Cancelled):
self._add_exc(exn)
return Abort.FAILED

self._parent_waiting_in_aexit = True
await wait_task_rescheduled(aborted)
else:
# Nothing to wait for, so just execute a checkpoint -- but we
# still need to mix any exception (e.g. from an external
# cancellation) in with the rest of our exceptions.
# Nothing to wait for, so execute a schedule point, but don't
# allow us to be cancelled, just like the other branch. We
# still need to catch and store non-Cancelled exceptions.
try:
await checkpoint()
await cancel_shielded_checkpoint()
except BaseException as exc:
self._add_exc(exc)

Expand Down
59 changes: 43 additions & 16 deletions trio/_core/tests/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,12 +433,12 @@ async def crasher():
# nursery block exited, all cancellations inside the
# nursery block continue propagating to reach the
# outer scope.
assert len(multi_exc.exceptions) == 5
assert len(multi_exc.exceptions) == 4
summary = {}
for exc in multi_exc.exceptions:
summary.setdefault(type(exc), 0)
summary[type(exc)] += 1
assert summary == {_core.Cancelled: 4, KeyError: 1}
assert summary == {_core.Cancelled: 3, KeyError: 1}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarifying the cancelled count in a comment might be clearer here for those unfamiliar with the recent change?

Something like we only receive cancelleds from subtasks not from the nursery itself?

raise
except AssertionError: # pragma: no cover
raise
Expand Down Expand Up @@ -1101,6 +1101,9 @@ async def child():
assert isinstance(excinfo.value.__context__, KeyError)


@pytest.mark.skipif(
sys.version_info < (3, 6, 2), reason="https://bugs.python.org/issue29600"
)
async def test_nursery_exception_chaining_doesnt_make_context_loops():
async def crasher():
raise KeyError
Expand Down Expand Up @@ -1605,20 +1608,35 @@ async def test_trivial_yields():
await _core.checkpoint_if_cancelled()
await _core.cancel_shielded_checkpoint()

with assert_checkpoints():
# Weird case: opening and closing a nursery schedules, but doesn't check
# for cancellation (unless something inside the nursery does)
task = _core.current_task()
before_schedule_points = task._schedule_points
with _core.CancelScope() as cs:
cs.cancel()
async with _core.open_nursery():
pass
assert not cs.cancelled_caught
assert task._schedule_points > before_schedule_points

before_schedule_points = task._schedule_points

async def noop_with_no_checkpoint():
pass

with _core.CancelScope() as cs:
cs.cancel()
async with _core.open_nursery() as nursery:
nursery.start_soon(noop_with_no_checkpoint)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume it's out of scope (and already covered in other tests) but normally doing this cancellation before opening the nursery results in cancellation at the first checkpoint right?

assert not cs.cancelled_caught

assert task._schedule_points > before_schedule_points

with _core.CancelScope() as cancel_scope:
cancel_scope.cancel()
with pytest.raises(_core.MultiError) as excinfo:
with pytest.raises(KeyError):
async with _core.open_nursery():
raise KeyError
assert len(excinfo.value.exceptions) == 2
assert {type(e) for e in excinfo.value.exceptions} == {
KeyError,
_core.Cancelled,
}


async def test_nursery_start(autojump_clock):
Expand Down Expand Up @@ -1685,28 +1703,37 @@ async def nothing(task_status=_core.TASK_STATUS_IGNORED):
# is ignored; start() raises Cancelled.
async def just_started(task_status=_core.TASK_STATUS_IGNORED):
task_status.started("hi")
await _core.checkpoint()

async with _core.open_nursery() as nursery:
with _core.CancelScope() as cs:
cs.cancel()
with pytest.raises(_core.Cancelled):
await nursery.start(just_started)

# and if after the no-op started(), the child crashes, the error comes out
# of start()
# but if the task does not execute any checkpoints, and exits, then start()
# doesn't raise Cancelled, since the task completed successfully.
async def started_with_no_checkpoint(task_status=_core.TASK_STATUS_IGNORED):
task_status.started("hi")

async with _core.open_nursery() as nursery:
with _core.CancelScope() as cs:
cs.cancel()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case where you cancel from inside the nursery after having started tasks with checkpoints, won't counts of expected cancels change as well or is that entirely covered by the one test change in test_run.py?

await nursery.start(started_with_no_checkpoint)
assert not cs.cancelled_caught

# and since starting in a cancelled context makes started() a no-op, if
# the child crashes after calling started(), the error can *still* come
# out of start()
async def raise_keyerror_after_started(task_status=_core.TASK_STATUS_IGNORED):
task_status.started()
raise KeyError("whoopsiedaisy")

async with _core.open_nursery() as nursery:
with _core.CancelScope() as cs:
cs.cancel()
with pytest.raises(_core.MultiError) as excinfo:
with pytest.raises(KeyError):
await nursery.start(raise_keyerror_after_started)
assert {type(e) for e in excinfo.value.exceptions} == {
_core.Cancelled,
KeyError,
}

# trying to start in a closed nursery raises an error immediately
async with _core.open_nursery() as closed_nursery:
Expand Down