Skip to content

Commit 93a5746

Browse files
gschaffnergraingertagronholm
authored
Fixed asyncio.Task.cancelling issues (#790)
* Shrink TaskState to save a little memory * Fix uncancel() being called too early * Refactor to avoid duplicate computation * Test TaskInfo.has_pending_cancellation in cleanup code * Fix TaskInfo.has_pending_cancellation in cleanup code on asyncio * Test that uncancel() isn't called too early Co-authored-by: Thomas Grainger <[email protected]> Co-authored-by: Alex Grönholm <[email protected]>
1 parent 39cf394 commit 93a5746

File tree

3 files changed

+117
-60
lines changed

3 files changed

+117
-60
lines changed

docs/versionhistory.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.
1818
- Fixed the return type annotations of ``readinto()`` and ``readinto1()`` methods in the
1919
``anyio.AsyncFile`` class
2020
(`#825 <https://github.com/agronholm/anyio/issues/825>`_)
21+
- Fixed ``TaskInfo.has_pending_cancellation()`` on asyncio returning false positives in
22+
cleanup code on Python >= 3.11
23+
(`#832 <https://github.com/agronholm/anyio/issues/832>`_; PR by @gschaffner)
24+
- Fixed cancelled cancel scopes on asyncio calling ``asyncio.Task.uncancel`` when
25+
propagating a ``CancelledError`` on exit to a cancelled parent scope
26+
(`#790 <https://github.com/agronholm/anyio/pull/790>`_; PR by @gschaffner)
2127

2228
**4.6.2**
2329

src/anyio/_backends/_asyncio.py

Lines changed: 59 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -372,11 +372,22 @@ def _task_started(task: asyncio.Task) -> bool:
372372

373373

374374
def is_anyio_cancellation(exc: CancelledError) -> bool:
375-
return (
376-
bool(exc.args)
377-
and isinstance(exc.args[0], str)
378-
and exc.args[0].startswith("Cancelled by cancel scope ")
379-
)
375+
# Sometimes third party frameworks catch a CancelledError and raise a new one, so as
376+
# a workaround we have to look at the previous ones in __context__ too for a
377+
# matching cancel message
378+
while True:
379+
if (
380+
exc.args
381+
and isinstance(exc.args[0], str)
382+
and exc.args[0].startswith("Cancelled by cancel scope ")
383+
):
384+
return True
385+
386+
if isinstance(exc.__context__, CancelledError):
387+
exc = exc.__context__
388+
continue
389+
390+
return False
380391

381392

382393
class CancelScope(BaseCancelScope):
@@ -397,8 +408,10 @@ def __init__(self, deadline: float = math.inf, shield: bool = False):
397408
self._cancel_handle: asyncio.Handle | None = None
398409
self._tasks: set[asyncio.Task] = set()
399410
self._host_task: asyncio.Task | None = None
400-
self._cancel_calls: int = 0
401-
self._cancelling: int | None = None
411+
if sys.version_info >= (3, 11):
412+
self._pending_uncancellations: int | None = 0
413+
else:
414+
self._pending_uncancellations = None
402415

403416
def __enter__(self) -> CancelScope:
404417
if self._active:
@@ -424,8 +437,6 @@ def __enter__(self) -> CancelScope:
424437

425438
self._timeout()
426439
self._active = True
427-
if sys.version_info >= (3, 11):
428-
self._cancelling = self._host_task.cancelling()
429440

430441
# Start cancelling the host task if the scope was cancelled before entering
431442
if self._cancel_called:
@@ -470,30 +481,41 @@ def __exit__(
470481

471482
host_task_state.cancel_scope = self._parent_scope
472483

473-
# Undo all cancellations done by this scope
474-
if self._cancelling is not None:
475-
while self._cancel_calls:
476-
self._cancel_calls -= 1
477-
if self._host_task.uncancel() <= self._cancelling:
478-
break
484+
# Restart the cancellation effort in the closest visible, cancelled parent
485+
# scope if necessary
486+
self._restart_cancellation_in_parent()
479487

480488
# We only swallow the exception iff it was an AnyIO CancelledError, either
481489
# directly as exc_val or inside an exception group and there are no cancelled
482490
# parent cancel scopes visible to us here
483-
not_swallowed_exceptions = 0
484-
swallow_exception = False
485-
if exc_val is not None:
486-
for exc in iterate_exceptions(exc_val):
487-
if self._cancel_called and isinstance(exc, CancelledError):
488-
if not (swallow_exception := self._uncancel(exc)):
489-
not_swallowed_exceptions += 1
490-
else:
491-
not_swallowed_exceptions += 1
491+
if self._cancel_called and not self._parent_cancellation_is_visible_to_us:
492+
# For each level-cancel() call made on the host task, call uncancel()
493+
while self._pending_uncancellations:
494+
self._host_task.uncancel()
495+
self._pending_uncancellations -= 1
496+
497+
# Update cancelled_caught and check for exceptions we must not swallow
498+
cannot_swallow_exc_val = False
499+
if exc_val is not None:
500+
for exc in iterate_exceptions(exc_val):
501+
if isinstance(exc, CancelledError) and is_anyio_cancellation(
502+
exc
503+
):
504+
self._cancelled_caught = True
505+
else:
506+
cannot_swallow_exc_val = True
492507

493-
# Restart the cancellation effort in the closest visible, cancelled parent
494-
# scope if necessary
495-
self._restart_cancellation_in_parent()
496-
return swallow_exception and not not_swallowed_exceptions
508+
return self._cancelled_caught and not cannot_swallow_exc_val
509+
else:
510+
if self._pending_uncancellations:
511+
assert self._parent_scope is not None
512+
assert self._parent_scope._pending_uncancellations is not None
513+
self._parent_scope._pending_uncancellations += (
514+
self._pending_uncancellations
515+
)
516+
self._pending_uncancellations = 0
517+
518+
return False
497519
finally:
498520
self._host_task = None
499521
del exc_val
@@ -520,31 +542,6 @@ def _parent_cancellation_is_visible_to_us(self) -> bool:
520542
and self._parent_scope._effectively_cancelled
521543
)
522544

523-
def _uncancel(self, cancelled_exc: CancelledError) -> bool:
524-
if self._host_task is None:
525-
self._cancel_calls = 0
526-
return True
527-
528-
while True:
529-
if is_anyio_cancellation(cancelled_exc):
530-
# Only swallow the cancellation exception if it's an AnyIO cancel
531-
# exception and there are no other cancel scopes down the line pending
532-
# cancellation
533-
self._cancelled_caught = (
534-
self._effectively_cancelled
535-
and not self._parent_cancellation_is_visible_to_us
536-
)
537-
return self._cancelled_caught
538-
539-
# Sometimes third party frameworks catch a CancelledError and raise a new
540-
# one, so as a workaround we have to look at the previous ones in
541-
# __context__ too for a matching cancel message
542-
if isinstance(cancelled_exc.__context__, CancelledError):
543-
cancelled_exc = cancelled_exc.__context__
544-
continue
545-
546-
return False
547-
548545
def _timeout(self) -> None:
549546
if self._deadline != math.inf:
550547
loop = get_running_loop()
@@ -576,8 +573,11 @@ def _deliver_cancellation(self, origin: CancelScope) -> bool:
576573
waiter = task._fut_waiter # type: ignore[attr-defined]
577574
if not isinstance(waiter, asyncio.Future) or not waiter.done():
578575
task.cancel(f"Cancelled by cancel scope {id(origin):x}")
579-
if task is origin._host_task:
580-
origin._cancel_calls += 1
576+
if (
577+
task is origin._host_task
578+
and origin._pending_uncancellations is not None
579+
):
580+
origin._pending_uncancellations += 1
581581

582582
# Deliver cancellation to child scopes that aren't shielded or running their own
583583
# cancellation callbacks
@@ -2154,12 +2154,11 @@ def has_pending_cancellation(self) -> bool:
21542154
# If the task isn't around anymore, it won't have a pending cancellation
21552155
return False
21562156

2157-
if sys.version_info >= (3, 11):
2158-
if task.cancelling():
2159-
return True
2157+
if task._must_cancel: # type: ignore[attr-defined]
2158+
return True
21602159
elif (
2161-
isinstance(task._fut_waiter, asyncio.Future)
2162-
and task._fut_waiter.cancelled()
2160+
isinstance(task._fut_waiter, asyncio.Future) # type: ignore[attr-defined]
2161+
and task._fut_waiter.cancelled() # type: ignore[attr-defined]
21632162
):
21642163
return True
21652164

tests/test_taskgroups.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,38 @@ async def test_cancel_shielded_scope() -> None:
673673
await checkpoint()
674674

675675

676+
async def test_shielded_cleanup_after_cancel() -> None:
677+
"""Regression test for #832."""
678+
with CancelScope() as outer_scope:
679+
outer_scope.cancel()
680+
try:
681+
await checkpoint()
682+
finally:
683+
assert current_effective_deadline() == -math.inf
684+
assert get_current_task().has_pending_cancellation()
685+
686+
with CancelScope(shield=True): # noqa: ASYNC100
687+
assert current_effective_deadline() == math.inf
688+
assert not get_current_task().has_pending_cancellation()
689+
690+
assert current_effective_deadline() == -math.inf
691+
assert get_current_task().has_pending_cancellation()
692+
693+
694+
@pytest.mark.parametrize("anyio_backend", ["asyncio"])
695+
async def test_cleanup_after_native_cancel() -> None:
696+
"""Regression test for #832."""
697+
# See also https://github.com/python/cpython/pull/102815.
698+
task = asyncio.current_task()
699+
assert task
700+
task.cancel()
701+
with pytest.raises(asyncio.CancelledError):
702+
try:
703+
await checkpoint()
704+
finally:
705+
assert not get_current_task().has_pending_cancellation()
706+
707+
676708
async def test_cancelled_not_caught() -> None:
677709
with CancelScope() as scope: # noqa: ASYNC100
678710
scope.cancel()
@@ -1488,6 +1520,26 @@ async def taskfunc() -> None:
14881520
assert str(exc_info.value.exceptions[0]) == "dummy error"
14891521
assert not cast(asyncio.Task, asyncio.current_task()).cancelling()
14901522

1523+
async def test_uncancel_cancelled_scope_based_checkpoint(self) -> None:
1524+
"""See also test_cancelled_scope_based_checkpoint."""
1525+
task = asyncio.current_task()
1526+
assert task
1527+
1528+
with CancelScope() as outer_scope:
1529+
outer_scope.cancel()
1530+
1531+
try:
1532+
# The following three lines are a way to implement a checkpoint
1533+
# function. See also https://github.com/python-trio/trio/issues/860.
1534+
with CancelScope() as inner_scope:
1535+
inner_scope.cancel()
1536+
await sleep_forever()
1537+
finally:
1538+
assert isinstance(sys.exc_info()[1], asyncio.CancelledError)
1539+
assert task.cancelling()
1540+
1541+
assert not task.cancelling()
1542+
14911543

14921544
async def test_cancel_before_entering_task_group() -> None:
14931545
with CancelScope() as scope:

0 commit comments

Comments
 (0)