Skip to content

Socket address APIs return uncompressed IPv6 where Addrinfo#ip_address compresses, breaking Resolv::DNS with 2+ nameservers #9550

Description

@gallardo

For the same IPv6 address, JRuby returns two different textual forms depending on which API you ask:

Addrinfo.ip('::1').ip_address   # => "::1"
udp_socket.recvfrom(16)[1][3]   # => "0:0:0:0:0:0:0:1"

Ruby does not document a required format for the address fields of IPSocket#addr / UDPSocket#recvfrom, so this is not obviously a violation of a written spec. But the two forms must agree with each other, because resolv — a default gem — uses the first to build a key and the second to look it up (Requester::UnconnectedUDP#sender / #sender_for). On JRuby they never match, so every DNS reply is discarded as unsolicited and each lookup burns its full timeout.

For reference, MRI returns the compressed form from both, and RFC 5952 §4.2.1 defines maximal compression as the canonical text representation. PR #8255 already took that view for the unspecified address, changing 0:0:0:0:0:0:0:0 to ::; this asks for the same treatment for every other compressible address.

Reproducer

No network and no DNS — it sends one datagram to ::1 and inspects what comes back.

require 'socket'

unspec = UDPSocket.new(Socket::AF_INET6); unspec.bind('::', 0)
rx = UDPSocket.new(Socket::AF_INET6); rx.do_not_reverse_lookup = true; rx.bind('::1', 0)
tx = UDPSocket.new(Socket::AF_INET6); tx.send('x', 0, '::1', rx.addr[1])

puts "addr[3]     = #{unspec.addr[3].inspect}"
puts "recvfrom[3] = #{rx.recvfrom(16)[1][3].inspect}"
puts "Addrinfo    = #{Addrinfo.ip('::1').ip_address.inspect}"
engine addr[3] (for ::) recvfrom[3] (for ::1) Addrinfo#ip_address
MRI 3.1.7 "::" "::1" "::1"
MRI 3.4.10 "::" "::1" "::1"
JRuby 9.4.7.0 "0:0:0:0:0:0:0:0" "0:0:0:0:0:0:0:1" "::1"
JRuby 9.4.8.0 "::" "0:0:0:0:0:0:0:1" "::1"
JRuby 9.4.15.0 "::" "0:0:0:0:0:0:0:1" "::1"
JRuby 10.1.1.0 "::" "0:0:0:0:0:0:0:1" "::1"

Also reproduced on 9.4.0.0, 9.4.5.0 and 9.4.12.0. The MRI runs were done in the same Linux container image family (ruby:3.1-slim, ruby:3.4-slim) as the JRuby ones (jruby:*) to rule out platform effects. JRuby 9.4.x was tested on Java 17, 10.1.1.0 on Java 21.

Where it comes from

RubyBasicSocket#addrFor builds the address fields straight from Java's InetAddress#getHostAddress(), which never compresses. PR #8255 (for #8183, 9.4.8.0) added a special case for one literal:

ret3 = ipV6 && hostAddress.equals("0:0:0:0:0:0:0:0") ?
        runtime.newString("::") :
        runtime.newString(hostAddress);

UDPSocket#recvfrom (RubyUDPSocket.java:471) and #recvfrom_nonblock (:291) both go through that same method, so any peer address other than the unspecified one still comes back expanded.

Addrinfo#ip_address does not have this problem because it routes through ipv6_ip()SocketUtilsIPV6.getIPV6Address(), which compresses properly.

Impact: Resolv::DNS hangs when resolv.conf lists 2+ nameservers

resolv stores each pending query under [[Addrinfo.ip(host).ip_address, port], id] and looks the reply up under [[recvfrom_addr, port], id]. Those keys never match on JRuby, so replies are dropped and every lookup runs to timeout.

This affects only Requester::UnconnectedUDP, which Resolv selects when two or more nameservers are configured; a single nameserver uses ConnectedUDP, which keys on the message id alone and is unaffected. That is why the bug looks intermittent across deployments.

We hit this in production on Puppet Server (JRuby 9.4.15.0, Java 17, two IPv6 nameservers in /etc/resolv.conf). Each Resolv lookup took 160 s instead of ~10 ms. Catalog compilation performs about a dozen lookups, so every affected compile held a JRuby instance for ~30 minutes until the pool was exhausted, and unrelated nodes started timing out against the server.

Note also that the address notation written in resolv.conf makes no difference, since sender normalises through Addrinfo.ip(host).ip_address before building the key. The only workaround we found is configuring a single nameserver, which costs DNS redundancy.

Prior art

#3663 reported the same symptom in 2016 and was fixed by PR #4496, which patched JRuby's bundled resolv.rb to compare addresses as IPAddr objects. resolv is now the upstream ruby/resolv gem shared with MRI and carries no such normalisation, so that mitigation is no longer in play and the fix has to live on the socket side.

Suggested fix

Have addrFor canonicalise through SocketUtilsIPV6.getIPV6Address(), the way Addrinfo#ip_address already does, rather than comparing against a single literal. Happy to put up a PR if that approach looks right to you.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions