10000 Improved asyncio cancellation semantics · IBMZ-Linux-OSS-Python/anyio@04f9ce6 · GitHub
[go: up one dir, main page]

Skip to content

Commit 04f9ce6

Browse files
committed
Improved asyncio cancellation semantics
The semantics now better match with trio's.
1 parent a04691c commit 04f9ce6

File tree

4 files changed

+64
-20
lines changed

4 files changed

+64
-20
lines changed

docs/versionhistory.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.
4242
the event loop to be closed
4343
- Fixed ``current_effective_deadline()`` not returning ``-inf`` on asyncio when the
4444
currently active cancel scope has been cancelled (PR by Ganden Schaffner)
45+
- Fixed task group not raising a cancellation exception on asyncio at exit if no child
46+
tasks were spawned and an outer cancellation scope had been cancelled before
4547

4648
**3.6.1**
4749

src/anyio/_backends/_asyncio.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
Coroutine,
4343
Deque,
4444
Generator,
45-
Iterator,
4645
Mapping,
4746
Optional,
4847
Sequence,
@@ -456,14 +455,6 @@ def collapse_exception_group(excgroup: BaseExceptionGroup) -> BaseException:
456455
return excgroup
457456

458457

459-
def walk_exception_group(excgroup: BaseExceptionGroup) -> Iterator[BaseException]:
460-
for exc in excgroup.exceptions:
461-
if isinstance(exc, BaseExceptionGroup):
462-
yield from walk_exception_group(exc)
463-
else:
464-
yield exc
465-
466-
467458
def is_anyio_cancelled_exc(exc: BaseException) -> bool:
468459
return isinstance(exc, CancelledError) and not exc.args
469460

@@ -490,11 +481,21 @@ async def __aexit__(
490481
self.cancel_scope.cancel()
491482
self._exceptions.append(exc_val)
492483

493-
while self.cancel_scope._tasks:
484+
if self.cancel_scope._tasks:
485+
while self.cancel_scope._tasks:
486+
try:
487+
await asyncio.wait(self.cancel_scope._tasks)
488+
except asyncio.CancelledError as e:
489+
if exc_val is None:
490+
self.cancel_scope.cancel()
491+
self._exceptions.append(e)
492+
exc_val = e
493+
else:
494+
# No tasks to wait on, but we still need to check for cancellation here
494495
try:
495-
await asyncio.wait(self.cancel_scope._tasks)
496-
except asyncio.CancelledError:
497-
self.cancel_scope.cancel()
496+
await AsyncIOBackend.checkpoint()
497+
except CancelledError as e:
498+
self._exceptions.append(e)
498499

499500
self._active = False
500501
if self._exceptions:
@@ -504,9 +505,6 @@ async def __aexit__(
504505
# If any exceptions other than AnyIO cancellation exceptions have been
505506
# received, raise those
506507
_, exc = group.split(is_anyio_cancelled_exc)
507-
elif all(is_anyio_cancelled_exc(e) for e in walk_exception_group(group)):
508-
# All tasks were cancelled by AnyIO
509-
exc = CancelledError()
510508
else:
511509
exc = group
512510

src/anyio/from_thread.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ async def __aexit__(
148148
await self.stop()
149149
return await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
150150

151+
@property
152+
def _running(self) -> bool:
153+
return self._event_loop_thread_id is not None
154+
151155
def _check_running(self) -> None:
152156
if self._event_loop_thread_id is None:
153157
raise RuntimeError("This portal is not running")
@@ -202,8 +206,11 @@ def callback(f: Future) -> None:
202206
if not future.cancelled():
203207
future.set_exception(exc)
204208

205-
# Let base exceptions fall through
209+
# Let base exceptions fall through, but mark the portal as not running, so
210+
# start_blocking_portal() won't try to stop it since BaseException will
211+
# cause that anyway
206212
if not isinstance(exc, Exception):
213+
self._event_loop_thread_id = None
207214
raise
208215
else:
209216
if not future.cancelled():
@@ -413,9 +420,7 @@ async def run_portal() -> None:
413420
cancel_remaining_tasks = True
414421
raise
415422
finally:
416-
try:
423+
if not run_future.done() and portal._running:
417424
portal.call(portal.stop, cancel_remaining_tasks)
418-
except RuntimeError:
419-
pass
420425

421426
run_future.result()

tests/test_taskgroups.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,45 @@ async def test_cancel_before_entering_scope() -> None:
396396
pytest.fail("execution should not reach this point")
397397

398398

399+
async def test_cancel_outer_scope_no_tasks() -> None:
400+
"""
401+
Test that a task group raises an exception group containing one cancellation error
402+
from __aexit__() if the outer cancel scope was cancelled.
403+
404+
"""
405+
with CancelScope() as outer_scope:
406+
try:
407+
async with anyio.create_task_group():
408+
outer_scope.cancel()
409+
except BaseException as exc:
410+
if not isinstance(exc, get_cancelled_exc_class()):
411+
pytest.fail("should have raised a cancellation exception")
412+
413+
raise
414+
else:
415+
pytest.fail("should have raised an exception")
416+
417+
418+
async def test_cancel_outer_scope_one_task() -> None:
419+
"""
420+
Test that a task group propagates a cancellation error (wrapped in an exception
421+
group) from __aexit__() that was not intended for the task group's cancel scope.
422+
423+
"""
424+
with CancelScope() as outer_scope:
425+
try:
426+
async with anyio.create_task_group() as tg:
427+
tg.start_soon(sleep, 3)
428+
outer_scope.cancel()
429+
except BaseExceptionGroup as excgrp:
430+
assert len(excgrp.exceptions) == 2
431+
raise
432+
except get_cancelled_exc_class():
433+
pytest.fail("task group raised a plain cancellation error")
434+
else:
435+
pytest.fail("should have raised an exception group")
436+
437+
399438
async def test_exception_group_children() -> None:
400439
with pytest.raises(BaseExceptionGroup) as exc:
401440
async with create_task_group() as tg:

0 commit comments

Comments
 (0)
0