Skip to content

Conversation

@osyoyu
Copy link
Contributor

@osyoyu osyoyu commented Jul 16, 2025

Resolves #6.

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. First, it does not rely on a separate Ruby Thread for monitoring Timeout (which is what the timeout library internally does). Also, it is compatible with Ractors, since it does not rely on Mutexes (which is also what the timeout library does).

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 }.

Changes in behavior

On timeout, the raised Net::OpenTimeout's message has slightly changed, and also carrys a Errno::ETIMEDOUT as its cause.

/home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1670:in 'Net::HTTP#connect': Failed to open TCP connection to example.com:80 (Connection timed out - user specified timeout) (Net::OpenTimeout)
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1636:in 'Net::HTTP#do_start'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1625:in 'Net::HTTP#start'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:2378:in 'Net::HTTP#request'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1993:in 'Net::HTTP#get'
	from nethttptest.rb:13:in '<main>'
/home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1660:in 'TCPSocket#initialize': Connection timed out - user specified timeout (Errno::ETIMEDOUT)
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1660:in 'IO.open'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1660:in 'Net::HTTP#connect'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1636:in 'Net::HTTP#do_start'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1625:in 'Net::HTTP#start'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:2378:in 'Net::HTTP#request'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1993:in 'Net::HTTP#get'
	from nethttptest.rb:13:in '<main>'

Previously, it looked like this.

/home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1665:in 'TCPSocket#initialize': Failed to open TCP connection to example.com:80 (execution expired) (Net::OpenTimeout)
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1665:in 'IO.open'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1665:in 'block in Net::HTTP#connect'
	from /home/osyoyu/.rbenv/versions/3.4.5/lib/ruby/3.4.0/timeout.rb:185:in 'block in Timeout.timeout'
	from /home/osyoyu/.rbenv/versions/3.4.5/lib/ruby/3.4.0/timeout.rb:192:in 'Timeout.timeout'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1664:in 'Net::HTTP#connect'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1636:in 'Net::HTTP#do_start'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1625:in 'Net::HTTP#start'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:2378:in 'Net::HTTP#request'
	from /home/osyoyu/Development/rubyorg/net-http/lib/net/http.rb:1993:in 'Net::HTTP#get'
	from nethttptest.rb:13:in '<main>'

…able

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 }`.
@osyoyu
Copy link
Contributor Author

osyoyu commented Jul 16, 2025

This version needs ruby/ruby#13909. It's now merged!

@osyoyu osyoyu force-pushed the tcpsocket-open-timeout branch from 4666f41 to 728eb8f Compare July 19, 2025 00:22
lib/net/http.rb Outdated
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't come up with a better way to detect the absence of open_timeout: option. Is this a acceptable solution?

TCPSocket.open is basically (**args) from the perspective of Ruby, so Method#parameters wasn't an option:

irb(main):001> require 'socket'
=> true
irb(main):002> TCPSocket.method(:open).parameters
=> [[:rest]]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine, given this is only to keep things working for very old rubies, right, and the error message is not going to change for those rubies anyways.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually it's not only very old Rubies, but even 3.4 raises on a TCPSocket.open call with open_timeout. It is true that the situation is different in Ruby 2.x, where keyword arguments were not a argument of its own kind (workaround in 09bf573).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I thought your question was specifically for the Ruby 2.x workaround, since you need to parse a very generic error message, but I see how the detection for Ruby 3.4 is also brittle since it also involves parsing the error message, even if more specific. Unfortunately, I don't know of a better way, but I'd say this way is acceptable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering that every single new HTTP request is going to go through this raise/rescue flow on ruby < 3.5, and that exceptions are kind of expensive, personally I think a better option is just to have test for RUBY_VERSION.to_f < 3.5 directly to see if we use the old Timeout.timeout. I get that testing version numbers directly is a bit distasteful, but when there's actual performance issues and brittleness issues on the line... I'd say its merited

Copy link
Contributor

@mohamedhafez mohamedhafez Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah Christ. Ractors. Yeah nevermind my suggestion above won't work any more, since it would make it so instances of Net::HTTP wouldn't work inside anything but the main Ractor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that’s exactly right. Using class variables would unfortunately kill Ractors.

Copy link
Contributor

@mohamedhafez mohamedhafez Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case I'd still argue that simply testing RUBY_VERSION.to_f >= 3.5 is the more straightforward and efficient way to do it - to address @eregon's arguments: Regarding alternative Rubies, I'd say its their responsibility to implement Ruby 3.5 functionality before advertising compatibility with it in RUBY_VERSION (unless there's some reason why open_timeout specifically would be difficult to implemented in Java?). And in the unlikely case that open_timeout is removed in a future Ruby version, well then an update to this library would be necessary, which is to be expected when behavior is deprecated/removed, and isn't something we usually guard against no? Things would get pretty messy if we did that all over the place.

Just my two cents, and yeah an exception raised on first use of every Net::HTTP instance probably isn't a performance hit we need to be debating a ton. I'll just be happy either way if this hopefully gets merged, and i appreciate the work!! 🙏

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding alternative Rubies, I'd say its their responsibility to implement Ruby 3.5 functionality before advertising compatibility with it in RUBY_VERSION (unless there's some reason why open_timeout specifically would be difficult to implemented in Java?).

I'm specifically worried about that because open_timeout is resolv_timeout + connect_timeout.
resolv_timeout is extremely complex to handle, CRuby has had many iterations of it (some of them buggy, leaking, segfaulting and other issues) and it will probably be a lot of work to implement it for other Ruby implementations, so I think it's not unlikely e.g. TruffleRuby/JRuby won't support resolv_timeout properly (because the libc doesn't give a way to do interruptible getaddrinfo()) and yet implements other Ruby 4 features.

In such a case maybe open_timeout should be handled as just connect_timeout, but I'm not sure what is the best behavior in such a case. I'd need to check how resolv_timeout is handled on TruffleRuby/JRuby currently.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh i see. Yeah if there's a reason this specifically would be hard to implement in alternative rubies then I wholeheartedly agree with the approach that was merged in, since I'm using alternative Rubies myself;) Thanks for the explanation!

@osyoyu osyoyu marked this pull request as ready for review July 19, 2025 00:35
lib/net/http.rb Outdated
Comment on lines 1658 to 1667
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on @eregon's idea(https://github.com/ruby/net-http/pull/224/files#r2411676663) , I wrote this code.
It's a little verbose, but I feel I've kept it understandable.

Feel free to use it if you need it, @osyoyu

Suggested change
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 = begin
# Determine once whether TCPSocket.open supports open_timeout keyword argument
case @tcpsocket_open_timeout_supported
when nil
begin
# Try using open_timeout keyword argument
sock = TCPSocket.open(conn_addr, conn_port, @local_host, @local_port, open_timeout: @open_timeout)
@tcpsocket_open_timeout_supported = true
sock
rescue ArgumentError => e
if e.message.include?('unknown keyword: :open_timeout') || e.message.include?('wrong number of arguments (given 5, expected 2..4)')
# TCPSocket.open does not support open_timeout keyword argument
@tcpsocket_open_timeout_supported = false
# Fallback to Timeout.timeout
Timeout.timeout(@open_timeout, Net::OpenTimeout) {
TCPSocket.open(conn_addr, conn_port, @local_host, @local_port)
}
else
raise
end
end
when true
# Use open_timeout keyword argument (known to be supported)
# assume Ruby >= 3.5
TCPSocket.open(conn_addr, conn_port, @local_host, @local_port, open_timeout: @open_timeout)
when false
# Use Timeout.timeout (known that open_timeout is not supported)
# assume Ruby < 3.5
Timeout.timeout(@open_timeout, Net::OpenTimeout) {
TCPSocket.open(conn_addr, conn_port, @local_host, @local_port)
}
end
end

@niku
Copy link

niku commented Oct 31, 2025

Here's what we've found so far:

  • TCPSocket.method(:open).parameters returns [[:rest]], so it cannot be used to determine if the open_timeout option is accepted.
  • While it's possible to call TCPSocket.open directly and branch the logic by rescuing ArgumentError, this approach makes the code somewhat complex.

Given this, I'd like to propose an alternative: What if we check the parameters of Socket.tcp to determine if TCPSocket.open accepts open_timeout?

Socket.tcp is closely related to TCPSocket.open; for instance, it was mentioned when the open_timeout argument was originally added to TCPSocket.open ( ruby/ruby#13909 ).

Strictly speaking, a situation could exist where Socket.tcp accepts open_timeout but TCPSocket.open does not. However, looking at their relationship in the codebase, I believe they are tightly coupled and can be treated as effectively inseparable (i.e., they are likely to change together).

I'm thinking the code would look something like this:

      # Check if TCPSocket.open supports the open_timeout keyword argument.
      # We cannot use TCPSocket.method(:open).parameters directly because it always returns [[:rest]] due to being a C extension method.
      # Instead, we use Socket.tcp as a substitute since it's closely related (see PR https://github.com/ruby/ruby/pull/13909).
      # Socket.tcp is implemented in Ruby, so keyword arguments appear in its parameters.
      open_timeout_supported = Socket.method(:tcp).parameters.any? { |param| param[0] == :key && param[1] == :open_timeout }
      s = begin
            if open_timeout_supported
              TCPSocket.open(conn_addr, conn_port, @local_host, @local_port, open_timeout: @open_timeout)
            else
              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

@osyoyu
Copy link
Contributor Author

osyoyu commented Nov 2, 2025

What if we check the parameters of Socket.tcp to determine if TCPSocket.open accepts open_timeout?

Actually I've never thought of that. This sounds like a good idea, if replacing TCPSocket.new with Socket.new is acceptable in terms of performance.
My understanding is that Socket.new is slower than TCPSocket.new in theory since it is implemented in Ruby instead of C, but I am not sure if that is neglibigle or not for net/http.

Edit: I have misunderstood your proposal. I'm not sure if we can take chances here and assume that "if Socket.tcp supports open_timeout, TCPSocket.open will also do so" -- if that assumption breaks somewhere, net/http will raise an ArgumentError that is unfixable on the user's side.
If we're to determine open_timeout availability via Socket.method(:tcp).parameters, we'd better directly use Socket.tcp.

Edit 2: I've obviously thought of the latter idea in #223 🤫

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.
@osyoyu
Copy link
Contributor Author

osyoyu commented Nov 7, 2025

Had a chance to chat with @shioimm regarding my previous comment and #223. She advised not to use Socket.tcp for net/http for performance reasons. (I don't have any numbers at hand, but maybe I can run some benchmarks later)

@niku
Copy link

niku commented Nov 7, 2025

We've identified several options at this point. To make them easier to review, I've compiled them into a table.

Method Advantages (Pros) Disadvantages (Cons) Notes
Check the result of TCPSocket.method(:open).parameters Most direct It's unknown/difficult to get a result more specific than [[:rest]] from a C extension. #224 (comment)
Try calling TCPSocket.open with open_timeout and fallback on ArgumentError The check (if it can run) and the actual execution method are identical. There are no other considerations. Requires code to catch the ArgumentError and perform the fallback. this PR
Assume TCPSocket.method(:open) accepts open_timeout if Socket.method(:tcp).parameters does Keeps code complexity low. It's not intuitive why this workaround is used. / There is no guarantee this assumption is always true. #224 (comment)
Use Socket.tcp Keeps code complexity low. / The check and the method to be executed are identical, with no other considerations. Poor performance. #224 (comment)
Check the Ruby version (RUBY_VERSION) Simple May not work correctly on implementations other than CRuby. / Will fail if open_timeout is removed for some reason in a future version (e.g., Ruby 3.5+). #224 (comment) #224 (comment)

At this point, this PR's changes seem to be the most suitable for me 👍

@shioimm
Copy link
Contributor

shioimm commented Nov 10, 2025

I also support the approach of calling TCPSocket.open with open_timeout, and raising an ArgumentError if it’s not available.
This makes the intention clear and ensures the behavior is independent of the Ruby implementation.
For these reasons, I will merge this PR.

@shioimm shioimm merged commit 1903ced into ruby:master Nov 10, 2025
25 checks passed
@osyoyu
Copy link
Contributor Author

osyoyu commented Nov 10, 2025

Thank you everyone for comments & reviews!

@forthrin
Copy link

Acknowledging extensive discussions and changes around TCPSocket timeout. Is the exception expected behavior (in given configurations like Ruby < 3.5) and what is the recommended mitigation on the developer side?

# ruby 3.4.7 (2025-10-08 revision 7a5688e2a2)
# net-http (0.8.0, 0.6.0)
irb> Net::HTTP.start('foo.bar', 80)
/opt/homebrew/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1682:in 'TCPSocket#initialize': unknown keyword: :open_timeout (ArgumentError)

@osyoyu
Copy link
Contributor Author

osyoyu commented Nov 19, 2025

Oh, definitely not expected. I'll take a look. I think there is no workaround besides downgrading net-http.

@osyoyu
Copy link
Contributor Author

osyoyu commented Nov 19, 2025

@forthrin Are you pointing to the ArgumentError wrapped in the Socket::ResolutionError? In that case, this behavior is expected (I understand it's a bit cluttered). You should be able to rescue this as rescue Socket::ResolutionError.
Or are you seeing a ArgumentError at the top level?

% irb
irb(main):001> RUBY_VERSION
=> "3.4.7"
irb(main):002> require 'net/http'
=> true
irb(main):003> Net::HTTP::VERSION
=> "0.8.0"
irb(main):004> Net::HTTP.start('foo.bar', 80)
/Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1691:in 'TCPSocket#initialize': Failed to open TCP connection to foo.bar:80 (getaddrinfo(3): nodename no
r servname provided, or not known) (Socket::ResolutionError)
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1691:in 'IO.open'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1691:in 'block in Net::HTTP#connect'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/timeout-0.4.4/lib/timeout.rb:188:in 'block in Timeout.timeout'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/timeout-0.4.4/lib/timeout.rb:195:in 'Timeout.timeout'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1690:in 'Net::HTTP#connect'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1655:in 'Net::HTTP#do_start'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1635:in 'Net::HTTP#start'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1064:in 'Net::HTTP.start'
        from (irb):4:in '<main>'
        from <internal:kernel>:168:in 'Kernel#loop'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/irb-1.15.3/exe/irb:9:in '<top (required)>'
        from /Users/osyoyu/.rbenv/versions/3.4.7/bin/irb:25:in 'Kernel#load'
        from /Users/osyoyu/.rbenv/versions/3.4.7/bin/irb:25:in '<main>'
/Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1682:in 'TCPSocket#initialize': unknown keyword: :open_timeout (ArgumentError)

              sock = TCPSocket.open(conn_addr, conn_port, @local_host, @local_port, open_timeout: @open_timeout)
                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1682:in 'IO.open'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1682:in 'Net::HTTP#connect'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1655:in 'Net::HTTP#do_start'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1635:in 'Net::HTTP#start'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/net-http-0.8.0/lib/net/http.rb:1064:in 'Net::HTTP.start'
        from (irb):4:in '<main>'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/irb-1.15.3/lib/irb/workspace.rb:101:in 'Kernel#eval'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/irb-1.15.3/lib/irb/workspace.rb:101:in 'IRB::WorkSpace#evaluate'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/irb-1.15.3/lib/irb/context.rb:591:in 'IRB::Context#evaluate_expression'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/irb-1.15.3/lib/irb/context.rb:557:in 'IRB::Context#evaluate'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/irb-1.15.3/lib/irb.rb:202:in 'block (2 levels) in IRB::Irb#eval_input'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/irb-1.15.3/lib/irb.rb:521:in 'IRB::Irb#signal_status'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/irb-1.15.3/lib/irb.rb:194:in 'block in IRB::Irb#eval_input'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/irb-1.15.3/lib/irb.rb:281:in 'block in IRB::Irb#each_top_level_statement'
        from <internal:kernel>:168:in 'Kernel#loop'
        from /Users/osyoyu/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/gems/irb-1.15.3/lib/irb.rb:278:in 'IRB::Irb#each_top_level_statement'
        ... 8 levels...

@forthrin
Copy link

@osyoyu: Same as you. First Socket::ResolutionError, then unknown keyword: :open_timeout (ArgumentError).

So yes, catching the prior would obviously eliminate the latter, but what is the purpose of raising the latter? If it's not supposed to be addressable by a developer at the far end, but more seems like an internal fixture of sorts.

Is it a temporary measure until another project (Ruby core / TCPSocket) fix things on their side?

@osyoyu
Copy link
Contributor Author

osyoyu commented Nov 19, 2025

Yes, it is internal. There simply wasn't a way to remove the wrapped exception, as far as I know. I'd be happy to do so if possible.

Is it a temporary measure until another project (Ruby core / TCPSocket) fix things on their side?

Once you upgrade to Ruby 4.0 (the next version), you won't see this as TCPSocket.open will accept open_timeout.

@eregon
Copy link
Member

eregon commented Nov 19, 2025

There simply wasn't a way to remove the wrapped exception, as far as I know. I'd be happy to do so if possible.

I think it should be possible if you extract the new logic in a new method, and then on ArgumentError do a recursive call after @tcpsocket_supports_open_timeout = false, the call should clear $! since that's only visible inside the rescue.

@eregon
Copy link
Member

eregon commented Nov 19, 2025

Re

It's unknown/difficult to get a result more specific than [[:rest]] from a C extension.

Maybe it would make sense to define TCPSocket.open in Ruby code in CRuby, then the detection would work fine.
It can make sense on its own to be written in Ruby as that's typically a lot more efficient to pass keyword arguments than passing them to a method defined in C.

Or alternatively maybe there should be a way to declare proper parameters for methods defined in C in the Ruby C API.

For the first, I think a PR to CRuby would be most actionable, and for the second a ticket on https://bugs.ruby-lang.org/.

The current solution seems fine to me though, but it's a problem that has come up a few times so it'd be nice to get a proper fix in CRuby.

@osyoyu
Copy link
Contributor Author

osyoyu commented Nov 19, 2025

Maybe it would make sense to define TCPSocket.open in Ruby code in CRuby

Do you mean reimplementing entire TCPSocket.open in Ruby? Socket.tcp is exactly that, so in that case it’d be better to replace TCPSocket with Socket (which is believed to have degraded performance).

I think it should be possible if you extract the new logic in a new method, and then on ArgumentError do a recursive call

Thank you! I’ll work on that.

@osyoyu
Copy link
Contributor Author

osyoyu commented Nov 19, 2025

Is there any place where a changelog should be recorded? I can send a patch to CRuby’s NEWS if that’s the place.

@eregon
Copy link
Member

eregon commented Nov 19, 2025

Do you mean reimplementing entire TCPSocket.open in Ruby?

No, just TCPSocket.open. Actually there is no TCPSocket.open, it's just IO.open.
IO.open is trivial to implement in Ruby code.
But it won't solve our detection issue here as it's fully generic and takes arbitrary args & kwargs 😅

Socket.tcp is exactly that, so in that case it’d be better to replace TCPSocket with Socket (which is believed to have degraded performance).

I think it'd be good to actually benchmark this. It's not because something is partially written in Ruby that it's slower than C. In fact, receiving keyword arguments, accessing ivars, calling other methods are all faster when done in Ruby code than in C code (basically because Ruby code has inline caches and C code doesn't).

@eregon
Copy link
Member

eregon commented Nov 19, 2025

Is there any place where a changelog should be recorded? I can send a patch to CRuby’s NEWS if that’s the place.

I think just the release notes of this gem is fine (and that's automatic).

@osyoyu
Copy link
Contributor Author

osyoyu commented Nov 19, 2025

No, just TCPSocket.open. Actually there is no TCPSocket.open, it's just IO.open

I see (The docs are also confusing for this reason!)

I have some spare time tonight, so I’ll try some benchmarks.

@osyoyu
Copy link
Contributor Author

osyoyu commented Nov 19, 2025

TCPSocket.open was slightly faster in most scenarios. Maybe the difference is negligible?

See the following Gist for code and details:
https://gist.github.com/osyoyu/7c85e8120facba8cbe5e6b45b9038e19

Open TCP socket and close

This benchmark aims to compare performance of TCPSocket.open and Socket.tcp by doing nothing else, aside of close.

Calculating -------------------------------------
TCPSocket  w/o options
                         36.378k (±59.1%) i/s   (27.49 μs/i) -     13.095k in   5.380034s
Socket.tcp w/o options
                         37.031k (±39.7%) i/s   (27.00 μs/i) -     12.173k in   5.426029s
TCPSocket  w/ options
                         44.877k (±29.4%) i/s   (22.28 μs/i) -     12.864k in   5.430528s
Socket.tcp w/ options
                         40.331k (±28.4%) i/s   (24.80 μs/i) -     13.300k in   5.434711s

Comparison:
TCPSocket  w/ options:    44877.0 i/s
Socket.tcp w/ options:    40330.6 i/s - same-ish: difference falls within error
Socket.tcp w/o options:    37030.7 i/s - same-ish: difference falls within error
TCPSocket  w/o options:    36378.1 i/s - same-ish: difference falls within error

Single HTTP request (small response)

Network I/O shall be dominant in this scenario.

Calculating -------------------------------------
           TCPSocket      2.270k (±70.1%) i/s  (440.53 μs/i) -      9.696k in   5.063461s
          Socket.tcp      1.824k (±22.9%) i/s  (548.22 μs/i) -      8.816k in   5.061455s

Comparison:
           TCPSocket:     2270.0 i/s
          Socket.tcp:     1824.1 i/s - same-ish: difference falls within error

Single HTTP request (10M response)

Network I/O shall be even more dominant.

Calculating -------------------------------------
           TCPSocket    188.905 (± 5.3%) i/s    (5.29 ms/i) -    950.000 in   5.047272s
          Socket.tcp    190.200 (± 3.2%) i/s    (5.26 ms/i) -    950.000 in   5.000060s

Comparison:
          Socket.tcp:      190.2 i/s
           TCPSocket:      188.9 i/s - same-ish: difference falls within error

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.

Replacing Timeout.timeout in Net::HTTP#connect with socket timeout options

7 participants