From 728eb8fc4222f11e1b8d28cd831cf054f7a56568 Mon Sep 17 00:00:00 2001 From: Daisuke Aritomo Date: Thu, 17 Jul 2025 00:09:47 +0900 Subject: [PATCH 1/3] Replace Timeout.timeout with TCPSocket.open(open_timeout:) when available This patch replaces the implementation of #open_timeout from Timeout.timeout from the builtin timeout in TCPSocket.open, which was introduced in Ruby 3.5 (https://bugs.ruby-lang.org/issues/21347). The builtin timeout in TCPSocket.open is better in several ways than Timeout.timeout. It does not rely on a separate Ruby Thread for monitoring Timeout (which is what the timeout library internally does). Furthermore, it is compatible with Ractors, as opposed to Timeout.timeout (it internally uses Thread::Mutex which can not be used in non-main Ractors). This change allows the following code to work. require 'net/http' Ractor.new { uri = URI('http://example.com/') http = Net::HTTP.new(uri.host, uri.port) http.open_timeout = 1 http.get(uri.path) }.value In Ruby <3.5 environments where `TCPSocket.open` does not have the `open_timeout` option, I have kept the behavior unchanged. net/http will use `Timeout.timeout { TCPSocket.open }`. --- lib/net/http.rb | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/net/http.rb b/lib/net/http.rb index f64f7ba..039f3f7 100644 --- a/lib/net/http.rb +++ b/lib/net/http.rb @@ -1654,14 +1654,22 @@ def connect end debug "opening connection to #{conn_addr}:#{conn_port}..." - s = Timeout.timeout(@open_timeout, Net::OpenTimeout) { - begin - TCPSocket.open(conn_addr, conn_port, @local_host, @local_port) - rescue => e - raise e, "Failed to open TCP connection to " + - "#{conn_addr}:#{conn_port} (#{e.message})" + begin + s = begin + # Use built-in timeout in TCPSocket.open if available + TCPSocket.open(conn_addr, conn_port, @local_host, @local_port, open_timeout: @open_timeout) + rescue ArgumentError => e + raise if !e.message.include?('unknown keyword: :open_timeout') + # Fallback to Timeout.timeout if TCPSocket.open does not support open_timeout + Timeout.timeout(@open_timeout, Net::OpenTimeout) { + TCPSocket.open(conn_addr, conn_port, @local_host, @local_port) + } end - } + rescue => e + e = Net::OpenTimeout.new(e) if e.is_a?(Errno::ETIMEDOUT) # for compatibility with previous versions + raise e, "Failed to open TCP connection to " + + "#{conn_addr}:#{conn_port} (#{e.message})" + end s.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) debug "opened" if use_ssl? From 09bf573dd5bd7111b56c57918c8a3a84b068309b Mon Sep 17 00:00:00 2001 From: Daisuke Aritomo Date: Tue, 22 Jul 2025 23:34:25 +0900 Subject: [PATCH 2/3] Ruby 2 compat --- lib/net/http.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/net/http.rb b/lib/net/http.rb index 039f3f7..f8f6c4a 100644 --- a/lib/net/http.rb +++ b/lib/net/http.rb @@ -1659,7 +1659,7 @@ def connect # Use built-in timeout in TCPSocket.open if available TCPSocket.open(conn_addr, conn_port, @local_host, @local_port, open_timeout: @open_timeout) rescue ArgumentError => e - raise if !e.message.include?('unknown keyword: :open_timeout') + raise if !(e.message.include?('unknown keyword: :open_timeout') || e.message.include?('wrong number of arguments (given 5, expected 2..4)')) # Fallback to Timeout.timeout if TCPSocket.open does not support open_timeout Timeout.timeout(@open_timeout, Net::OpenTimeout) { TCPSocket.open(conn_addr, conn_port, @local_host, @local_port) From 06d982f3a178a65777332ed402d5a80c4b3216da Mon Sep 17 00:00:00 2001 From: Daisuke Aritomo Date: Thu, 6 Nov 2025 11:20:11 +0900 Subject: [PATCH 3/3] Remember if TCPSocket impl supports open_timeout For open_timeout support detection, the previous implementation relied on an ArgumentError being raised and then rescued. In Ruby, rescue is a rather expensive operation and should be avoided when possible. This patch reduces the number of begin-rescues by remembering if the TCPSocket implementation supports open_timeout. --- lib/net/http.rb | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/lib/net/http.rb b/lib/net/http.rb index f8f6c4a..3702623 100644 --- a/lib/net/http.rb +++ b/lib/net/http.rb @@ -1179,6 +1179,7 @@ def initialize(address, port = nil) # :nodoc: @debug_output = options[:debug_output] @response_body_encoding = options[:response_body_encoding] @ignore_eof = options[:ignore_eof] + @tcpsocket_supports_open_timeout = nil @proxy_from_env = false @proxy_uri = nil @@ -1655,16 +1656,30 @@ def connect debug "opening connection to #{conn_addr}:#{conn_port}..." begin - s = begin - # Use built-in timeout in TCPSocket.open if available - TCPSocket.open(conn_addr, conn_port, @local_host, @local_port, open_timeout: @open_timeout) - rescue ArgumentError => e - raise if !(e.message.include?('unknown keyword: :open_timeout') || e.message.include?('wrong number of arguments (given 5, expected 2..4)')) - # Fallback to Timeout.timeout if TCPSocket.open does not support open_timeout - Timeout.timeout(@open_timeout, Net::OpenTimeout) { - TCPSocket.open(conn_addr, conn_port, @local_host, @local_port) - } - end + s = + case @tcpsocket_supports_open_timeout + when nil, true + begin + # Use built-in timeout in TCPSocket.open if available + sock = TCPSocket.open(conn_addr, conn_port, @local_host, @local_port, open_timeout: @open_timeout) + @tcpsocket_supports_open_timeout = true + sock + rescue ArgumentError => e + raise if !(e.message.include?('unknown keyword: :open_timeout') || e.message.include?('wrong number of arguments (given 5, expected 2..4)')) + @tcpsocket_supports_open_timeout = false + + # Fallback to Timeout.timeout if TCPSocket.open does not support open_timeout + Timeout.timeout(@open_timeout, Net::OpenTimeout) { + TCPSocket.open(conn_addr, conn_port, @local_host, @local_port) + } + end + when false + # The current Ruby is known to not support TCPSocket(open_timeout:). + # Directly fall back to Timeout.timeout to avoid performance penalty incured by rescue. + Timeout.timeout(@open_timeout, Net::OpenTimeout) { + TCPSocket.open(conn_addr, conn_port, @local_host, @local_port) + } + end rescue => e e = Net::OpenTimeout.new(e) if e.is_a?(Errno::ETIMEDOUT) # for compatibility with previous versions raise e, "Failed to open TCP connection to " +