Skip to content

Commit 04adf2d

Browse files
kristjanvalurgvanrossumAlexWaygood
authored
gh-102780: Fix uncancel() call in asyncio timeouts (#102815)
Also use `raise TimeOut from <CancelledError instance>` so that the CancelledError is set in the `__cause__` field rather than in the `__context__` field. Co-authored-by: Guido van Rossum <[email protected]> Co-authored-by: Alex Waygood <[email protected]>
1 parent 1ca3155 commit 04adf2d

File tree

4 files changed

+50
-6
lines changed

4 files changed

+50
-6
lines changed

Doc/library/asyncio-task.rst

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,13 +300,17 @@ in the task at the next opportunity.
300300
It is recommended that coroutines use ``try/finally`` blocks to robustly
301301
perform clean-up logic. In case :exc:`asyncio.CancelledError`
302302
is explicitly caught, it should generally be propagated when
303-
clean-up is complete. Most code can safely ignore :exc:`asyncio.CancelledError`.
303+
clean-up is complete. :exc:`asyncio.CancelledError` directly subclasses
304+
:exc:`BaseException` so most code will not need to be aware of it.
304305

305306
The asyncio components that enable structured concurrency, like
306307
:class:`asyncio.TaskGroup` and :func:`asyncio.timeout`,
307308
are implemented using cancellation internally and might misbehave if
308309
a coroutine swallows :exc:`asyncio.CancelledError`. Similarly, user code
309-
should not call :meth:`uncancel <asyncio.Task.uncancel>`.
310+
should not generally call :meth:`uncancel <asyncio.Task.uncancel>`.
311+
However, in cases when suppressing :exc:`asyncio.CancelledError` is
312+
truly desired, it is necessary to also call ``uncancel()`` to completely
313+
remove the cancellation state.
310314

311315
.. _taskgroups:
312316

@@ -1148,7 +1152,9 @@ Task Object
11481152
Therefore, unlike :meth:`Future.cancel`, :meth:`Task.cancel` does
11491153
not guarantee that the Task will be cancelled, although
11501154
suppressing cancellation completely is not common and is actively
1151-
discouraged.
1155+
discouraged. Should the coroutine nevertheless decide to suppress
1156+
the cancellation, it needs to call :meth:`Task.uncancel` in addition
1157+
to catching the exception.
11521158

11531159
.. versionchanged:: 3.9
11541160
Added the *msg* parameter.
@@ -1238,6 +1244,10 @@ Task Object
12381244
with :meth:`uncancel`. :class:`TaskGroup` context managers use
12391245
:func:`uncancel` in a similar fashion.
12401246

1247+
If end-user code is, for some reason, suppresing cancellation by
1248+
catching :exc:`CancelledError`, it needs to call this method to remove
1249+
the cancellation state.
1250+
12411251
.. method:: cancelling()
12421252

12431253
Return the number of pending cancellation requests to this Task, i.e.,

Lib/asyncio/timeouts.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def __repr__(self) -> str:
8484
async def __aenter__(self) -> "Timeout":
8585
self._state = _State.ENTERED
8686
self._task = tasks.current_task()
87+
self._cancelling = self._task.cancelling()
8788
if self._task is None:
8889
raise RuntimeError("Timeout should be used inside a task")
8990
self.reschedule(self._when)
@@ -104,10 +105,10 @@ async def __aexit__(
104105
if self._state is _State.EXPIRING:
105106
self._state = _State.EXPIRED
106107

107-
if self._task.uncancel() == 0 and exc_type is exceptions.CancelledError:
108-
# Since there are no outstanding cancel requests, we're
108+
if self._task.uncancel() <= self._cancelling and exc_type is exceptions.CancelledError:
109+
# Since there are no new cancel requests, we're
109110
# handling this.
110-
raise TimeoutError
111+
raise TimeoutError from exc_val
111112
elif self._state is _State.ENTERED:
112113
self._state = _State.EXITED
113114

Lib/test/test_asyncio/test_timeouts.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,36 @@ async def test_nested_timeout_in_finally(self):
247247
async with asyncio.timeout(0.01):
248248
await asyncio.sleep(10)
249249

250+
async def test_timeout_after_cancellation(self):
251+
try:
252+
asyncio.current_task().cancel()
253+
await asyncio.sleep(1) # work which will be cancelled
254+
except asyncio.CancelledError:
255+
pass
256+
finally:
257+
with self.assertRaises(TimeoutError):
258+
async with asyncio.timeout(0.0):
259+
await asyncio.sleep(1) # some cleanup
260+
261+
async def test_cancel_in_timeout_after_cancellation(self):
262+
try:
263+
asyncio.current_task().cancel()
264+
await asyncio.sleep(1) # work which will be cancelled
265+
except asyncio.CancelledError:
266+
pass
267+
finally:
268+
with self.assertRaises(asyncio.CancelledError):
269+
async with asyncio.timeout(1.0):
270+
asyncio.current_task().cancel()
271+
await asyncio.sleep(2) # some cleanup
272+
273+
async def test_timeout_exception_cause (self):
274+
with self.assertRaises(asyncio.TimeoutError) as exc:
275+
async with asyncio.timeout(0):
276+
await asyncio.sleep(1)
277+
cause = exc.exception.__cause__
278+
assert isinstance(cause, asyncio.CancelledError)
279+
250280

251281
if __name__ == '__main__':
252282
unittest.main()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The :class:`asyncio.Timeout` context manager now works reliably even when performing cleanup due
2+
to task cancellation. Previously it could raise a
3+
:exc:`~asyncio.CancelledError` instead of an :exc:`~asyncio.TimeoutError` in such cases.

0 commit comments

Comments
 (0)