From 57629cb9b0448215cc5d4f4b6f3b4b6466bfabde Mon Sep 17 00:00:00 2001 From: HoneyryderChuck Date: Mon, 22 Sep 2025 12:58:41 +0100 Subject: [PATCH 1/2] fix: do not check if multi-homed network at boot time, instead do it a runtime this requires calling , which calls the syscall, which may be blocked at boot time in certain constrained environments this is moved into a module function, which memoizes the result; it's done in a non-thread safe way, but since the result is expected to be the same, it should be ok to race --- lib/httpx/options.rb | 16 +--------------- lib/httpx/resolver.rb | 15 +++++++++++++++ lib/httpx/resolver/multi.rb | 4 +++- lib/httpx/resolver/resolver.rb | 18 ++++++++++++------ lib/httpx/resolver/system.rb | 6 ++++-- sig/options.rbs | 4 ++-- sig/resolver.rbs | 2 ++ test/support/servlets/by_ip_cert_server.rb | 3 +-- 8 files changed, 40 insertions(+), 28 deletions(-) diff --git a/lib/httpx/options.rb b/lib/httpx/options.rb index e10b0230..06371b71 100644 --- a/lib/httpx/options.rb +++ b/lib/httpx/options.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "socket" - module HTTPX # Contains a set of options which are passed and shared across from session to its requests or # responses. @@ -414,18 +412,6 @@ module HTTPX end end - # https://github.com/ruby/resolv/blob/095f1c003f6073730500f02acbdbc55f83d70987/lib/resolv.rb#L408 - ip_address_families = begin - list = Socket.ip_address_list - if list.any? { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? } - [Socket::AF_INET6, Socket::AF_INET] - else - [Socket::AF_INET] - end - rescue NotImplementedError - [Socket::AF_INET] - end.freeze - DEFAULT_OPTIONS = { :max_requests => Float::INFINITY, :debug => nil, @@ -470,7 +456,7 @@ module HTTPX :resolver_class => (ENV["HTTPX_RESOLVER"] || :native).to_sym, :resolver_options => { cache: true }.freeze, :pool_options => EMPTY_HASH, - :ip_families => ip_address_families, + :ip_families => nil, :close_on_fork => false, }.freeze end diff --git a/lib/httpx/resolver.rb b/lib/httpx/resolver.rb index 6094ace2..cefab220 100644 --- a/lib/httpx/resolver.rb +++ b/lib/httpx/resolver.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "socket" require "resolv" module HTTPX @@ -22,6 +23,20 @@ module HTTPX module_function + def supported_ip_families + @supported_ip_families ||= begin + # https://github.com/ruby/resolv/blob/095f1c003f6073730500f02acbdbc55f83d70987/lib/resolv.rb#L408 + list = Socket.ip_address_list + if list.any? { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? } + [Socket::AF_INET6, Socket::AF_INET] + else + [Socket::AF_INET] + end + rescue NotImplementedError + [Socket::AF_INET] + end.freeze + end + def resolver_for(resolver_type, options) case resolver_type when Symbol diff --git a/lib/httpx/resolver/multi.rb b/lib/httpx/resolver/multi.rb index 61828294..898ed0dd 100644 --- a/lib/httpx/resolver/multi.rb +++ b/lib/httpx/resolver/multi.rb @@ -15,7 +15,9 @@ module HTTPX @options = options @resolver_options = @options.resolver_options - @resolvers = options.ip_families.map do |ip_family| + ip_families = options.ip_families || Resolver.supported_ip_families + + @resolvers = ip_families.map do |ip_family| resolver = resolver_type.new(ip_family, options) resolver.multi = self resolver diff --git a/lib/httpx/resolver/resolver.rb b/lib/httpx/resolver/resolver.rb index 46db4d75..fec47003 100644 --- a/lib/httpx/resolver/resolver.rb +++ b/lib/httpx/resolver/resolver.rb @@ -79,12 +79,18 @@ module HTTPX "answer #{connection.peer.host}: #{addresses.inspect} (early resolve: #{early_resolve})" end - if !early_resolve && # do not apply resolution delay for non-dns name resolution - @current_selector && # just in case... - family == Socket::AF_INET && # resolution delay only applies to IPv4 - !connection.io && # connection already has addresses and initiated/ended handshake - connection.options.ip_families.size > 1 && # no need to delay if not supporting dual stack IP - addresses.first.to_s != connection.peer.host.to_s # connection URL host is already the IP (early resolve included perhaps?) + # do not apply resolution delay for non-dns name resolution + if !early_resolve && + # just in case... + @current_selector && + # resolution delay only applies to IPv4 + family == Socket::AF_INET && + # connection already has addresses and initiated/ended handshake + !connection.io && + # no need to delay if not supporting dual stack / multi-homed IP + (connection.options.ip_families || Resolver.supported_ip_families).size > 1 && + # connection URL host is already the IP (early resolve included perhaps?) + addresses.first.to_s != connection.peer.host.to_s log { "resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: applying resolution delay..." } @current_selector.after(0.05) do diff --git a/lib/httpx/resolver/system.rb b/lib/httpx/resolver/system.rb index dd700d26..a8c5ba84 100644 --- a/lib/httpx/resolver/system.rb +++ b/lib/httpx/resolver/system.rb @@ -187,7 +187,9 @@ module HTTPX transition(:open) - connection.options.ip_families.each do |family| + ip_families = connection.options.ip_families || Resolver.supported_ip_families + + ip_families.each do |family| @queries << [family, connection] end async_resolve(connection, hostname, scheme) @@ -195,7 +197,7 @@ module HTTPX end def async_resolve(connection, hostname, scheme) - families = connection.options.ip_families + families = connection.options.ip_families || Resolver.supported_ip_families log { "resolver: query for #{hostname}" } timeouts = @timeouts[connection.peer.host] resolve_timeout = timeouts.first diff --git a/sig/options.rbs b/sig/options.rbs index 6de7200c..59106ad4 100644 --- a/sig/options.rbs +++ b/sig/options.rbs @@ -130,7 +130,7 @@ module HTTPX attr_reader pool_options: pool_options # ip_families - attr_reader ip_families: Array[ip_family] + attr_reader ip_families: Array[ip_family]? def ==: (Options other) -> bool @@ -195,7 +195,7 @@ module HTTPX def option_addresses: (ipaddr | _ToAry[ipaddr] value) -> Array[ipaddr] - def option_ip_families: (Integer | _ToAry[Integer] value) -> Array[Integer] + def option_ip_families: (ip_family | _ToAry[ip_family] value) -> Array[ip_family] end type options = Options | Hash[Symbol, untyped] diff --git a/sig/resolver.rbs b/sig/resolver.rbs index 44f7cf4a..e04c992c 100644 --- a/sig/resolver.rbs +++ b/sig/resolver.rbs @@ -23,6 +23,8 @@ module HTTPX def self?.hosts_resolve: (String hostname) -> Array[Entry]? + def self?.supported_ip_families: () -> Array[ip_family] + def self?.resolver_for: (Symbol | singleton(Resolver) resolver_type, Options options) -> singleton(Resolver) def self?.cached_lookup: (String hostname) -> Array[Entry]? diff --git a/test/support/servlets/by_ip_cert_server.rb b/test/support/servlets/by_ip_cert_server.rb index f9227f02..6a5562bb 100644 --- a/test/support/servlets/by_ip_cert_server.rb +++ b/test/support/servlets/by_ip_cert_server.rb @@ -3,14 +3,13 @@ require_relative "test" class ByIpCertServer < TestServer - USE_IPV6 = HTTPX::Options::DEFAULT_OPTIONS[:ip_families].size > 1 CERTS_DIR = File.expand_path("../ci/certs", __dir__) def initialize cert = OpenSSL::X509::Certificate.new(File.read(File.join(CERTS_DIR, "localhost-server.crt"))) key = OpenSSL::PKey.read(File.read(File.join(CERTS_DIR, "localhost-server.key"))) super( - :BindAddress => USE_IPV6 ? "::1" : "127.0.0.1", + :BindAddress => HTTPX::Resolver.supported_ip_families.size > 1 ? "::1" : "127.0.0.1", :SSLEnable => true, :SSLCertificate => cert, :SSLPrivateKey => key, From 861e6ca94b7ea7597f11e1f6c0858203e2e93ec5 Mon Sep 17 00:00:00 2001 From: HoneyryderChuck Date: Mon, 22 Sep 2025 13:07:00 +0100 Subject: [PATCH 2/2] native resolver: do not rely on resolver supported ip families if options for ip_families is explicitly used option from user takes precedence --- lib/httpx/resolver/multi.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/httpx/resolver/multi.rb b/lib/httpx/resolver/multi.rb index 898ed0dd..dd25b2d3 100644 --- a/lib/httpx/resolver/multi.rb +++ b/lib/httpx/resolver/multi.rb @@ -69,8 +69,12 @@ module HTTPX addresses = @resolver_options[:cache] && (connection.addresses || HTTPX::Resolver.nolookup_resolve(hostname)) return false unless addresses + ip_families = connection.options.ip_families || Resolver.supported_ip_families + resolved = false addresses.group_by(&:family).sort { |(f1, _), (f2, _)| f2 <=> f1 }.each do |family, addrs| + next unless ip_families.include?(family) + # try to match the resolver by family. However, there are cases where that's not possible, as when # the system does not have IPv6 connectivity, but it does support IPv6 via loopback/link-local. resolver = @resolvers.find { |r| r.family == family } || @resolvers.first @@ -87,7 +91,11 @@ module HTTPX end def lazy_resolve(connection) + ip_families = connection.options.ip_families || Resolver.supported_ip_families + @resolvers.each do |resolver| + next unless ip_families.include?(resolver.family) + resolver << @current_session.try_clone_connection(connection, @current_selector, resolver.family) next if resolver.empty?