Skip to content

asyncio.timeout(0) and asyncio.timeout(-1) cancellation is delayed #95051

Description

@graingert

Bug report

I expect this to raise a TimeoutError:

async def amain():
    async with asyncio.timeout(-11):
        await asyncio.sleep(0)

asyncio.run(amain())

Instead I have to add an extra asyncio.sleep(0) to get a TimeoutError

async def amain():
    async with asyncio.timeout(-11):
        await asyncio.sleep(0)  # does not raise CancelledError
        await asyncio.sleep(0)  # raises CancelledError

asyncio.run(amain())

Your environment

  • CPython versions tested on:
  • Operating system and architecture:

Activity

  1. graingert commented on Jul 20, 2022

    @graingert
    ContributorAuthor

    This also makes refactoring asyncio.wait_for to use asyncio.timeout difficult: #31847 (comment) difficult

  2. Tinche commented on Jul 21, 2022

    @Tinche
    Contributor

    Sorry, deleted my last comment since it was wrong. I'm going to try to dive into this a little to get some understanding.

  3. Tinche commented on Jul 21, 2022

    @Tinche
    Contributor

    This is a race condition. Two things need to happen on the same event loop iteration, but it turns out A happens before B, and not B before A. I don't think asyncio has a spec anywhere saying this is wrong, so a legitimate response might be to just live with it.

    Maybe it's confusing to users though so we want to do something about it.

    The event loop maintains two callback lists:

    • self._ready - these are callbacks ready to be run right now. (actually a deque)
    • self._scheduled - these are callbacks that will be ready at a certain time t. t might be in the past. (a list, but actually a heapq)

    When processing a loop step, the event loop looks at _scheduled first, taking all callbacks that are due to run and appending them to _ready. So callbacks from _scheduled will run after the callbacks in _ready. Then it executes all callbacks in _ready.

    timeout uses loop.call_at under the hood, and call_at uses self._scheduled.
    sleep has a special case for 0 and negative values; this case uses loop.call_soon, and call_soon uses self._ready.

    So this explains the issue:

    1. loop iteration starts, amain() starts running
    2. timeout puts a callback into loop._scheduled
    3. sleep stops amain() and puts a callback into loop._ready
    4. Another loop iteration starts
    5. timeout callback gets appended to loop._ready, but the sleep callback is first
    6. sleep callback runs, drives the coroutine to completion
    7. no timeout exception, since when the timeout callback runs, the coroutine/task is done

    A couple of proposals how to handle this:

    A. do nothing, leave it up to the event loop how to resolve cases like this
    B. change timeout to special case delays <= 0 to use loop.call_soon instead of loop.call_at. This would make the timeout callback go first, but the fix feels narrowly scoped, might leave the race condition still to happen under a different timeout.
    C. special case loop.call_at to internally call loop._call_soon if the deadline is in the past.
    D. change how the event loop processes callbacks. The scheduled callbacks could be prepended to self._ready instead of appended. This would again make the timeout callback go first. Also feels more intuitive to me as far as callback scheduling goes. I guess it'd make the event loop do slightly more work. Might also be backwards-incompatible, but it doesn't matter since it's unspecified?

    My personal preference might be D, and then A.

    @gvanrossum I know you've been in this space recently, would appreciate your feedback.

  4. graingert commented on Jul 21, 2022

    @graingert
    ContributorAuthor

    C. special case loop.call_at to internally call loop._call_soon if the deadline is in the past.

    This is the uvloop behavior

  5. graingert commented on Jul 21, 2022

    @graingert
    ContributorAuthor

    I think it's probably too late in the 3.11 release cycle to touch something this low level in the event loop.

    I think tweaking asyncio.timeout to match the behavior of asyncio.wait_for(x, 0) and asyncio.sleep(0) makes the most sense to me

  6. gvanrossum commented on Jul 21, 2022

    @gvanrossum
    Member

    Tin, thanks for the deep dive! I have to agree with Thomas that we don't want to touch the event loop or call_at. But since timeout is new, I'd be okay if we tweaked that (i.e., B). If either of you want to produce a PR that includes a unit test demonstrating the need for a fix, I'd approve it and it might not be hard to convince Pablo to allow it in 3.11, but I think A is also acceptable for 3.11 (race conditions gonna race). For 3.12 we might be able to do C or D.

  7. Fidget-Spinner commented on Jul 24, 2022

    @Fidget-Spinner
    Member

    I'm not an asyncio expert, but I don't think we should go with D. Prepending may cause starvation in certain scenarios (the callbacks at the back of the deque may never get to run).

  8. Tinche commented on Jul 24, 2022

    @Tinche
    Contributor

    So out of curiosity I checked out what UVLoop does, since in my experience anyone who's serious about asyncio runs it anyway.

    They prepend the scheduled callbacks (well, lib uv does under the hood), so they do D. @graingert 's example works expectedly there.

    This increases my confidence in that approach, since I'm already using it in production ;)

    @Fidget-Spinner Not sure how callbacks at the end of the deque not running is any worse than callbacks in the heapq.

  9. added a commit that references this issue on Jul 24, 2022
  10. Tinche commented on Jul 24, 2022

    @Tinche
    Contributor

    So I did some more digging, especially through libuv (the library that powers NodeJS and uvloop).

    As far as I can see, libuv does what I'm proposing. On each iteration of the loop, expired timers (= callbacks with a deadline that is before now) get run first, and then the _ready set (which they call 'idle handlers').

  11. added a commit that references this issue on Jul 26, 2022
  12. kumaraditya303 commented on Oct 2, 2022

    @kumaraditya303
    Contributor

    Fixed by #95109

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    3.11only security fixes3.12only security fixestopic-asynciotype-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions