diff --git a/lib/resolv.rb b/lib/resolv.rb index bac338c..ee4a8fe 100644 --- a/lib/resolv.rb +++ b/lib/resolv.rb @@ -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 @@ -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 @@ -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) + else + TCPSocket.new(@host, @port) + end @socks = [sock] @senders = {} @reusable = true diff --git a/test/resolv/test_dns.rb b/test/resolv/test_dns.rb index b8ecef2..f363c82 100644 --- a/test/resolv/test_dns.rb +++ b/test/resolv/test_dns.rb @@ -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