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
平均マージ
2日 8時間
マージ済み PR(30日)
19

説明

### 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
```

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

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

調査の方向性

Run the standalone anyio_cancel_repro.py reproduction with AnyIO 4.14.1, then inspect CancelScope.__exit__ and the cancellation path reached from anyio.connect_tcp(). The work is done when the external native cancellation remains observable, producing CancelledError and task.cancelled() == True instead of returning normally.

索引モデルが issue の本文から書いたものです。

評価

技術スタック
python
領域
backend
issue の種類
バグ
難易度
4/5
見積もり時間
3〜5日
活発さ
活発
明瞭さ
おおむね明確
初心者へのやさしさ
48/100

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

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