Skip to content

asyncio CapacityLimiter lost token on cancellation #947

Closed

Description

@i-vovk

Things to check first

  • I have searched the existing issues and didn't find my bug already reported there

  • I have checked that my bug is still present in the latest release

AnyIO version

4.9.0

Python version

3.11

What happened?

I have encountered a situation in production code when the state of capacity limiter (asyncio implementation) is this

borrowed_tokens = 0
total_tokens = 40
tasks_waiting = 14  # and growing as time passes

As a result, no new tasks can be executed; they all end up in the wait queue, but there are no borrowers to signal them. The race seems to happen in acquire_on_behalf_of when it gets WouldBlock

try:
    self.acquire_on_behalf_of_nowait(borrower)
except WouldBlock:
    # 1. limiter is full -> put ourself in queue
    event = asyncio.Event()
    self._wait_queue[borrower] = event
    try:
        # 2. return control to event loop and wait
        await event.wait()
    except BaseException:
        # 3. Imagine 
        #    - the waiting task gets cancelled (from outside or by timeout), and 
        #    - current borrower finished -> calls return() -> calls event.set()
        #  so, event loop resumed on `await event.wait()` with `CancelError` and the event is set
        # I assume event loop has a choice here on which one of these to resume execution, and it chooses cancel
        # so we were given a token, but been cancelled
        # we pop ourselves from the waiting queue, but as the event is set - token has been given to us, 
        # but we aren't using it, so it will be lost if we don't pass it to the next waiter
        self._wait_queue.pop(borrower, None)
        raise

    self._borrowers.add(borrower)

I have made this fix and we haven't encountered such a scenario since

def _wake_next_waiter(self) -> None:
    """
    Wake up the next waiter in the queue if there is capacity.
    This is the core logic that should be called whenever a token slot might have become free.
    """
    if self._wait_queue and len(self._borrowers) < self._total_tokens:
        # Pop the waiter from the queue to "reserve" the slot for them
        event = self._wait_queue.popitem(last=False)[1]
        event.set()

async def acquire_on_behalf_of(self, borrower: object) -> None:
    await AsyncIOBackend.checkpoint_if_cancelled()
    try:
        self.acquire_on_behalf_of_nowait(borrower)
    except WouldBlock:
        event = asyncio.Event()
        self._wait_queue[borrower] = event
        try:
            await event.wait()
        except BaseException:
            self._wait_queue.pop(borrower, None)

            # If the event is set, it means we were given a token by a
            # release() call, but we were cancelled before we could claim it
            # We are now responsible for passing the token on to the next waiter.
            if event.is_set():
                self._wake_next_waiter()

            raise

        self._borrowers.add(borrower)
    else:
        try:
            await AsyncIOBackend.cancel_shielded_checkpoint()
        except BaseException:
            self.release_on_behalf_of(borrower)
            raise

How can we reproduce the bug?

I used this piece to reproduce the exact scenario we encountered

import asyncio

import anyio
import pytest
from anyio._backends._asyncio import CapacityLimiter
from anyio.abc import CancelScope
from more_itertools import one
from pytest import MonkeyPatch


@pytest.fixture
def anyio_backend():
    return "asyncio"


VIRTUAL_LOG: list[str] = []


class SlowEvent(asyncio.Event):
    async def wait(self):
        VIRTUAL_LOG.append("SlowEvent.wait")
        await super().wait()
        await asyncio.sleep(0)


async def releaser_task(
    limiter: CapacityLimiter, token_acquired: asyncio.Event, release_token: asyncio.Event
) -> None:
    """Acquires the token, waits for a signal, then releases it."""

    VIRTUAL_LOG.append("Releaser: trying to acquire...")
    async with limiter:
        VIRTUAL_LOG.append("Releaser: ...acquired.")
        token_acquired.set()
        await release_token.wait()
        VIRTUAL_LOG.append("Releaser: ...releasing token now.")

    # The token is released on __aexit__
    # Yield control to allow the controller to cancel the waiter
    await asyncio.sleep(0)
    VIRTUAL_LOG.append("Releaser: finished.")


async def waiter_task(
    limiter: CapacityLimiter, token_waiting: asyncio.Event, waiter_scope_box: list[CancelScope]
) -> None:
    """The task that will be cancelled at the critical moment."""

    with anyio.CancelScope() as scope:
        waiter_scope_box.append(scope)
        VIRTUAL_LOG.append("Waiter: trying to acquire...")
        token_waiting.set()
        async with limiter:
            pytest.fail("Waiter task was not cancelled and acquired the lock.")

    VIRTUAL_LOG.append("Waiter: finished (was cancelled).")


async def stranded_task(limiter: CapacityLimiter, token_acquired: asyncio.Event) -> None:
    """This task should get stuck waiting for the 'lost' token."""

    VIRTUAL_LOG.append("Stranded: trying to acquire...")
    async with limiter:
        VIRTUAL_LOG.append("Stranded: ...acquired. The test has failed.")
        token_acquired.set()

    pytest.fail("Stranded task should never finish.")


@pytest.mark.asyncio
async def test_capacity_limiter_has_cancel_race_deadlock(
    monkeypatch: MonkeyPatch
) -> None:
    """
    Tests for the specific deadlock scenario in CapacityLimiter where a waiting
    task is cancelled just after it has been woken up by a release, but before
    it has a chance to run and claim the token.

    This causes the token to be "lost", stranding other waiting tasks.
    """

    limiter = CapacityLimiter(1)
    monkeypatch.setattr("anyio._backends._asyncio.asyncio.Event", SlowEvent)

    # Events to control the task execution order
    releaser_has_acquired = asyncio.Event()
    waiter_is_waiting = asyncio.Event()
    release_the_token = asyncio.Event()

    # A place to store the waiter's cancel scope so we can cancel it from the test
    waiter_scope_box: list[CancelScope] = []

    # The task that will be stranded if the deadlock occurs
    stranded_task_finished = asyncio.Event()

    anyio.create_task_group
    async with anyio.create_task_group() as tg:
        # 1. Start the releaser and wait for it to acquire the only token.
        tg.start_soon(releaser_task, limiter, releaser_has_acquired, release_the_token)
        await releaser_has_acquired.wait()
        assert limiter.borrowed_tokens == 1

        # 2. Start the waiter task and wait for it to be blocked.
        tg.start_soon(waiter_task, limiter, waiter_is_waiting, waiter_scope_box)
        await waiter_is_waiting.wait()

        # Give the waiter a chance to actually enter the limiter's wait queue
        await asyncio.sleep(0)
        assert limiter.borrowed_tokens == 1
        assert len(limiter._wait_queue) == 1

        # 3. Start the stranded task, which will wait behind the first waiter.
        tg.start_soon(stranded_task, limiter, stranded_task_finished)
        await asyncio.sleep(0)
        assert len(limiter._wait_queue) == 2

        # 4. THE RACE: Tell the releaser to release the token.
        # The limiter will wake up the waiter_task.
        # Then, immediately cancel the waiter_task before it gets a chance to run.
        VIRTUAL_LOG.append("Controller: Triggering release and immediate cancel...")
        release_the_token.set()

        # Yield control. The releaser will run, release the token (waking the waiter),
        # and then yield again. The waiter is now "ready", but we get control back.
        await asyncio.sleep(0)
        # Now, cancel the waiter task.
        one(waiter_scope_box).cancel()
        VIRTUAL_LOG.append("Controller: ...waiter has been cancelled.")

        # 5. Check if the stranded_task is stuck.
        # We give it a short timeout.
        # If it were going to acquire the lock, it would do so almost instantly.
        VIRTUAL_LOG.append("Controller: Waiting to see if stranded_task acquires the token...")
        with anyio.move_on_after(0.1) as scope:
            await stranded_task_finished.wait()

        # The deadlock occurred if the timeout was reached.
        assert scope.cancelled_caught is True, "Deadlock did not occur, stranded_task finished."

        VIRTUAL_LOG.append("Controller: Stranded_task did not finish, as expected.")

        # 6. Final state check.
        #  The token was lost. No one is borrowing, but the stranded_task is still waiting.
        assert limiter.borrowed_tokens == 0
        assert len(limiter._wait_queue) == 1
        VIRTUAL_LOG.append("Controller: limiter state is corrupted")

        # exit the test
        tg.cancel_scope.cancel()

Activity

  1. agronholm commented on Jul 23, 2025

    @agronholm
    Owner

    OK, so given that AnyIO cancel scopes won't cancel tasks scheduled to resume, as would be the case for a task waiting for an event and that even would be set, it looks like the sequence to trigger this is that the task is first cancelled, and only then is the event set. I've come up with a somewhat simpler reproducer, using only AnyIO APIs:

    import pytest
    
    import anyio
    from anyio import CapacityLimiter, Event, move_on_after
    from anyio.abc import CancelScope, TaskStatus
    from anyio.lowlevel import checkpoint
    
    
    @pytest.mark.anyio
    async def test_acquire_cancelled() -> None:
        limiter = CapacityLimiter(1)
    
        async def borrower(event: Event, *, task_status: TaskStatus[CancelScope]) -> None:
            with CancelScope() as scope:
                task_status.started(scope)
                async with limiter:
                    event.set()
                    await checkpoint()
    
        async with anyio.create_task_group() as tg:
            async with limiter:
                event1 = Event()
                scope1 = await tg.start(borrower, event1)
                event2 = Event()
                await tg.start(borrower, event2)
                scope1.cancel()
    
            with move_on_after(0.1):
                await event2.wait()
                return
    
            tg.cancel_scope.cancel()
    
        pytest.fail("The second borrower failed to acquire the limiter")
  2. added a commit that references this issue on Jul 23, 2025
    aa69fdb
  3. added this to the 4.10 milestone on Jul 23, 2025
  4. i-vovk commented on Jul 23, 2025

    @i-vovk
    Author

    Nice, thanks, it was quick

  5. added a commit that references this issue on Jul 24, 2025
    19e5477
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

    bugSomething isn't working

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions