asyncio backend: race in SocketStream.aclose() — transport.abort() raises AttributeError: 'NoneType' object has no attribute 'call_soon'
- Lingua principale
- Python
- Stelle
- 2.5k
- Fork
- 260
- Merge medio
- 1g 8h
- PR unite (30g)
- 17
Descrizione
### 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'
```
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.