agronholm / agronholm/anyio

asyncio backend: race in SocketStream.aclose() — transport.abort() raises AttributeError: 'NoneType' object has no attribute 'call_soon'

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

説明

### 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 (4.14.1; the relevant code in `SocketStream.aclose()` is unchanged on current master)

### AnyIO version

4.14.1

### Python version

3.13.7 (also observed in production on CPython 3.13 / Linux)

### What happened?

On the asyncio backend, `SocketStream.aclose()` can raise
`AttributeError: 'NoneType' object has no attribute 'call_soon'` from
`transport.abort()`.

`aclose()` does:

```python
self._transport.close()
await sleep(0)
self._transport.abort()
```

The race, cross-referencing CPython's `asyncio/selector_events.py`:

1. If the transport's userspace write buffer is **non-empty** when `close()` is
called, `close()` takes the deferred path: it does *not* increment
`_conn_lost` and waits for the buffer to drain
([`close()`](https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/selector_events.py#L857-L865)).
2. If the write-ready callback for the socket is already queued in the same
event-loop pass (or fires in the next one before the `sleep(0)` continuation),
`_write_ready` drains the buffer and — since `_closing` is set — calls
`_call_connection_lost(None)` **synchronously**
([`_write_ready`](https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/selector_events.py#L1112-L1113)),
which sets `self._loop = None`. Note `_conn_lost` is still 0 on this path.
3. The `sleep(0)` continuation then resumes and calls `abort()` →
`_force_close()`, which checks `if self._conn_lost: return` (still 0, so it
falls through) and executes `self._loop.call_soon(...)` on a `_loop` that is
now `None`
([`_force_close`](https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/selector_events.py#L888-L898)).

So the crash needs: buffered outbound bytes at `close()` time, plus the peer
draining the socket within that one-tick window. Rare and timing-dependent, but
it happens in the wild: we (Inspect AI) received a report of it crashing an
httpx/httpcore SSE request over TLS, where `TLSStream.aclose()` writes the TLS
close_notify just before calling the transport stream's `aclose()` — so there
are essentially always freshly buffered bytes on that path
(downstream report: https://github.com/meridianlabs-ai/inspect_ai/issues/177).

Expected behavior: `aclose()` completes without raising. The `abort()` is
redundant in this interleaving — connection_lost has already run and the socket
is already closed — so it seems like `aclose()` should skip the abort (or
tolerate it) when the connection has already been lost, e.g. by having
`StreamProtocol.connection_lost()` record that it ran and checking that flag
before calling `abort()`.

### How can we reproduce the bug?

Plain anyio + asyncio, no third-party libraries. The script fills the kernel
send buffer so the transport buffers in userspace (forcing the deferred-close
path), then calls `aclose()` while the server drains. On my machine it
typically reproduces within a few dozen attempts (well under a second):

```python
import asyncio
import socket

import anyio
from anyio.abc import SocketAttribute

async def attempt(port: int) -> bool:
"""One attempt; returns True if the race reproduced."""
stream = await anyio.connect_tcp("127.0.0.1", port)

# keep buffers small so the userspace transport buffer fills quickly
raw = stream.extra(SocketAttribute.raw_socket)
raw.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4096)

# fill the kernel send buffer so the asyncio transport starts buffering in
# userspace (non-empty transport buffer <=> deferred-close path). Private
# attribute access is only used to detect when that has happened.
transport = stream._transport
with anyio.move_on_after(2):
while transport.get_write_buffer_size() == 0:
await stream.send(b"x" * 4096)

try:
# server drains concurrently; if _write_ready runs between
# transport.close() and transport.abort() inside aclose(), it calls
# _call_connection_lost, which sets transport._loop = None
await stream.aclose()
except AttributeError as ex:
assert "call_soon" in str(ex), ex
return True
return False

async def main() -> None:
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
while await reader.read(65536):
pass
except ConnectionError:
pass
writer.close()

server = await asyncio.start_server(handle, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]

for i in range(1000):
if await attempt(port):
print(f"reproduced on attempt {i + 1}")
return
print("did not reproduce in 1000 attempts")

asyncio.run(main())
```

Traceback (with the `try/except` removed):

```
Traceback (most recent call last):
File "repro.py", line 28, in main
File "repro.py", line 14, in attempt
File ".../site-packages/anyio/_backends/_asyncio.py", line 1376, in aclose
self._transport.abort()
~~~~~~~~~~~~~~~~~~~~~^^
File ".../lib/python3.13/asyncio/selector_events.py", line 826, in abort
self._force_close(None)
~~~~~~~~~~~~~~~~~^^^^^^
File ".../lib/python3.13/asyncio/selector_events.py", line 898, in _force_close
self._loop.call_soon(self._call_connection_lost, exc)
^^^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'call_soon'
```

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

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

調査の方向性

Read anyio/_backends/_asyncio.py around SocketStream.aclose() and StreamProtocol.connection_lost(), then run the standalone reproducer from the issue on asyncio. Trace the close and connection-lost ordering, and verify that the supplied race completes aclose() without raising AttributeError while preserving normal transport cleanup.

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

評価

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

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

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