-
Notifications
You must be signed in to change notification settings - Fork 603
Description
Summary
When loop.create_connection(protocol_factory, sock=sock) is cancelled (e.g. by a connect timeout), the passed-in Python socket object is never detached. Its __del__ later closes whatever fd the OS recycled into that slot, silently corrupting an unrelated transport.
Standard asyncio is not affected — its transport directly owns the Python socket and properly closes it on the error path, setting fileno() to -1.
Root cause
In loop.pyx (the sock is not None branch of create_connection):
# uvloop/loop.pyx ~line 2060
waiter = self._new_future()
tr = TCPTransport.new(self, protocol, None, waiter, context)
try:
tr._open(sock.fileno()) # libuv takes over the fd
tr._init_protocol() # _transports[fd] = tr
await waiter # ← cancellation point
except BaseException:
tr._close() # libuv closes fd, but sock doesn't know
raise
tr._attach_fileobj(sock) # only reached on success — calls sock.detach() later via _close()On the success path, _attach_fileobj saves a reference to sock. When the transport is later closed, _close() calls sock.detach(), which sets the socket's internal fd to -1 so GC won't double-close it.
On the error/cancellation path, _attach_fileobj is never called → sock.detach() never happens → the Python socket still believes it owns the (now-recycled) fd number → sock.__del__() closes an unrelated fd.
Reproduction
import asyncio
import socket
import uvloop
async def test(loop):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(1)
addr = srv.getsockname()
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.setblocking(False)
try:
client.connect(addr)
except BlockingIOError:
pass
await asyncio.sleep(0.01)
task = asyncio.ensure_future(loop.create_connection(asyncio.Protocol, sock=client))
await asyncio.sleep(0)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
print(f"uvloop: sock.fileno() = {client.fileno()}") # NOT -1 → bug
srv.close()
try:
client.close()
except OSError:
pass
# uvloop — socket NOT detached
uvloop.install()
loop = asyncio.new_event_loop()
loop.run_until_complete(test(loop))
loop.close()
# standard asyncio — socket properly detached
asyncio.set_event_loop_policy(None)
loop = asyncio.new_event_loop()
loop.run_until_complete(test(loop))
loop.close()Output:
uvloop: sock.fileno() = 13 ← should be -1
asyncio: sock.fileno() = -1 ← correct
Impact
Under high concurrency with force_close=True (aiohttp) and intermittent connection timeouts, the GC'd socket closes a live transport's fd. This manifests as:
RuntimeError: File descriptor 1768 is used by transport <TCPTransport closed=False reading=True 0x...>
followed by Assertion failed: ok (src/mailbox.cpp:72) → SIGABRT, crashing the process.
Suggested fix
Detach the socket in the error path:
except BaseException:
tr._close()
if sock is not None:
sock.detach()
raiseThis matches the semantics of standard asyncio, where the transport always takes full ownership of the socket.
Environment
- uvloop 0.21.0
- Python 3.12
- Linux 5.15