summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMiss Islington (bot) <31488909+miss-islington@users.noreply.github.com>2023-03-22 11:23:47 -0700
committerGitHub <noreply@github.com>2023-03-22 11:23:47 -0700
commita9ece4a8392768b4ab35253101f628ec61956646 (patch)
treef0934925f764c3a170102a878b624aa8795eda04
parentgh-102921: [doc] Clarify `exc` argument name in `BaseExceptionGroup` is plura... (diff)
downloadcpython-a9ece4a8392768b4ab35253101f628ec61956646.tar.gz
cpython-a9ece4a8392768b4ab35253101f628ec61956646.tar.bz2
cpython-a9ece4a8392768b4ab35253101f628ec61956646.zip
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 04adf2df395ded81922c71360a5d66b597471e49) Co-authored-by: Kristján Valur Jónsson <sweskman@gmail.com> Co-authored-by: Guido van Rossum <gvanrossum@gmail.com> Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
-rw-r--r--Doc/library/asyncio-task.rst16
-rw-r--r--Lib/asyncio/timeouts.py7
-rw-r--r--Lib/test/test_asyncio/test_timeouts.py30
-rw-r--r--Misc/NEWS.d/next/Library/2023-03-22-16-15-18.gh-issue-102780.NEcljy.rst3
4 files changed, 50 insertions, 6 deletions
diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst
index c15d719ff33..5fb8776c3db 100644
--- a/Doc/library/asyncio-task.rst
+++ b/Doc/library/asyncio-task.rst
@@ -300,13 +300,17 @@ in the task at the next opportunity.
It is recommended that coroutines use ``try/finally`` blocks to robustly
perform clean-up logic. In case :exc:`asyncio.CancelledError`
is explicitly caught, it should generally be propagated when
-clean-up is complete. Most code can safely ignore :exc:`asyncio.CancelledError`.
+clean-up is complete. :exc:`asyncio.CancelledError` directly subclasses
+:exc:`BaseException` so most code will not need to be aware of it.
The asyncio components that enable structured concurrency, like
:class:`asyncio.TaskGroup` and :func:`asyncio.timeout`,
are implemented using cancellation internally and might misbehave if
a coroutine swallows :exc:`asyncio.CancelledError`. Similarly, user code
-should not call :meth:`uncancel <asyncio.Task.uncancel>`.
+should not generally call :meth:`uncancel <asyncio.Task.uncancel>`.
+However, in cases when suppressing :exc:`asyncio.CancelledError` is
+truly desired, it is necessary to also call ``uncancel()`` to completely
+remove the cancellation state.
.. _taskgroups:
@@ -1137,7 +1141,9 @@ Task Object
Therefore, unlike :meth:`Future.cancel`, :meth:`Task.cancel` does
not guarantee that the Task will be cancelled, although
suppressing cancellation completely is not common and is actively
- discouraged.
+ discouraged. Should the coroutine nevertheless decide to suppress
+ the cancellation, it needs to call :meth:`Task.uncancel` in addition
+ to catching the exception.
.. versionchanged:: 3.9
Added the *msg* parameter.
@@ -1227,6 +1233,10 @@ Task Object
with :meth:`uncancel`. :class:`TaskGroup` context managers use
:func:`uncancel` in a similar fashion.
+ If end-user code is, for some reason, suppresing cancellation by
+ catching :exc:`CancelledError`, it needs to call this method to remove
+ the cancellation state.
+
.. method:: cancelling()
Return the number of pending cancellation requests to this Task, i.e.,
diff --git a/Lib/asyncio/timeouts.py b/Lib/asyncio/timeouts.py
index d07b291005e..029c468739b 100644
--- a/Lib/asyncio/timeouts.py
+++ b/Lib/asyncio/timeouts.py
@@ -84,6 +84,7 @@ class Timeout:
async def __aenter__(self) -> "Timeout":
self._state = _State.ENTERED
self._task = tasks.current_task()
+ self._cancelling = self._task.cancelling()
if self._task is None:
raise RuntimeError("Timeout should be used inside a task")
self.reschedule(self._when)
@@ -104,10 +105,10 @@ class Timeout:
if self._state is _State.EXPIRING:
self._state = _State.EXPIRED
- if self._task.uncancel() == 0 and exc_type is exceptions.CancelledError:
- # Since there are no outstanding cancel requests, we're
+ if self._task.uncancel() <= self._cancelling and exc_type is exceptions.CancelledError:
+ # Since there are no new cancel requests, we're
# handling this.
- raise TimeoutError
+ raise TimeoutError from exc_val
elif self._state is _State.ENTERED:
self._state = _State.EXITED
diff --git a/Lib/test/test_asyncio/test_timeouts.py b/Lib/test/test_asyncio/test_timeouts.py
index 3a54297050f..cc8feb8d0c1 100644
--- a/Lib/test/test_asyncio/test_timeouts.py
+++ b/Lib/test/test_asyncio/test_timeouts.py
@@ -248,6 +248,36 @@ class TimeoutTests(unittest.IsolatedAsyncioTestCase):
async with asyncio.timeout(0.01):
await asyncio.sleep(10)
+ async def test_timeout_after_cancellation(self):
+ try:
+ asyncio.current_task().cancel()
+ await asyncio.sleep(1) # work which will be cancelled
+ except asyncio.CancelledError:
+ pass
+ finally:
+ with self.assertRaises(TimeoutError):
+ async with asyncio.timeout(0.0):
+ await asyncio.sleep(1) # some cleanup
+
+ async def test_cancel_in_timeout_after_cancellation(self):
+ try:
+ asyncio.current_task().cancel()
+ await asyncio.sleep(1) # work which will be cancelled
+ except asyncio.CancelledError:
+ pass
+ finally:
+ with self.assertRaises(asyncio.CancelledError):
+ async with asyncio.timeout(1.0):
+ asyncio.current_task().cancel()
+ await asyncio.sleep(2) # some cleanup
+
+ async def test_timeout_exception_cause (self):
+ with self.assertRaises(asyncio.TimeoutError) as exc:
+ async with asyncio.timeout(0):
+ await asyncio.sleep(1)
+ cause = exc.exception.__cause__
+ assert isinstance(cause, asyncio.CancelledError)
+
if __name__ == '__main__':
unittest.main()
diff --git a/Misc/NEWS.d/next/Library/2023-03-22-16-15-18.gh-issue-102780.NEcljy.rst b/Misc/NEWS.d/next/Library/2023-03-22-16-15-18.gh-issue-102780.NEcljy.rst
new file mode 100644
index 00000000000..2aaffe34b86
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2023-03-22-16-15-18.gh-issue-102780.NEcljy.rst
@@ -0,0 +1,3 @@
+The :class:`asyncio.Timeout` context manager now works reliably even when performing cleanup due
+to task cancellation. Previously it could raise a
+:exc:`~asyncio.CancelledError` instead of an :exc:`~asyncio.TimeoutError` in such cases.