Skip to content

tcpclient: close the SSLIOStream on TLS handshake timeout so the socket is released#3684

Closed
HrachShah wants to merge 1 commit into
tornadoweb:masterfrom
HrachShah:fix-3614-tls-handshake-leak
Closed

tcpclient: close the SSLIOStream on TLS handshake timeout so the socket is released#3684
HrachShah wants to merge 1 commit into
tornadoweb:masterfrom
HrachShah:fix-3614-tls-handshake-leak

Conversation

@HrachShah

Copy link
Copy Markdown

When TCPClient.connect is given both ssl_options and a timeout, the handshake is raced against gen.with_timeout. IOStream.start_tls transfers the underlying socket to a new SSLIOStream before the handshake completes and returns a future that resolves when the handshake does. gen.with_timeout does not cancel its inner future on timeout, so the SSLIOStream (which now holds the only reference to the real socket) is left registered on the IOLoop with no reachable reference, and the socket file descriptor is leaked permanently.

This was reported in #3614. The reporter noticed the socket becoming unreachable from the caller and never closed, and worked around it by manually calling io_loop.remove_handler and stream.close() on the underlying socket before starting the handshake.

Fix

  1. IOStream.start_tls stashes a back-reference to the new SSLIOStream on the future it returns, so callers that race the handshake against a timeout can still reach the stream if the timeout fires.
  2. TCPClient.connect catches the gen.TimeoutError from the wrapped gen.with_timeout, closes the SSLIOStream (which releases the underlying socket and unregisters the handshake handler), then re-raises.

Test

Added test_ssl_handshake_timeout_does_not_leak_socket in tornado/test/tcpclient_test.py:

  • binds a server socket that accepts connections but never speaks TLS (so the client side hangs in do_handshake)
  • runs TCPClient().connect(host, port, ssl_options=ctx, timeout=0.05) and expects gen.TimeoutError
  • lets the IOLoop run a few iterations so any leftover close handler can fire
  • asserts the open-fd count after the timeout equals the count before the connect plus the number of accepted server-side connections (i.e. no client-side fd leaked)

Without the fix, the assertion fails with AssertionError: 11 != 10 (one client socket leaked). With the fix, it passes.

Verification

  • python -m tornado.test.runtests tornado.test.tcpclient_test -> 28 tests, OK (4 skipped for ipv6)
  • python -m tornado.test.runtests tornado.test.iostream_test -> 142 tests, OK (covers TestIOStreamStartTLS)

Closes #3614

When TCPClient.connect is given both ssl_options and a timeout, the
handshake is raced against gen.with_timeout. The IOStream is wrapped in
an SSLIOStream inside IOStream.start_tls, which transfers socket
ownership away from the original stream before the handshake completes.
gen.with_timeout does not cancel the inner future on timeout, so the
SSLIOStream (now the only thing that holds the underlying socket) is
left registered on the IOLoop with no reachable reference, and the
socket file descriptor is leaked permanently.

Stash a back-reference to the new SSLIOStream on the future returned by
IOStream.start_tls, and in TCPClient.connect close the SSLIOStream when
the timeout fires so the socket is released. Adds a regression test
that runs a TLS handshake against a server that never speaks TLS and
asserts no fd is leaked past the timeout.

Closes tornadoweb#3614
@bdarnell

bdarnell commented Jul 6, 2026

Copy link
Copy Markdown
Member

This is your third open PR for the same issue (#3673 and #3662). This is spam and I'm going to block you from opening any more PRs.

@bdarnell bdarnell closed this Jul 6, 2026
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.

Socket leak in TCPClient.connect when TLS handshake times out

2 participants