Skip to content

create_connection(sock=sock) does not detach socket on cancellation, causing fd double-close #738

@junjzhang

Description

@junjzhang

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()
    raise

This 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions