gh-90908: Document asyncio.Task.cancelling() and asyncio.Task.uncancel() by ambv · Pull Request #95253 · python/cpython · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 128 additions & 74 deletions Doc/library/asyncio-task.rst
4 changes: 2 additions & 2 deletions Lib/asyncio/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,8 @@ def cancelling(self):
def uncancel(self):
"""Decrement the task's count of cancellation requests.

This should be used by tasks that catch CancelledError
and wish to continue indefinitely until they are cancelled again.
This should be called by the party that called `cancel()` on the task
beforehand.
Comment on lines -246 to +247

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous docstring was invalid, we actively don't want for user code to call uncancel() when catching CancelledError.


Returns the remaining number of cancellation requests.
"""
Expand Down
128 changes: 124 additions & 4 deletions Lib/test/test_asyncio/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ async def task():
finally:
loop.close()

def test_uncancel(self):
def test_uncancel_basic(self):
loop = asyncio.new_event_loop()

async def task():
Expand All @@ -534,17 +534,137 @@ async def task():
try:
t = self.new_task(loop, task())
loop.run_until_complete(asyncio.sleep(0.01))
self.assertTrue(t.cancel()) # Cancel first sleep

# Cancel first sleep
self.assertTrue(t.cancel())
self.assertIn(" cancelling ", repr(t))
self.assertEqual(t.cancelling(), 1)
self.assertFalse(t.cancelled()) # Task is still not complete
loop.run_until_complete(asyncio.sleep(0.01))
self.assertNotIn(" cancelling ", repr(t)) # after .uncancel()
self.assertTrue(t.cancel()) # Cancel second sleep

# after .uncancel()
self.assertNotIn(" cancelling ", repr(t))
self.assertEqual(t.cancelling(), 0)
self.assertFalse(t.cancelled()) # Task is still not complete

# Cancel second sleep
self.assertTrue(t.cancel())
self.assertEqual(t.cancelling(), 1)
self.assertFalse(t.cancelled()) # Task is still not complete
with self.assertRaises(asyncio.CancelledError):
loop.run_until_complete(t)
self.assertTrue(t.cancelled()) # Finally, task complete
self.assertTrue(t.done())

# uncancel is no longer effective after the task is complete
t.uncancel()
self.assertTrue(t.cancelled())
self.assertTrue(t.done())
finally:
loop.close()

def test_uncancel_structured_blocks(self):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like you to look at this test and tell me if you think anything here (esp. the comments!) is not factual.

# This test recreates the following high-level structure using uncancel()::
#
# async def make_request_with_timeout():
# try:
# async with asyncio.timeout(1):
# # Structured block affected by the timeout:
# await make_request()
# await make_another_request()
# except TimeoutError:
# pass # There was a timeout
# # Outer code not affected by the timeout:
# await unrelated_code()

loop = asyncio.new_event_loop()

async def make_request_with_timeout(*, sleep: float, timeout: float):
task = asyncio.current_task()
loop = task.get_loop()

timed_out = False
structured_block_finished = False
outer_code_reached = False

def on_timeout():
nonlocal timed_out
timed_out = True
task.cancel()

timeout_handle = loop.call_later(timeout, on_timeout)
try:
try:
# Structured block affected by the timeout
await asyncio.sleep(sleep)
structured_block_finished = True
finally:
timeout_handle.cancel()
if (
timed_out
and task.uncancel() == 0
and sys.exc_info()[0] is asyncio.CancelledError
):
# Note the five rules that are needed here to satisfy proper

@graingert graingert Aug 4, 2022

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Canceling the stimulus (timeouts), or guarding against cancelling after calling uncancel() (TaskGroup and Runner) I think is a sixth rule

Suggested change

# uncancellation:
#
# 1. handle uncancellation in a `finally:` block to allow for
# plain returns;
# 2. our `timed_out` flag is set, meaning that it was our event
# that triggered the need to uncancel the task, regardless of
# what exception is raised;
# 3. we can call `uncancel()` because *we* called `cancel()`
# before;
# 4. we call `uncancel()` but we only continue converting the
# CancelledError to TimeoutError if `uncancel()` caused the
# cancellation request count go down to 0. We need to look
# at the counter vs having a simple boolean flag because our
# code might have been nested (think multiple timeouts). See
# commit 7fce1063b6e5a366f8504e039a8ccdd6944625cd for

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that commit 7d611b4 changes the behavior again.

# details.
# 5. we only convert CancelledError to TimeoutError; for other
# exceptions raised due to the cancellation (like
# a ConnectionLostError from a database client), simply
# propagate them.
#
# Those checks need to take place in this exact order to make

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Those checks" == (2), (4), (5), right? Because (1) and (3) don't describe "checks".

Other than that nit, these comments seem correct, and the code looks so too (but I'm not taking money :-).

(Another nit: the huge comment interrupts the logic. Maybe move it to the top of the test function?)

# sure the `cancelling()` counter always stays in sync.
#
# Additionally, the original stimulus to `cancel()` the task
# needs to be unscheduled to avoid re-cancelling the task later.
# Here we do it by cancelling `timeout_handle` in the `finally:`
# block.
raise TimeoutError
except TimeoutError:
self.assertTrue(timed_out)

# Outer code not affected by the timeout:
outer_code_reached = True
await asyncio.sleep(0)
Comment thread
ambv marked this conversation as resolved.
return timed_out, structured_block_finished, outer_code_reached

# Test which timed out.
t1 = self.new_task(loop, make_request_with_timeout(sleep=10.0, timeout=0.1))
timed_out, structured_block_finished, outer_code_reached = (
loop.run_until_complete(t1)
)
self.assertTrue(timed_out)
self.assertFalse(structured_block_finished) # it was cancelled
self.assertTrue(outer_code_reached) # task got uncancelled after leaving
# the structured block and continued until
# completion
self.assertEqual(t1.cancelling(), 0) # no pending cancellation of the outer task

# Test which did not time out.
t2 = self.new_task(loop, make_request_with_timeout(sleep=0, timeout=10.0))
timed_out, structured_block_finished, outer_code_reached = (
loop.run_until_complete(t2)
)
self.assertFalse(timed_out)
self.assertTrue(structured_block_finished)
self.assertTrue(outer_code_reached)
self.assertEqual(t2.cancelling(), 0)

def test_cancel(self):

def gen():
Expand Down