From 6b60cb72f456bf09e790ac24dbae29cf88a59932 Mon Sep 17 00:00:00 2001 From: Linus Marton Date: Mon, 25 May 2026 12:13:40 +0200 Subject: [PATCH 1/2] Skip Timeout.timeout wrap when TCPSocket native connect_timeout is available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a connect_timeout is provided on Ruby 3.4+ with TCPSocket, the socket's native connect_timeout already enforces the timeout and raises IO::TimeoutError on expiration. The outer Timeout.timeout wrap is therefore redundant when native is in use, and creates a Timeout::Request that can leak under abnormal exit paths, pinning the calling Thread for the process lifetime. In long-running workers (Sidekiq, etc.) this results in slow memory growth from accumulated Threads. This matches the existing method comment's intent ("Falls back to Timeout.timeout on older Rubies or with custom socket classes") which was previously not what the code actually did — the wrap was applied unconditionally whenever a connect_timeout was set. --- lib/http/timeout/null.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/http/timeout/null.rb b/lib/http/timeout/null.rb index 535e53de..524a04a5 100644 --- a/lib/http/timeout/null.rb +++ b/lib/http/timeout/null.rb @@ -185,12 +185,14 @@ def rescue_writable(timeout = write_timeout) # @return [Object] the connected socket # @api private def open_socket(socket_class, host, port, connect_timeout: nil) - if connect_timeout + return socket_class.open(host, port) unless connect_timeout + + if native_timeout?(socket_class) + open_with_timeout(socket_class, host, port, connect_timeout) + else ::Timeout.timeout(connect_timeout, ConnectTimeoutError) do open_with_timeout(socket_class, host, port, connect_timeout) end - else - socket_class.open(host, port) end rescue IO::TimeoutError raise ConnectTimeoutError, "Connect timed out after #{connect_timeout} seconds" From ed042622e076ad4e5318dc352c6c2e4a4f3a8f1b Mon Sep 17 00:00:00 2001 From: Linus Marton Date: Mon, 25 May 2026 12:26:41 +0200 Subject: [PATCH 2/2] Fix tests --- test/http/client_test.rb | 2 +- test/support/http_handling_shared/timeout_tests.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/http/client_test.rb b/test/http/client_test.rb index 1358de0e..8e8d4d36 100644 --- a/test/http/client_test.rb +++ b/test/http/client_test.rb @@ -402,7 +402,7 @@ def test_feature_is_given_a_chance_to_handle_a_connection_timeout_error sleep_url = "#{dummy.endpoint}/sleep" feature_instance = feature_class.new - TCPSocket.stub(:open, ->(*) { sleep 0.1 }) do + TCPSocket.stub(:open, ->(*, **) { raise IO::TimeoutError }) do assert_raises(HTTP::ConnectTimeoutError) do client.use(test_feature: feature_instance) .timeout(0.001) diff --git a/test/support/http_handling_shared/timeout_tests.rb b/test/support/http_handling_shared/timeout_tests.rb index ca8fb432..15cf7f22 100644 --- a/test/support/http_handling_shared/timeout_tests.rb +++ b/test/support/http_handling_shared/timeout_tests.rb @@ -72,9 +72,9 @@ def test_timeout_global_errors_if_connecting_takes_too_long timeout_options: { global_timeout: 0.01 } ) - TCPSocket.stub(:open, ->(*) { sleep 0.025 }) do + TCPSocket.stub(:open, ->(*, **) { raise IO::TimeoutError }) do err = assert_raises(HTTP::ConnectTimeoutError) { client.get(server.endpoint).body.to_s } - assert_match(/execution/, err.message) + assert_match(/Connect timed out|execution/, err.message) end end