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

If cancellation is scheduled for current task, have a way of waiting for the cancellation to take place. #520

Open
jonathanslenders opened this issue Jan 11, 2023 · 1 comment

Comments

@jonathanslenders
Copy link
Contributor

We have a situation where the cancellation of a cancel scope can't be delivered to a task, because it's not yet started. So, anyio will retry the delivering of the cancellation to this task using call_soon. (I'm using the asyncio backend.)

This situation causes a race condition where the task is started anyway, even able to create a new task group, spawn sub tasks in there, have these subtasks raise an exception and only after that realizing it was actually cancelled raising an ExceptionGroup of both a CancelledError and our own exception.

I'd like to be able to check whether cancellation is scheduled for our current task, and if so, don't proceed doing other stuff, but wait for the cancellation to take place.

Apparently, adding an await sleep(0) isn't sufficient as a check point to allow the cancellation to take place. The cancellation seems to be scheduled only when doing await sleep(0.1) or something like that, but a random sleep value doesn't feel right. As a workaround, I found that the following seems to work:

from anyio.lowlevel import get_async_backend
from anyio import sleep_forever

if get_async_backend().current_effective_deadline() < 0:
    await anyio.sleep_forever()

However, I'd like to know whether doing something like this makes sense, or whether there's a bug somewhere? I'd expect for instance that entering a task group (create_task_group().__aenter__()) would be considered a checkpoint where cancellation can take place, and where we could do exactly the above code.

I've been looking to create a small reproducer script, but so far failed to reproduce the more complex scenario we currently have.

@jonathanslenders
Copy link
Contributor Author

Actually, the checkpoint_if_cancelled function does more or less what I want, but the sleep(0) in there is not sufficient, because it could be that the cancellation is only scheduled, so asyncio will not yet throw a CancelledError in there.

What I have now as a workaround that works both on master and on 3.6.2 is this:

async def wait_for_cancellation_if_cancelled() -> None:
    """
    If cancellation is scheduled for the current task, wait until the
    cancellation takes place and propagate the `CancelledError` in that case.

    This is needed in some situation to avoid doing stuff if we know that we're
    going to be cancelled. When a cancel scope (or task group) gets cancelled,
    the cancellation will be delivered to all containing tasks. If these tasks
    are not yet started, the cancellation will be scheduled using `call_soon`.
    This means that at some point, a task can be started, knowing that it will
    be cancelled *very soon*, possibly at the first `await` checkpoint, if it
    takes long enough for the `call_soon` callbacks to be processed. In this
    case, using `await sleep(0)` is typically not long enough for all
    `call_soon` callbacks to run.
    """
    try:
        # Anyio 4.0 (master branch.)
        from anyio.lowlevel import get_async_backend
    except ImportError:
        # Anyio 3.6.2 (implementation only works for asyncio).
        from anyio._backends._asyncio import _task_states, current_task

        def is_cancelled() -> bool:
            # Partly copied from `checkpoint_if_cancelled` in Anyio's asyncio
            # backend.
            task = current_task()
            if task is None:
                return False
            try:
                cancel_scope = _task_states[task].cancel_scope
            except KeyError:
                return False
            while cancel_scope:
                if cancel_scope.cancel_called:
                    return True
                elif cancel_scope.shield:
                    break
                else:
                    cancel_scope = cancel_scope._parent_scope

            return False

        cancelled = is_cancelled()
    else:
        cancelled = get_async_backend().current_effective_deadline() < 0

    if cancelled:
        # Will soon raise `CancelledError`.
        await anyio.sleep_forever()

I notice that this fix here: https://github.com/agronholm/anyio/pull/485/files is not yet part of the latest release on PyPI, possibly because it's for Anyio 4.0? Because of that, in the above snippet I can't use current_effective_deadline on 3.6.2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant