Skip to content

Commit a308f74

Browse files
authored
[PR #9326/fe26ae2 backport][3.10] Fix TimerContext not uncancelling the current task (#9328)
1 parent 52e0b91 commit a308f74

File tree

3 files changed

+76
-4
lines changed

3 files changed

+76
-4
lines changed

CHANGES/9326.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed cancellation leaking upwards on timeout -- by :user:`bdraco`.

aiohttp/helpers.py

+20-3
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,7 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
686686
self._loop = loop
687687
self._tasks: List[asyncio.Task[Any]] = []
688688
self._cancelled = False
689+
self._cancelling = 0
689690

690691
def assert_timeout(self) -> None:
691692
"""Raise TimeoutError if timer has already been cancelled."""
@@ -694,12 +695,17 @@ def assert_timeout(self) -> None:
694695

695696
def __enter__(self) -> BaseTimerContext:
696697
task = asyncio.current_task(loop=self._loop)
697-
698698
if task is None:
699699
raise RuntimeError(
700700
"Timeout context manager should be used " "inside a task"
701701
)
702702

703+
if sys.version_info >= (3, 11):
704+
# Remember if the task was already cancelling
705+
# so when we __exit__ we can decide if we should
706+
# raise asyncio.TimeoutError or let the cancellation propagate
707+
self._cancelling = task.cancelling()
708+
703709
if self._cancelled:
704710
raise asyncio.TimeoutError from None
705711

@@ -712,11 +718,22 @@ def __exit__(
712718
exc_val: Optional[BaseException],
713719
exc_tb: Optional[TracebackType],
714720
) -> Optional[bool]:
721+
enter_task: Optional[asyncio.Task[Any]] = None
715722
if self._tasks:
716-
self._tasks.pop()
723+
enter_task = self._tasks.pop()
717724

718725
if exc_type is asyncio.CancelledError and self._cancelled:
719-
raise asyncio.TimeoutError from None
726+
assert enter_task is not None
727+
# The timeout was hit, and the task was cancelled
728+
# so we need to uncancel the last task that entered the context manager
729+
# since the cancellation should not leak out of the context manager
730+
if sys.version_info >= (3, 11):
731+
# If the task was already cancelling don't raise
732+
# asyncio.TimeoutError and instead return None
733+
# to allow the cancellation to propagate
734+
if enter_task.uncancel() > self._cancelling:
735+
return None
736+
raise asyncio.TimeoutError from exc_val
720737
return None
721738

722739
def timeout(self) -> None:

tests/test_helpers.py

+55-1
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,61 @@ def test_timer_context_not_cancelled() -> None:
397397
assert not m_asyncio.current_task.return_value.cancel.called
398398

399399

400-
def test_timer_context_no_task(loop) -> None:
400+
@pytest.mark.skipif(
401+
sys.version_info < (3, 11), reason="Python 3.11+ is required for .cancelling()"
402+
)
403+
async def test_timer_context_timeout_does_not_leak_upward() -> None:
404+
"""Verify that the TimerContext does not leak cancellation outside the context manager."""
405+
loop = asyncio.get_running_loop()
406+
ctx = helpers.TimerContext(loop)
407+
current_task = asyncio.current_task()
408+
assert current_task is not None
409+
with pytest.raises(asyncio.TimeoutError):
410+
with ctx:
411+
assert current_task.cancelling() == 0
412+
loop.call_soon(ctx.timeout)
413+
await asyncio.sleep(1)
414+
415+
# After the context manager exits, the task should no longer be cancelling
416+
assert current_task.cancelling() == 0
417+
418+
419+
@pytest.mark.skipif(
420+
sys.version_info < (3, 11), reason="Python 3.11+ is required for .cancelling()"
421+
)
422+
async def test_timer_context_timeout_does_swallow_cancellation() -> None:
423+
"""Verify that the TimerContext does not swallow cancellation."""
424+
loop = asyncio.get_running_loop()
425+
current_task = asyncio.current_task()
426+
assert current_task is not None
427+
ctx = helpers.TimerContext(loop)
428+
429+
async def task_with_timeout() -> None:
430+
nonlocal ctx
431+
new_task = asyncio.current_task()
432+
assert new_task is not None
433+
with pytest.raises(asyncio.TimeoutError):
434+
with ctx:
435+
assert new_task.cancelling() == 0
436+
await asyncio.sleep(1)
437+
438+
task = asyncio.create_task(task_with_timeout())
439+
await asyncio.sleep(0)
440+
task.cancel()
441+
assert task.cancelling() == 1
442+
ctx.timeout()
443+
444+
# Cancellation should not leak into the current task
445+
assert current_task.cancelling() == 0
446+
# Cancellation should not be swallowed if the task is cancelled
447+
# and it also times out
448+
await asyncio.sleep(0)
449+
with pytest.raises(asyncio.CancelledError):
450+
await task
451+
assert task.cancelling() == 1
452+
453+
454+
def test_timer_context_no_task(loop: asyncio.AbstractEventLoop) -> None:
401455
with pytest.raises(RuntimeError):
402456
with helpers.TimerContext(loop):
403457
pass

0 commit comments

Comments
 (0)