Skip to content
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

Catching CancelledError requires use of task.uncancel() #102780

Closed
kristjanvalur opened this issue Mar 17, 2023 · 10 comments
Closed

Catching CancelledError requires use of task.uncancel() #102780

kristjanvalur opened this issue Mar 17, 2023 · 10 comments
Labels
topic-asyncio type-bug An unexpected behavior, bug, or error

Comments

@kristjanvalur
Copy link
Contributor

kristjanvalur commented Mar 17, 2023

Bug report

Today I came across the following problem:

import asyncio
ready = asyncio.Event()
async def task_func():
    try:
        ready.set()
        await asyncio.sleep(1)
    except asyncio.CancelledError:
         # this is required, otherwise a TimeoutError is not created below,
         # instead, the timeout-generated CancelledError is just raised upwards.
        asyncio.current_task().uncancel()
    finally:
        # Perform some cleanup, with a timeout
        try:
            async with asyncio.timeout(0):
                await asyncio.sleep(1)
        except asyncio.TimeoutError:
            pass
    
async def main():
    task = asyncio.create_task(task_func())
    await ready.wait()
    await asyncio.sleep(0.01)
    # the task is now sleeping, lets send it an exception
    task.cancel()
    # We expect the task to finish cleanly.
    await task

asyncio.run(main())

The documentation mentions that sometimes an application may want to suppress CancelledError. What it fails
to mention is that unless the cancel state is subsequently cancelled with task.uncancel(), the task remains in a
cancelled state, and context managers such as asyncio.timeout will not work as designed. However, task.uncancel()
is not supposed to be called by user code.

I think the documentation should mention this use case for task.uncancel(), particularly in the context of catching (and choosing to ignore) CancelledError.

This could also be considered a bug in asyncio.timeout. It prevents the use of the Timeout context manager within cleanup code, even if the intention is not to ignore a CancelledError.

It should also be noted that the library asyncio_timeout on which the asyncio.timeout implementation is based, does not have this problem. Timeouts continue to work as designed, even if the task has been previously cancelled.

Your environment

  • CPython versions tested on: 3.11

Linked PRs

@gvanrossum
Copy link
Member

This should be treated as a documentation issue. The mechanism is essential for task groups.

@kristjanvalur
Copy link
Contributor Author

Yes, I wasn't quite sure about that.
Still, it means that cleanup code cannot use timeouts, if cleaning up after a cancel(). And the use of timeout context managers is widespread in asyncio libraries.

@gvanrossum
Copy link
Member

If you can propose a fix to the timeout code that would be great, I wasn’t aware of the issue there.

@kristjanvalur
Copy link
Contributor Author

This is the code in question:

async def __aexit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> Optional[bool]:
        assert self._state in (_State.ENTERED, _State.EXPIRING)

        if self._timeout_handler is not None:
            self._timeout_handler.cancel()
            self._timeout_handler = None

        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
                # handling this.
                raise TimeoutError
        elif self._state is _State.ENTERED:
            self._state = _State.EXITED

        return None

The test for the self._task.uncancel() return value is what is causing the problem. This was presumably added for a good reason, but its unclear to me what it is. Maybe whoever implemented this can comment?

I guess I could at least provide a PR for the docs, suggest that if the user chooses to suppress a CancelledError he must also call uncancel(). I don't know what implication that might or might not have for task groups, though.

@gvanrossum
Copy link
Member

Unfortunately @asvetlov is busy. Since I reviewed it I could try to have a look but it's been a while since I understood that code. Comparison to how and why task groups use uncancel() might help.

The proposed doc update sounds good.

@kristjanvalur
Copy link
Contributor Author

I think I know what is going on. Because exceptions are delivered asynchronously, the intent here is to guard against a race condition: A timeout expires, a cancel is scheduled to be delivered. At the same time, a proper cancel is also issued. We want the proper cancel to propagate and not be turned into a timeout error.

I think this is fixable, at least in the majority of cases, by comparing the cancel counter before and after entering. I'll see if I can make that happen without making too much rubble.

On the other hand, I think this also indicates what I think is a shortcoming with the asyncio api: There is currently no mechanism to send a general exception to a task. There is only cancel(), which sends one type of exception.

In stacklesslib we had a similar timeout mechanism, but it arranged for a custom exception to be sent to the tasklet. This exception had the triggering context manager instance as a an instance variable so there was no need to perform logic to deduce if it should be turned into a TimeoutError or not.

I have previously stopped short working with asyncio wanting to send a custom exception and not finding it possible. I would suggest that such an api be added. Even cancel() would benefit from having at least a subclass instance being optionally provided.

A complication in asyncio vs stackless is that in the latter, the exceptions could be raised synchronously. There was no race, where different exceptions might be sent to a tasklet, each overriding the previous. In asyncio, this could be accomplished by considering the CancelledError as special (which is already the case) and not having other exceptions override it.

Implementing a timeout by simply delivering a custom BaseException and not messing with the cancel mechanism (which looks very complex at this point, I just had a look at the source) would be far simpler. I don't think co-opting CancelledError is very sound anymore.

Anyway, just a few thoughts I wanted to get out of my head :)

@kumaraditya303
Copy link
Contributor

kumaraditya303 commented Mar 18, 2023

It should also be noted that the library asyncio_timeout on which the asyncio.timeout implementation is based, does not have this problem. Timeouts continue to work as designed, even if the task has been previously cancelled.

Seems worth looking into where's the difference between the two. I'll take a look at this and PR in more detail in next week.

@gvanrossum
Copy link
Member

On the other hand, I think this also indicates what I think is a shortcoming with the asyncio api: There is currently no mechanism to send a general exception to a task. There is only cancel(), which sends one type of exception.

This is at least worth a discussion, but I'd rather not continue in this thread. Could you create a new thread with the same thoughts? Given how much we've been struggling with cancellation I agree it deserves more consideration.

gvanrossum added a commit that referenced this issue Mar 22, 2023
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 <gvanrossum@gmail.com>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
miss-islington pushed a commit to miss-islington/cpython that referenced this issue Mar 22, 2023
…2815)

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 <sweskman@gmail.com>
Co-authored-by: Guido van Rossum <gvanrossum@gmail.com>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
@gvanrossum
Copy link
Member

Thanks for the fix, Kristjan. (And welcome back!) I'm backporting this to 3.11 as a bugfix.

miss-islington added a commit that referenced this issue Mar 22, 2023
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 <sweskman@gmail.com>
Co-authored-by: Guido van Rossum <gvanrossum@gmail.com>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
@kristjanvalur
Copy link
Contributor Author

. (And welcome back!) I

Thanks. I'm never far off, and when I notice curious and strange behaviour in Python, I try to help fix it. My first core fix in many years was in #95207

Fidget-Spinner pushed a commit to Fidget-Spinner/cpython that referenced this issue Mar 27, 2023
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 <gvanrossum@gmail.com>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
warsaw pushed a commit to warsaw/cpython that referenced this issue Apr 11, 2023
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 <gvanrossum@gmail.com>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
bdraco added a commit to home-assistant/core that referenced this issue Jul 20, 2023
vEpiphyte pushed a commit to vertexproject/synapse that referenced this issue Aug 31, 2023
…3321)

* Pull in asyncio.timeout implementation from Python 3.11.3
* Addresses issue from python/cpython#102780
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic-asyncio type-bug An unexpected behavior, bug, or error
Projects
Status: Done
Development

No branches or pull requests

4 participants