agronholm / agronholm/anyio

_deliver_cancellation re-arms for tasks skipped via _must_cancel, for which the retry can never do anything

Abierto
#1,258 0 comentarios 0 reacciones 0 asignados Ver en GitHub
Lenguaje dominante
Python
Estrellas
2.5k
Forks
260
Merge medio
2 d 8 h
PR fusionados (30 d)
19

Descripción

### Things to check first

- [x] I have searched the existing issues and didn't find my bug already reported there
- [x] I have checked that my bug is still present in the latest release

### AnyIO version

4.14.2 (also current master)

### Python version

3.12.11

### What happened?

Split out of #1257 as the standalone, conforming-parts core.

In `_deliver_cancellation`, a task with `task._must_cancel` set arms `should_retry` before being skipped:

```python
should_retry = True
if task._must_cancel:
continue
```

The retry can never do anything for that task. `_must_cancel` means CPython will raise `CancelledError` into it at its next step with no help from anyio, and anyio's only action (`task.cancel()`) is skipped anyway. So while such a task sits in a cancelled scope, the scope reschedules itself via `call_soon` every loop iteration until the task steps.

The state is reachable with stock primitives. `Future.cancel()` returns `False` when the future is already done, and a task's waiter can be done before its `__wakeup` has run. A `Task.cancel()` in that window takes CPython's `_must_cancel` branch.

On a fair loop the pending `__wakeup` runs promptly, so the cost here is a few wasted callbacks per occurrence. Anything that delays or prevents that step turns the same code path into a busy-spin (see #1257).

Suggested direction: when skipping a task on `_must_cancel`, don't drive the retry by polling; re-arm from `task._fut_waiter.add_done_callback(...)` when a waiter exists (in this repro the waiter is already done, so behavior is identical), and keep the retry armed in some form — a task may legally swallow the pending `CancelledError` and re-park, and redelivery must still work.

### How can we reproduce the bug?

```python
import asyncio
import anyio
from anyio._backends._asyncio import CancelScope

passes = []
_orig = CancelScope._deliver_cancellation

def counting(self, origin): # observation only
cur = asyncio.current_task()
would_deliver = mc_skips = 0
for t in self._tasks:
if t.done():
continue
if t._must_cancel:
mc_skips += 1
continue
if t is not cur:
w = t._fut_waiter
if not isinstance(w, asyncio.Future) or not w.done():
would_deliver += 1
ret = _orig(self, origin)
passes.append((would_deliver, mc_skips, ret))
return ret

CancelScope._deliver_cancellation = counting

async def main():
fut = asyncio.get_running_loop().create_future()
parked_task = None

async def parked():
nonlocal parked_task
parked_task = asyncio.current_task()
await fut

async with anyio.create_task_group() as tg:
tg.start_soon(parked)
await anyio.sleep(0.01)

fut.set_result("ok") # waiter done, __wakeup still pending
assert parked_task.cancel() is True # Future.cancel() -> False (done): _must_cancel set
assert parked_task._must_cancel is True

tg.cancel_scope.cancel()

print("(would_deliver, must_cancel_skips, should_retry) per pass:")
for p in passes:
print(f" {p}")

asyncio.run(main())
```

Output on 4.14.2 and master:

```
(would_deliver, must_cancel_skips, should_retry) per pass:
(0, 1, True)
(0, 0, True)
(1, 0, True)
(0, 0, True)
```

The first pass delivers nothing, skips the task on `_must_cancel`, and arms a retry that cannot contribute anything to that task's cancellation.

Guía de contribución

Abrir la guía de contribución

Línea de trabajo

Start at anyio._backends._asyncio.CancelScope._deliver_cancellation and run the standalone reproducer from the issue on Python 3.12. Trace the _must_cancel and _fut_waiter paths; done means the skipped task no longer causes futile polling while cancellation can still be redelivered if it swallows CancelledError and re-parks.

Escrito por el modelo de indexación a partir del texto del issue.

Evaluación

Stack tecnológico
python
Área
backend
Tipo de issue
Error
Dificultad
4/5
Tiempo estimado
3-5 días
Estado de actividad
Tranquilo
Claridad
Bastante claro
Aptitud para principiantes
48/100

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.