agronholm / agronholm/anyio

CancelScope can swallow a concurrent native asyncio.Task.cancel() when the scope is also cancelled

オープン
#1,214 コメント 2 件 リアクション 0 件 担当者 0 名 GitHub で見る
主要言語
Python
スター
2.5k
フォーク
260
平均マージ
1日 8時間
マージ済み PR(30日)
17

説明

### Things to check first

- [x] I searched the existing issues and did not find this exact bug already reported.
- [x] I verified that the bug is present in the latest release.

### AnyIO version

4.14.1 (also reproduced with 4.13.0)

### Python version

3.12.10

### What happened?

We came across this while investigating a production hang in an asyncio service that streams HTTP responses. When a new user event supersedes an in-flight response, the service calls `Task.cancel()` and waits for the streaming task to stop. In this case `Task.cancel()` was accepted, but the underlying request coroutine returned normally instead of propagating `CancelledError`. That left the higher-level cleanup path stuck waiting for the task to settle.

The unusual clue was the task state: `cancelled()` was `False`, while `cancelling()` was still `1`. In other words, asyncio still recorded a cancellation request, but no cancellation exception was propagating anymore.

Following the cancellation down through the HTTP networking stack led us to `anyio.connect_tcp()`. The failure happened during the early connection window where a successful Happy Eyeballs attempt cancels the task group containing the other connection attempts. That suggested that AnyIO's internal housekeeping cancellation and our caller's cancellation might be landing almost simultaneously. The standalone reproduction below isolates that race without HTTP or any third-party libraries.

In plain terms, the sequence appears to be:

1. A child cancels its AnyIO task group, which requests cancellation of the host task.
2. Before the host task processes that request, an unrelated caller invokes native `Task.cancel()`. It returns `True`, so the caller reasonably expects cancellation to propagate.
3. In this schedule, asyncio delivers the AnyIO-tagged `CancelledError`. `CancelScope.__exit__()` recognizes it as the scope's own cancellation, suppresses it, and calls `uncancel()` for the request AnyIO initiated.
4. The external cancellation count remains, but there is no second `CancelledError` waiting to be delivered. The task therefore returns normally with `cancelled() == False` and `cancelling() == 1`.

I expected the cancel scope to consume only the cancellation request it initiated. A separate native cancellation should remain observable, so awaiting the task should raise `CancelledError` and `task.cancelled()` should become `True`.

This seems related to the native-cancellation contract discussed in #374, but I could not find an issue covering this concurrent-cancellation case.

### How can we reproduce the bug?

This example uses only public asyncio and AnyIO APIs:

```python
import asyncio

import anyio

async def operation(
attempt_started: asyncio.Event,
connection_won: asyncio.Event,
) -> None:
async with anyio.create_task_group() as task_group:
async def connect_attempt() -> None:
attempt_started.set()
await connection_won.wait()

# Same pattern used by anyio.connect_tcp(): the first successful
# Happy Eyeballs attempt cancels the remaining attempts.
task_group.cancel_scope.cancel()

task_group.start_soon(connect_attempt)
await anyio.sleep_forever()

async def main() -> None:
attempt_started = asyncio.Event()
connection_won = asyncio.Event()
task = asyncio.create_task(operation(attempt_started, connection_won))
await attempt_started.wait()

external_cancel_results: list[bool] = []

def externally_cancel() -> None:
external_cancel_results.append(task.cancel("external cancellation"))

# This queues connect_attempt first. It cancels its AnyIO task group.
# The native cancellation callback then runs before the host task
# processes the AnyIO cancellation.
connection_won.set()
asyncio.get_running_loop().call_soon(externally_cancel)

try:
await task
except asyncio.CancelledError as exc:
outcome = f"CancelledError propagated: {exc!r}"
else:
outcome = "returned normally"

print(f"Task.cancel() returned: {external_cancel_results}")
print(f"Outcome: {outcome}")
print(f"task.cancelled(): {task.cancelled()}")
print(f"task.cancelling(): {task.cancelling()}")

asyncio.run(main())
```

Run with:

```console
uv run --no-project --with 'anyio==4.14.1' python anyio_cancel_repro.py
```

Actual output:

```text
Task.cancel() returned: [True]
Outcome: returned normally
task.cancelled(): False
task.cancelling(): 1
```

Expected output:

```text
Task.cancel() returned: [True]
Outcome: CancelledError propagated: CancelledError('external cancellation')
task.cancelled(): True
task.cancelling(): 1
```

コントリビューションガイド

コントリビューションガイドを開く

評価

この issue はまだ評価されていません。

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。