-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
PYTHON-4981 - Create workaround for asyncio.Task.cancelling support in older Python versions #2009
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
# Copyright 2024-present MongoDB, Inc. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
"""A custom asyncio.Task that allows checking if a task has been sent a cancellation request. | ||
Can be removed once we drop Python 3.10 support in favor of asyncio.Task.cancelling.""" | ||
|
||
|
||
from __future__ import annotations | ||
|
||
import asyncio | ||
import sys | ||
from typing import Any, Coroutine, Optional | ||
|
||
|
||
# TODO (https://jira.mongodb.org/browse/PYTHON-4981): Revisit once the underlying cause of the swallowed cancellations is uncovered | ||
class _Task(asyncio.Task): | ||
def __init__(self, coro: Coroutine[Any, Any, Any], *, name: Optional[str] = None) -> None: | ||
super().__init__(coro, name=name) | ||
self._cancel_requests = 0 | ||
asyncio._register_task(self) | ||
|
||
def cancel(self, msg: Optional[str] = None) -> bool: | ||
self._cancel_requests += 1 | ||
return super().cancel(msg=msg) | ||
|
||
def uncancel(self) -> int: | ||
if self._cancel_requests > 0: | ||
self._cancel_requests -= 1 | ||
return self._cancel_requests | ||
|
||
def cancelling(self) -> int: | ||
return self._cancel_requests | ||
|
||
|
||
def create_task(coro: Coroutine[Any, Any, Any], *, name: Optional[str] = None) -> asyncio.Task: | ||
if sys.version_info >= (3, 11): | ||
return asyncio.create_task(coro, name=name) | ||
return _Task(coro, name=name) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay if we're going down this route, can we at least only use this workaround on <=3.10?
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we're fine with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How would they differ? Oh I see, the stdlib returns a count of the cancellation requests as an integer. Is that what you mean? Also I see this:
So we are going against their guidance in multiple ways. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes let's implement the cancel/uncancel counter pattern. It's simple enough. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can't use private asyncio apis. Can we do this pattern instead?:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I see the problem. That wouldn't work because asyncio.all_tasks() would return the non-wrapper class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The problem still remains though. If we can't do this using only public apis then we can't add this workaround at all.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any implementation needs to support our
_Task
getting cancelled by external components likeunittest
orpytest
. The approach you outline, where_Task
is just a wrapper around the actual task, doesn't work in this case: the loop will only interact with the task it owns, rather than the_Task
itself. When our testing framework goes to cancel all remaining tasks, it only has access to the loop, which won't have a reference to any_Task
instances.Subclassing
asyncio.Task
and overriding thecancel
method to support pre-3.11 Python versions solves this issue.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
asyncio._register_task
is documented in the asyncio docs here: https://docs.python.org/3/library/asyncio-extending.html as the only way to extendTask
functionality.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The above passes all tests for me locally and doesn't use any private
asyncio
methods or import hacking, onlyMethodType
tricks.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we own the task and its cancellation, can't we keep a mapping of tasks to cancelled state ourselves?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't own the cancellation when the test runner cancels the task at the end of the test that created it. We could write our own teardown method that uses such a mapping to run before the runner's teardown, but that seems messy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay I relent for now since we plan remove this code anyway once we figure out the real issue causing the hangs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll add a TODO for us to come back and revisit this at that time.