#3614·tornado

Socket leak in TCPClient.connect when TLS handshake times out

Author: ptazithosCreated Apr 9, 2026Updated Apr 9, 2026

When TCPClient.connect is called with both ssl_options and timeout, a TLS handshake timeout causes the underlying socket to leak. The socket becomes unreachable from the caller and is never closed.

Affected Code

tornado/tcpclient.py lines 284-290:

if ssl_options is not None:
    if timeout is not None:
        stream = await gen.with_timeout(
            timeout,
            stream.start_tls(
                False, ssl_options=ssl_options, server_hostname=host
            ),
        )

Root Cause

IOStream.start_tls() transfers socket ownership to a new SSLIOStream before the TLS handshake completes:

  1. start_tls() extracts the raw socket from the original IOStream and sets self.socket = None (iostream.py:1251)
  2. It wraps the socket in SSL and creates a new SSLIOStream as a local variable (iostream.py:1263)
  3. It returns a Future that resolves only when the handshake completes

When gen.with_timeout fires:

  • TimeoutError is raised to the caller
  • The caller holds a reference to the original IOStream, whose .socket is already None -- calling stream.close() is a no-op
  • The new SSLIOStream (holding the real socket) is only reachable through the inner Future
  • gen.with_timeout explicitly does not cancel the wrapped Future (documented at gen.py:609)
  • The SSLIOStream remains registered on the IOLoop, waiting for a handshake that will never complete
  • The socket file descriptor is leaked permanently

Workaround

from tornado.iostream import SSLIOStream
from tornado.ioloop import IOLoop
from tornado import gen


async def tcp_connect_ssl(client, host, port, ssl_options, timeout=5):
    deadline = IOLoop.current().time() + timeout
    stream = await client.connect(host, port, timeout=timeout)

    sock = stream.socket
    stream.io_loop.remove_handler(sock)
    stream.socket = None

    ssl_sock = ssl_options.wrap_socket(
        sock, server_hostname=host, do_handshake_on_connect=False,
    )
    ssl_stream = SSLIOStream(ssl_sock, ssl_options=ssl_options)

    try:
        await gen.with_timeout(deadline, ssl_stream.wait_for_handshake())
    except Exception:
        ssl_stream.close()
        raise
    return ssl_stream