#1939·resque

macOS 26 Tahoe: forked workers hang at 100% CPU resolving DNS (os_log/NAT64 fork bug)

Author: AdamoffatCreated Jun 18, 2026Updated Jun 18, 2026

Summary

On macOS 26 (Tahoe), a Resque worker that resolves DNS after being forked hangs at 100% CPU. Same family as #1630 (getaddrinfo isn't fork-safe) but a different mechanism: the child's first getaddrinfo (AF_UNSPEC, IPv4-only host) faults on os_log state inherited invalid across fork:

_os_log_preferences_refresh   (libsystem_trace)   <- EXC_BAD_ACCESS
os_log_type_enabled
nw_path_access_agent_cache / nw_nat64_v4_address_requires_synthesis   (Network)
_gai_nat64_second_pass        (libsystem_info)
getaddrinfo

Ruby catches the SIGSEGV on its DNS helper thread but can't recover, so the worker spins. Affects macOS 26.0–26.5.1 and the 27 beta; not macOS 15 or earlier. (Ruby 2.6 and Python crash outright instead of hanging.)

Why it regressed now

Calling getaddrinfo between fork and exec has always been technically unsupported, but it worked through macOS 15. macOS keeps tightening fork-safety (the ObjC runtime in 10.13, Python defaulting to spawn); Tahoe's NAT64/os_log changes are the latest step. Apple DTS confirmed it's working-as-intended and won't be fixed (Feedback FB21364061, forum thread); Ruby closed their report as third-party (#21790). Pre-forking workers can no longer assume the system resolver is fork-safe.

Why this is distinct from #1630

#1630 is the glibc NSS-lock race: a background thread holds the resolver lock at the instant of fork, so the fix is to make sure no thread is mid-lookup when forking. Tahoe isn't a lock race: even a single-threaded parent with nothing else running reproduces it, as long as it initialized os_log before forking (any earlier DNS lookup does that). There's no in-flight thread or held lock to avoid, so the #1630 / Pitchfork #67 approach doesn't cover it.

Reproduction

Run under bundle exec, the way Resque workers are normally started (bundle exec rake resque:work):

ruby
# dns_fork_repro.rb
require "socket"
Socket.getaddrinfo("api.stripe.com", 443, nil, :STREAM)               # parent, IPv4-only host
pid = fork { Socket.getaddrinfo("api.stripe.com", 443, nil, :STREAM) } # child hangs at 100% CPU
Process.wait(pid)
bundle exec ruby dns_fork_repro.rb

bundle exec matters: the trigger is os_log initialized in the parent before fork, and the exec from bundler's Ruby carries that state in. A bare ruby from a clean shell may not reproduce, so don't treat that as a negative. Plain C (os_log_create + getaddrinfo + fork + getaddrinfo) reproduces with no Ruby/bundler, confirming it's at the OS level. Only AF_UNSPEC lookups of IPv4-only hosts are affected; IPv6-capable hosts, AF_INET hints, and numeric literals are immune.

Workarounds (Tahoe dev machines)

  • require "resolv-replace" before any DNS - bypasses the native resolver, but ignores macOS scoped resolvers, so it can break VPN/internal-hostname DNS; or
  • OS_ACTIVITY_MODE=disable in the worker environment - keeps the native resolver; the os_log enabled-check short-circuits before the fault.

Happy to open a PR to add a note to README/troubleshooting with some of the workarounds I listed above. Related: #1630, #1101.