Project

General

Profile

Bug #21876 » ruby_getaddrinfo_fork_bug.rb

nbeyer@gmail.com (Nathan Beyer), 02/13/2026 12:43 AM

 
# 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
    (1-1/1)