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:
start_tls()extracts the raw socket from the originalIOStreamand setsself.socket = None(iostream.py:1251)- It wraps the socket in SSL and creates a new
SSLIOStreamas a local variable (iostream.py:1263) - It returns a
Futurethat resolves only when the handshake completes
When gen.with_timeout fires:
TimeoutErroris raised to the caller- The caller holds a reference to the original
IOStream, whose.socketis alreadyNone-- callingstream.close()is a no-op - The new
SSLIOStream(holding the real socket) is only reachable through the innerFuture gen.with_timeoutexplicitly does not cancel the wrappedFuture(documented atgen.py:609)- The
SSLIOStreamremains registered on theIOLoop, 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
Source: tornadoweb/tornado