|
# frozen_string_literal: true
|
|
|
|
#
|
|
# Ruby Bug: Addrinfo.getaddrinfo(AF_UNSPEC) deadlocks after fork on macOS
|
|
# for hostnames with no AAAA (IPv6) records.
|
|
#
|
|
# The bug is probabilistic — it depends on mDNSResponder's internal state at
|
|
# the moment of fork. More parent-side DNS activity increases the probability.
|
|
# This script runs multiple trials in an attempt to demonstrate the issue.
|
|
#
|
|
# Usage:
|
|
# ruby ruby_getaddrinfo_fork_bug.rb
|
|
#
|
|
|
|
require 'socket'
|
|
require 'timeout'
|
|
|
|
NUM_TRIALS = 50
|
|
|
|
puts '=' * 70
|
|
puts 'getaddrinfo(AF_UNSPEC) deadlock after fork on macOS'
|
|
puts '=' * 70
|
|
puts
|
|
puts "Ruby: #{RUBY_DESCRIPTION}"
|
|
puts "Platform: #{RUBY_PLATFORM}"
|
|
puts "PID: #{Process.pid}"
|
|
puts
|
|
|
|
def fork_test(timeout: 6)
|
|
rd, wr = IO.pipe
|
|
pid = fork do
|
|
rd.close
|
|
begin
|
|
wr.write(yield)
|
|
rescue StandardError => error
|
|
wr.write("EXCEPTION:#{error.class}:#{error.message}")
|
|
ensure
|
|
wr.close
|
|
end
|
|
end
|
|
wr.close
|
|
begin
|
|
out = Timeout.timeout(timeout + 2) do
|
|
Process.waitpid(pid)
|
|
rd.read
|
|
end
|
|
rescue Timeout::Error
|
|
begin
|
|
Process.kill('KILL', pid)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
begin
|
|
Process.waitpid(pid)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
out = 'DEADLOCK'
|
|
end
|
|
rd.close
|
|
out
|
|
end
|
|
|
|
# Test 1: Resolve an IPv4-only host, fork, child resolves same host
|
|
puts "--- Test 1: getaddrinfo(httpbin.org, AF_UNSPEC) — #{NUM_TRIALS} trials ---"
|
|
puts '(httpbin.org has NO AAAA records)'
|
|
puts
|
|
|
|
deadlocks = 0
|
|
NUM_TRIALS.times do
|
|
# Resolve in parent (or re-resolve — each trial is a fresh getaddrinfo call)
|
|
Addrinfo.getaddrinfo('httpbin.org', 'https', Socket::AF_UNSPEC, Socket::SOCK_STREAM)
|
|
|
|
result = fork_test do
|
|
Timeout.timeout(5) do
|
|
Addrinfo.getaddrinfo('httpbin.org', 'https', Socket::AF_UNSPEC, Socket::SOCK_STREAM)
|
|
end
|
|
'OK'
|
|
rescue Timeout::Error
|
|
'DEADLOCK'
|
|
end
|
|
|
|
dl = result == 'DEADLOCK'
|
|
deadlocks += 1 if dl
|
|
$stdout.write(dl ? 'X' : '.')
|
|
$stdout.flush
|
|
end
|
|
|
|
puts
|
|
puts " Result: #{deadlocks}/#{NUM_TRIALS} deadlocked"
|
|
puts
|
|
|
|
# Test 2: Increase probability with more DNS activity — resolve multiple hosts before forking
|
|
puts "--- Test 2: Resolve multiple hosts first, then fork — #{NUM_TRIALS} trials ---"
|
|
puts
|
|
|
|
deadlocks2 = 0
|
|
NUM_TRIALS.times do
|
|
# Resolve several hosts (mix of IPv4-only and dual-stack) to increase
|
|
# mDNSResponder internal state complexity
|
|
%w[httpbin.org www.github.com api.github.com www.google.com rubygems.org
|
|
example.com www.cloudflare.com stackoverflow.com].each do |h|
|
|
Addrinfo.getaddrinfo(h, 'https', Socket::AF_UNSPEC, Socket::SOCK_STREAM)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
|
|
result = fork_test do
|
|
Timeout.timeout(5) do
|
|
Addrinfo.getaddrinfo('httpbin.org', 'https', Socket::AF_UNSPEC, Socket::SOCK_STREAM)
|
|
end
|
|
'OK'
|
|
rescue Timeout::Error
|
|
'DEADLOCK'
|
|
end
|
|
|
|
dl = result == 'DEADLOCK'
|
|
deadlocks2 += 1 if dl
|
|
$stdout.write(dl ? 'X' : '.')
|
|
$stdout.flush
|
|
end
|
|
|
|
puts
|
|
puts " Result: #{deadlocks2}/#{NUM_TRIALS} deadlocked"
|
|
puts
|
|
|
|
# Test 3: Control — dual-stack host (www.google.com has AAAA records)
|
|
puts "--- Test 3: Control — dual-stack host (www.google.com) — #{NUM_TRIALS} trials ---"
|
|
puts
|
|
|
|
deadlocks3 = 0
|
|
NUM_TRIALS.times do
|
|
%w[httpbin.org www.github.com www.google.com rubygems.org].each do |h|
|
|
Addrinfo.getaddrinfo(h, 'https', Socket::AF_UNSPEC, Socket::SOCK_STREAM)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
|
|
result = fork_test do
|
|
Timeout.timeout(5) do
|
|
Addrinfo.getaddrinfo('www.google.com', 'https', Socket::AF_UNSPEC, Socket::SOCK_STREAM)
|
|
end
|
|
'OK'
|
|
rescue Timeout::Error
|
|
'DEADLOCK'
|
|
end
|
|
|
|
dl = result == 'DEADLOCK'
|
|
deadlocks3 += 1 if dl
|
|
$stdout.write(dl ? 'X' : '.')
|
|
$stdout.flush
|
|
end
|
|
|
|
puts
|
|
puts " Result: #{deadlocks3}/#{NUM_TRIALS} deadlocked"
|
|
puts
|
|
|
|
# Test 4: Workaround — AF_INET avoids the deadlock
|
|
puts "--- Test 4: Workaround — AF_INET instead of AF_UNSPEC — #{NUM_TRIALS} trials ---"
|
|
puts
|
|
|
|
deadlocks4 = 0
|
|
NUM_TRIALS.times do
|
|
# Use AF_UNSPEC first
|
|
%w[httpbin.org www.github.com api.github.com].each do |h|
|
|
Addrinfo.getaddrinfo(h, 'https', Socket::AF_UNSPEC, Socket::SOCK_STREAM)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
|
|
result = fork_test do
|
|
Timeout.timeout(5) do
|
|
# Use AF_INET instead of AF_UNSPEC
|
|
Addrinfo.getaddrinfo('httpbin.org', 'https', Socket::AF_INET, Socket::SOCK_STREAM)
|
|
end
|
|
'OK'
|
|
rescue Timeout::Error
|
|
'DEADLOCK'
|
|
end
|
|
|
|
dl = result == 'DEADLOCK'
|
|
deadlocks4 += 1 if dl
|
|
$stdout.write(dl ? 'X' : '.')
|
|
$stdout.flush
|
|
end
|
|
|
|
puts
|
|
puts " Result: #{deadlocks4}/#{NUM_TRIALS} deadlocked"
|
|
puts
|
|
|
|
# Summary
|
|
puts '=' * 70
|
|
puts 'SUMMARY'
|
|
puts '=' * 70
|
|
puts
|
|
puts " Test 1 (single IPv4-only host): #{deadlocks}/#{NUM_TRIALS} deadlocked"
|
|
puts " Test 2 (multi-host warmup): #{deadlocks2}/#{NUM_TRIALS} deadlocked"
|
|
puts " Test 3 (dual-stack host control): #{deadlocks3}/#{NUM_TRIALS} deadlocked"
|
|
puts " Test 4 (AF_INET workaround): #{deadlocks4}/#{NUM_TRIALS} deadlocked"
|
|
puts
|
|
|
|
if (deadlocks + deadlocks2).positive? && (deadlocks3 + deadlocks4).zero?
|
|
puts 'BUG CONFIRMED: getaddrinfo(AF_UNSPEC) deadlocks after fork for'
|
|
puts 'IPv4-only hosts. Dual-stack hosts and AF_INET are not affected.'
|
|
elsif (deadlocks + deadlocks2).positive?
|
|
puts 'BUG REPRODUCED with some anomalies — see individual results.'
|
|
else
|
|
puts 'Bug did not reproduce in this run. Try running again — the issue'
|
|
puts 'is probabilistic and depends on mDNSResponder internal timing.'
|
|
end
|