Skip to content

Commit a9ece4a

Browse files
miss-islingtonkristjanvalurgvanrossumAlexWaygood
authored
gh-102780: Fix uncancel() call in asyncio timeouts (GH-102815)
Also use `raise TimeOut from <CancelledError instance>` so that the CancelledError is set in the `__cause__` field rather than in the `__context__` field. (cherry picked from commit 04adf2d) Co-authored-by: Kristján Valur Jónsson <[email protected]> Co-authored-by: Guido van Rossum <[email protected]> Co-authored-by: Alex Waygood <[email protected]>
1 parent f1b9673 commit a9ece4a

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

@@ -1137,7 +1141,9 @@ Task Object
11371141
Therefore, unlike :meth:`Future.cancel`, :meth:`Task.cancel` does
11381142
not guarantee that the Task will be cancelled, although
11391143
suppressing cancellation completely is not common and is actively
1140-
discouraged.
1144+
discouraged. Should the coroutine nevertheless decide to suppress
1145+
the cancellation, it needs to call :meth:`Task.uncancel` in addition
1146+
to catching the exception.
11411147

11421148
.. versionchanged:: 3.9
11431149
Added the *msg* parameter.
@@ -1227,6 +1233,10 @@ Task Object
12271233
with :meth:`uncancel`. :class:`TaskGroup` context managers use
12281234
:func:`uncancel` in a similar fashion.
12291235

1236+
If end-user code is, for some reason, suppresing cancellation by
1237+
catching :exc:`CancelledError`, it needs to call this method to remove
1238+
the cancellation state.
1239+
12301240
.. method:: cancelling()
12311241

12321242
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
@@ -248,6 +248,36 @@ async def test_nested_timeout_in_finally(self):
248248
async with asyncio.timeout(0.01):
249249
await asyncio.sleep(10)
250250

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

252282
if __name__ == '__main__':
253283
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)