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 original IOStream and sets self.socket = None (iostream.py:1251)
- It wraps the socket in SSL and creates a new
SSLIOStream as a local variable (iostream.py:1263)
- 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
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.pylines 284-290:Root Cause
IOStream.start_tls()transfers socket ownership to a newSSLIOStreambefore the TLS handshake completes:start_tls()extracts the raw socket from the originalIOStreamand setsself.socket = None(iostream.py:1251)SSLIOStreamas a local variable (iostream.py:1263)Futurethat resolves only when the handshake completesWhen
gen.with_timeoutfires:TimeoutErroris raised to the callerIOStream, whose.socketis alreadyNone-- callingstream.close()is a no-opSSLIOStream(holding the real socket) is only reachable through the innerFuturegen.with_timeoutexplicitly does not cancel the wrappedFuture(documented atgen.py:609)SSLIOStreamremains registered on theIOLoop, waiting for a handshake that will never completeWorkaround