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.
For the same IPv6 address, JRuby returns two different textual forms depending on which API you ask:
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, becauseresolv— 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:0to::; this asks for the same treatment for every other compressible address.Reproducer
No network and no DNS — it sends one datagram to
::1and inspects what comes back.addr[3](for::)recvfrom[3](for::1)Addrinfo#ip_address"::""::1""::1""::""::1""::1""0:0:0:0:0:0:0:0""0:0:0:0:0:0:0:1""::1""::""0:0:0:0:0:0:0:1""::1""::""0:0:0:0:0:0:0:1""::1""::""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#addrForbuilds the address fields straight from Java'sInetAddress#getHostAddress(), which never compresses. PR #8255 (for #8183, 9.4.8.0) added a special case for one literal: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_addressdoes not have this problem because it routes throughipv6_ip()→SocketUtilsIPV6.getIPV6Address(), which compresses properly.Impact: Resolv::DNS hangs when resolv.conf lists 2+ nameservers
resolvstores 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, whichResolvselects when two or more nameservers are configured; a single nameserver usesConnectedUDP, 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). EachResolvlookup 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.confmakes no difference, sincesendernormalises throughAddrinfo.ip(host).ip_addressbefore 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.rbto compare addresses asIPAddrobjects.resolvis now the upstreamruby/resolvgem 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
addrForcanonicalise throughSocketUtilsIPV6.getIPV6Address(), the wayAddrinfo#ip_addressalready does, rather than comparing against a single literal. Happy to put up a PR if that approach looks right to you.