Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions lib/resolv.rb
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ def fetch_resource(name, typeclass)
if !truncated[candidate] && udp_requester
udp_requester
else
requesters[[nameserver, port]] = make_tcp_requester(nameserver, port)
requesters[[nameserver, port]] = make_tcp_requester(nameserver, port, tout)
end
end

Expand Down Expand Up @@ -606,12 +606,15 @@ def make_udp_requester # :nodoc:
end
end

def make_tcp_requester(host, port) # :nodoc:
return Requester::TCP.new(host, port)
rescue Errno::ECONNREFUSED
# Treat a refused TCP connection attempt to a nameserver like a timeout,
# as Resolv::DNS::Config#resolv considers ResolvTimeout exceptions as a
# hint to try the next nameserver:
TCPConnectTimeoutErrors = [Errno::ETIMEDOUT] # :nodoc:
TCPConnectTimeoutErrors << IO::TimeoutError if defined?(IO::TimeoutError)

def make_tcp_requester(host, port, connect_timeout = nil) # :nodoc:
return Requester::TCP.new(host, port, connect_timeout: connect_timeout)
rescue Errno::ECONNREFUSED, *TCPConnectTimeoutErrors
# Treat a refused or timed out TCP connection attempt to a nameserver
# like a timeout, as Resolv::DNS::Config#resolv considers ResolvTimeout
# exceptions as a hint to try the next nameserver:
raise ResolvTimeout
end

Expand Down Expand Up @@ -995,11 +998,15 @@ def sender_for(addr, msg)
end

class TCP < Requester # :nodoc:
def initialize(host, port=Port)
def initialize(host, port=Port, connect_timeout: nil)
super()
@host = host
@port = port
sock = TCPSocket.new(@host, @port)
sock = if connect_timeout && RUBY_VERSION >= "3.0"
TCPSocket.new(@host, @port, connect_timeout: connect_timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On Ruby 4.0 and later, open_timeout is available, which times out based on the time elapsed since the start of the method call.
However, for the purpose of this change, I think it's fine to keep connect_timeout, which times out based on the time elapsed since the start of the last connection attempt.

else
TCPSocket.new(@host, @port)
end
@socks = [sock]
@senders = {}
@reusable = true
Expand Down
58 changes: 58 additions & 0 deletions test/resolv/test_dns.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1615,4 +1615,62 @@ def test_tcp_peer_lost_while_idle_is_not_used_again
end
end
end

# The TCP connect after a truncated reply is bounded by the timeout, and a
# connect that times out moves on to the next nameserver like a refused one.
# TCPSocket.new is stubbed for the first nameserver, as a TCP port that drops
# SYNs cannot be set up portably.
def test_truncated_tcp_fallback_connect_timeout_falls_back_to_the_next_nameserver
omit "TCPSocket.new has no connect_timeout" if RUBY_VERSION < "3.0"
with_udp_and_tcp('127.0.0.1', 0) do |u1, _t1|
with_udp_and_tcp('127.0.0.1', 0) do |u2, t2|
u2.close # only the TCP side of the second nameserver is used
_, server1_port, _, server1_address = u1.addr
_, server2_port, _, server2_address = t2.addr
tout = EnvUtil.apply_timeout_scale(5)
connect_timeouts = Thread::Queue.new

timeout_error = defined?(IO::TimeoutError) ? IO::TimeoutError : Errno::ETIMEDOUT
TCPSocket.define_singleton_method(:new) do |host, port, *args, **opts, &blk|
if port == server1_port
connect_timeouts.push(opts[:connect_timeout])
raise timeout_error, "user specified timeout"
end
super(host, port, *args, **opts, &blk)
end

begin
client_thread = Thread.new do
Resolv::DNS.open(nameserver_port: [[server1_address, server1_port],
[server2_address, server2_port]],
raise_timeout_errors: true) do |dns|
dns.timeouts = tout
Timeout.timeout(EnvUtil.apply_timeout_scale(20)) do
dns.getresources('foo.example.org', Resolv::DNS::Resource::IN::A)
end
end
end

udp_server1_thread = Thread.new { answer_truncated(u1) }

tcp_server2_thread = Thread.new do
ct = accept_within_timeout(t2)
begin
query = read_framed_query(ct)
ct.write(framed(reply_for_query(query, '192.0.2.1')))
ensure
ct.close
end
end

result, = assert_join_threads([client_thread, udp_server1_thread,
tcp_server2_thread])
ensure
TCPSocket.singleton_class.send(:remove_method, :new)
end
assert_equal(['192.0.2.1'], result.map {|rr| rr.address.to_s })
assert_equal(tout, connect_timeouts.pop(true))
end
end
end
end
Loading