Skip to content

TCPClient.connect: close the new SSLIOStream on TLS handshake timeout#3662

Open
HrachShah wants to merge 1 commit into
tornadoweb:masterfrom
HrachShah:fix/tcpclient-tls-handshake-timeout-leak
Open

TCPClient.connect: close the new SSLIOStream on TLS handshake timeout#3662
HrachShah wants to merge 1 commit into
tornadoweb:masterfrom
HrachShah:fix/tcpclient-tls-handshake-timeout-leak

Conversation

@HrachShah

Copy link
Copy Markdown

What

Closes the socket leak described in #3614: when TCPClient.connect is called with both ssl_options and a timeout, a TLS handshake timeout leaks the underlying socket.

Why

IOStream.start_tls transfers socket ownership to a new SSLIOStream before the handshake completes and sets self.socket = None on the original stream. The gen.with_timeout call around start_tls only raises TimeoutError to the caller. The original stream is now a no-op (its socket is gone) and the new SSLIOStream that actually owns the socket is reachable only through the inner future — which gen.with_timeout deliberately leaves running, per its docstring.

The net effect: the socket fd is unreachable from the caller, is not closed by the IOLoop, and is not garbage-collected because the inner future still holds a reference to the SSLIOStream.

Fix

Capture the future returned by start_tls so that, on timeout, we register a done-callback that closes the resulting SSLIOStream. If the handshake eventually fails, SSLIOStream already closes its socket on the failure path, so the callback only acts when the future resolved successfully. The original IOStream is not touched: its socket is already None and the SSL fd has moved to the new stream.

tls_future = stream.start_tls(
    False, ssl_options=ssl_options, server_hostname=host
)
try:
    stream = await gen.with_timeout(timeout, tls_future)
except TimeoutError:
    def _close_if_succeeded(f: Future[IOStream]) -> None:
        if f.exception() is None:
            f.result().close()
    future_add_done_callback(tls_future, _close_if_succeeded)
    raise

Test

test_tls_handshake_timeout_closes_ssl_stream in tornado/test/tcpclient_test.py:

  1. Stands up the existing TestTCPServer.
  2. Monkey-patches IOStream.start_tls with a stub that replicates enough of the real implementation to put the underlying socket into a new SSLIOStream, but returns an unresolved Future so the connect-timeout fires while the handshake is still pending.
  3. Instruments SSLIOStream.close to record the stream.
  4. Asserts the connect call raises TimeoutError.
  5. Resolves the now-orphaned handshake future so the cleanup callback runs.
  6. Asserts the SSLIOStream that owns the socket was closed exactly once.

All 28 existing tcpclient tests still pass.

… (issue tornadoweb#3614)

When TCPClient.connect is called with both ssl_options and a timeout, a
TLS handshake timeout leaks the underlying socket. IOStream.start_tls
transfers socket ownership to a new SSLIOStream *before* the handshake
completes and sets self.socket to None on the original stream. The
gen.with_timeout call around start_tls only raises TimeoutError to the
caller; the original stream is now a no-op (its socket is gone) and the
new SSLIOStream that actually owns the socket is reachable only through
the inner future -- which gen.with_timeout deliberately leaves running,
per its docstring.

Capture the future returned by start_tls so that, on timeout, we register
a done-callback that closes the resulting SSLIOStream. If the handshake
eventually fails, SSLIOStream already closes its socket on the failure
path, so the callback only acts when the future resolved successfully.

Regression test in tcpclient_test.py replaces start_tls with a stub that
builds a real SSLIOStream around the underlying socket but never completes
the handshake, instruments close() to record the stream, and asserts that
the SSLIOStream is closed when the timeout fires and the future is
subsequently resolved. All 28 existing tcpclient tests still pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant